Why I Stopped Writing Unit Tests for Spring Boot APIs — And Started Using Testcontainers

📅 April 30, 2026
Why I Stopped Writing Unit Tests for Spring Boot APIs — And Started Using Testcontainers
👁 ... views

I deleted 400 lines of mock setup from a Spring Boot project last week. The @MockBean annotations, the when().thenReturn() chains, the ArgumentCaptor gymnastics — all of it. My test suite went from 147 tests to 52. My CI time dropped from 8 minutes to 4. And my bug catch rate went up.

If you’ve spent any time in the Java testing world, this probably sounds like heresy. Unit tests are supposed to be the foundation of the testing pyramid. Mock everything. Test in isolation. Keep it fast.

I followed that advice for a decade. And it cost me.

The Mocking Lie We Tell Ourselves

Here’s what a “proper” unit test for a Spring Boot service looked like in my codebase:

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private PasswordEncoder passwordEncoder;

    @Mock
    private EmailService emailService;

    @InjectMocks
    private UserService userService;

    @Test
    void shouldCreateUser() {
        when(passwordEncoder.encode("plain")).thenReturn("hashed");
        when(userRepository.save(any(User.class)))
            .thenAnswer(invocation -> {
                User u = invocation.getArgument(0);
                u.setId(1L);
                return u;
            });
        when(emailService.sendWelcomeEmail(any()))
            .thenReturn(true);

        User created = userService.createUser(
            new CreateUserRequest("aymen", "[email protected]", "plain")
        );

        assertThat(created.getId()).isNotNull();
        assertThat(created.getPassword()).isEqualTo("hashed");
        verify(emailService).sendWelcomeEmail(any());
    }
}

This test passes. It’s fast. It’s “clean.” And it tells me absolutely nothing about whether my code actually works.

The mock returns what I tell it to return. The ArgumentCaptor confirms my code calls the mock the way I told it to. I’m testing my test setup, not my application. This is the mocking lie: we convince ourselves we’re testing business logic, but we’re really just verifying that our mocks behave the way we programmed them to.

The real bugs I’ve shipped to production never came from logic errors in service methods. They came from:

  • SQL queries that worked on H2 but failed on PostgreSQL (looking at you, DATE_TRUNC)
  • Transaction boundaries that were wrong (@Transactional on the wrong class)
  • JSON serialization quirks (Jackson @JsonFormat timezone bugs)
  • Validation constraints that didn’t fire (Bean Validation on nested objects)

None of these are caught by unit tests with mocked dependencies. Not one.

Enter Testcontainers

Testcontainers spins up real Docker containers for your tests. Real PostgreSQL. Real Redis. Real Kafka. Not in-memory fakes. Not mocks. The actual thing your application talks to in production.

The setup is straightforward. Here’s my base test class for a Spring Boot API:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
abstract class BaseIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test")
        .withInitScript("schema/init.sql")
        .withReuse(true); // <-- key for speed

    @DynamicPropertySource
    static void overrideProps(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    protected TestRestTemplate restTemplate;

    @Autowired
    protected UserRepository userRepository;

    @Autowired
    protected EntityManager entityManager;
}

The withReuse(true) flag is critical. Without it, every test class spins up a fresh container and your CI takes forever. With it, Testcontainers keeps the container alive across test runs. My local test suite goes from 90 seconds to 25 seconds on the second run.

The init.sql handles schema setup. I don’t use Flyway migrations in tests — they add 15 seconds to every run. I generate the schema once with Hibernate’s ddl-auto=create and dump it:

# Run once, save the output
./gradlew bootRun --args="--spring.jpa.hibernate.ddl-auto=create"
pg_dump -U test -d testdb --schema-only > src/test/resources/schema/init.sql

Then point your test container at it. The schema is always in sync with your entities, and you skip the migration overhead.

Real Tests That Catch Real Bugs

Here’s what an integration test looks like for a user creation endpoint:

class UserApiIntegrationTest extends BaseIntegrationTest {

    @Test
    void shouldCreateUserAndPersistToDatabase() {
        var request = new CreateUserRequest("aymen", "[email protected]", "plain123");

        ResponseEntity<UserResponse> response = restTemplate
            .postForEntity("/api/users", request, UserResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody().id()).isNotNull();
        assertThat(response.getBody().email()).isEqualTo("[email protected]");
        // Password should NOT be returned
        assertThat(response.getBody().passwordHash()).isNull();

        // Verify it's actually in the database
        Optional<User> saved = userRepository.findByEmail("[email protected]");
        assertThat(saved).isPresent();
        assertThat(saved.get().getPasswordHash()).isNotEqualTo("plain123");
    }

    @Test
    void shouldReturn409ForDuplicateEmail() {
        var request = new CreateUserRequest("aymen", "[email protected]", "pass1");
        restTemplate.postForEntity("/api/users", request, UserResponse.class);

        var duplicate = new CreateUserRequest("aymen2", "[email protected]", "pass2");
        ResponseEntity<ErrorResponse> response = restTemplate
            .postForEntity("/api/users", duplicate, ErrorResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
    }

    @Test
    void shouldApplyUniqueConstraintFromDatabase() {
        // Bypass the service layer entirely
        userRepository.save(new User("direct", "[email protected]", "hash"));
        entityManager.flush();

        // The constraint should still fire
        assertThatThrownBy(() -> {
            userRepository.save(new User("direct2", "[email protected]", "hash2"));
            entityManager.flush(); // forces the constraint check
        }).isInstanceOf(DataIntegrityViolationException.class);
    }
}

The third test is the one that would have never passed with mocks. It verifies that the database constraint fires even when the application-level validation is bypassed. This is a real production bug I shipped in 2023 — a direct SQL insert hit a duplicate that the service layer never checked because it assumed the unique constraint was the safety net. With mocks, that bug is invisible. With Testcontainers, it fails immediately.

The Transaction Test That Mocks Can’t Replicate

This is where integration tests absolutely demolish unit tests. Spring’s @Transactional behavior is notoriously hard to test with mocks because the proxy-based transaction management doesn’t activate when you call methods directly.

@Test
void shouldRollbackOnServiceFailure() {
    // Create a user that will pass validation but fail during email
    var request = new CreateUserRequest("rollback-test", "[email protected]", "pass");

    // Configure the email service to fail (we'll wire this in via @TestConfiguration)
    ResponseEntity<ErrorResponse> response = restTemplate
        .postForEntity("/api/users/failing-email", request, ErrorResponse.class);

    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);

    // The user should NOT exist — transaction rolled back
    Optional<User> saved = userRepository.findByEmail("[email protected]");
    assertThat(saved).isEmpty();
}

This test verifies the entire transaction boundary: controller → service → repository → database. If @Transactional is missing, misconfigured, or placed on the wrong class, this test fails. No mock setup can replicate this because the transaction manager is a runtime proxy that only activates through the real Spring context.

I’ve caught three different transaction bugs with this pattern:

  1. @Transactional on a private method (Spring proxies only work on public)
  2. Missing @Transactional on a batch operation (half the records saved, half failed)
  3. Wrong isolation level causing dirty reads in a concurrent test scenario

None of these would have been caught by UserServiceTest with Mockito.

What About Test Speed?

The biggest objection I hear: “Integration tests are slow.” Let me show you my actual numbers:

Test TypeCountTotal TimeAvg Per Test
Old unit tests (Mockito)1478 min 12 sec3.3 sec
New integration tests524 min 08 sec4.8 sec

Yes, each integration test is slower. But I have fewer of them, and they’re testing the actual system. The total CI time is half what it was. And the 52 integration tests give me more confidence than the 147 unit tests ever did.

The container reuse (withReuse(true)) does most of the heavy lifting here. After the first run, the PostgreSQL container stays alive in Docker. Subsequent test runs connect to the existing container in about 2 seconds. The @SpringBootTest context also caches between test classes.

The One Thing I Still Unit Test

I’m not saying unit tests are dead. I still write them for pure logic — algorithms that have no external dependencies. If you have a pricing calculator, a date formatter, or a validation rule that doesn’t touch a database, a unit test makes sense:

@Test
void shouldCalculateTierDiscount() {
    // Pure logic, no dependencies
    PricingService pricing = new PricingService();

    assertThat(pricing.calculateDiscount("enterprise", 500))
        .isEqualTo(BigDecimal.valueOf(0.25));
    assertThat(pricing.calculateDiscount("starter", 10))
        .isEqualTo(BigDecimal.valueOf(0.05));
    assertThat(pricing.calculateDiscount("starter", 5))
        .isEqualTo(BigDecimal.ZERO);
}

This test is fast, deterministic, and tests actual business logic. There are no mocks because there are no dependencies. This is what unit testing should be — testing pure computation in isolation. Everything else? Integration test.

The Counter-Arguments

“Integration tests are flaky.” — They can be, if you treat them like unit tests. Don’t share state between tests. Use @Transactional on each test method to roll back after execution. Clean up in @AfterEach. My integration tests haven’t been flaky in two years because I follow these rules religiously.

“Docker isn’t available in CI.” — GitHub Actions, GitLab CI, and CircleCI all support Docker. If your CI environment doesn’t have Docker, you have bigger problems than testing. For local development, Docker Desktop is free for personal use. The barrier to entry is near zero in 2026.

“We have a separate QA environment.” — Great. Does QA catch the transaction rollback bug before production? Because that’s the kind of thing that slips through manual testing every single time. Integration tests in CI catch it on every commit.

My Testing Pyramid, Actually

The classic testing pyramid says: lots of unit tests, fewer integration tests, fewest end-to-end tests. I inverted it for Spring Boot APIs:

        ┌─────────┐
        │   E2E   │  ← Smoke tests only (5-10)
        ├─────────┤
        │   API   │  ← Integration tests (50-100)
        │ (real DB)│
        ├─────────┤
        │  Logic  │  ← Pure unit tests (10-20)
        └─────────┘

Most of the application lives in the middle layer: API integration tests with real databases. The top layer is a handful of smoke tests that hit the running application. The bottom layer is small — just pure logic.

This matches how Spring Boot actually works. The framework handles the heavy lifting (dependency injection, transaction management, serialization). Your business logic is usually a thin layer on top. Testing that thin layer in isolation gives you a false sense of security. Testing the full stack gives you real confidence.

Bottom Line

I spent years writing unit tests that verified my mocks behaved correctly. I caught zero production bugs that way. Since switching to Testcontainers integration tests, I’ve caught transaction boundary bugs, constraint violations, serialization issues, and migration failures — the kinds of bugs that actually matter.

The trade-off is real: each test is slower, you need Docker, and the setup takes an afternoon. But the payoff is a test suite that actually tests your application, not your mock configuration. And with container reuse, the speed difference is smaller than you think.

What’s your testing strategy for Spring Boot APIs? Are you still on the mocking pyramid, or have you made the switch to real dependencies?



📚 Continue Reading

Supporting the blog through affiliate links (at no extra cost to you):

💡

Enjoying the content? Here are tools I personally use and recommend:

  • 🌐 Hosting: Bluehost — what this blog runs on
  • 🛒 Tech Gear: My Amazon Store — keyboards, monitors, dev tools I use

Purchases through my links help keep this blog ad-free 💙

Enjoyed this post?

Subscribe to the newsletter or follow on YouTube for more dev content.

🎬 Watch Shorts