Why I Stopped Writing Unit Tests for Spring Boot APIs — And Started Using Testcontainers
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 (
@Transactionalon the wrong class) - JSON serialization quirks (Jackson
@JsonFormattimezone 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:
@Transactionalon aprivatemethod (Spring proxies only work onpublic)- Missing
@Transactionalon a batch operation (half the records saved, half failed) - 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 Type | Count | Total Time | Avg Per Test |
|---|---|---|---|
| Old unit tests (Mockito) | 147 | 8 min 12 sec | 3.3 sec |
| New integration tests | 52 | 4 min 08 sec | 4.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?
Related Articles
- Spring Boot 3.x — What Actually Changed — migration guide for the framework this article builds on
- Docker Compose Patterns Every Developer Should Know — because Testcontainers uses Docker under the hood
- PostgreSQL Tricks I Wish I Knew 5 Years Ago — the database your integration tests will use
📚 Continue Reading
Supporting the blog through affiliate links (at no extra cost to you):
- Spring Boot in Action — comprehensive guide to Spring Boot development
- Testing Java Microservices — Testcontainers, JUnit 5, and integration testing patterns
- The Pragmatic Programmer — timeless advice for devs at any level
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