Testing Strategy: The Modern Testing Pyramid
The 3-Line Engineering Rule
Build a balanced testing pyramid: fast unit tests for domain logic, containerized integration tests (Testcontainers) for data access, and selective E2E tests.
Relying solely on unit tests with mocked databases creates a false sense of security; tests pass locally, but fail in production on real SQL dialect differences.
Test business rules in pure unit tests without Spring context (sub-millisecond). Test real queries against real PostgreSQL/Redis instances using Testcontainers in CI.
Architectural Blueprint & Sequence
Hover over nodes to inspect state transitions10% E2E Smoke Tests
Playwright / Cypress on staging (Slow)
20% Integration Tests
Testcontainers (Real Postgres & Kafka)
70% Pure Unit Tests
Pure domain logic & mockito (Sub-second)
Key Engineering Takeaways
- Never use H2 in-memory DB to test PostgreSQL-specific features (JSONB, window functions, locking).
- Keep unit tests independent of external frameworks for lightning-fast feedback.
- Use Testcontainers to spin up real Dockerized databases and Kafka in integration tests.
- Reserve expensive end-to-end (E2E) browser tests for top 5 critical user journeys.
The Problem & Production Reality
A brittle test suite slows down engineering velocity. When developers wait 45 minutes for E2E tests that randomly fail due to network timeouts, they stop running tests. A healthy testing strategy gives high confidence with minimal execution time.
Anti-Pattern: In-Memory H2 Mismatch & Heavy Mocking
Testing PostgreSQL queries against an in-memory H2 database. H2 ignores locking semantics, JSONB operators, and Postgres-specific constraints.
// ❌ ANTI-PATTERN: Test passes on H2, fails on production Postgres!
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.ANY) // Uses H2 in-memory
class OrderRepoTest {
@Test
void testLocking() {
// H2 completely ignores SELECT FOR UPDATE SKIP LOCKED
// Test passes, but deadlocks in production under concurrent traffic!
}
}Production Pattern: Real Database Testing with Testcontainers
Spin up an authentic PostgreSQL Docker container automatically during JUnit test execution.
// ✅ PRODUCTION PATTERN: Testcontainers for 100% Real PostgreSQL Fidelity
@SpringBootTest
@Testcontainers
class OrderRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private OrderRepository orderRepo;
@Test
void testConcurrentInventoryDeduction() {
// Tests real PostgreSQL row locking and constraint behavior
int updated = orderRepo.atomicDeductStock("prod_1", 1);
assertThat(updated).isEqualTo(1);
}
}Trade-off Analysis & Staff Recommendations
| Aspect | Pros / Strengths | Cons / Hazards | Staff Recommendation |
|---|---|---|---|
| Testcontainers vs In-Memory Mocks | 100% fidelity: tests exact SQL dialects, triggers, constraints, and indexes. | Takes 5-10 seconds to pull and boot Docker containers on initial test run. | Use Testcontainers with reusable mode enabled (`.withReuse(true)`) in local dev. |
| Mocking Third-Party APIs | WireMock allows simulating network delays, 500 errors, and malformed responses. | Mocks can drift if the external partner changes their API without notice. | Use WireMock for integration test suites; complement with consumer-driven contract tests (Pact). |