Spring Boot Test Strategy with Testcontainers: 4 Layers
The tests exist, yet production bugs get through — because H2 doesn't behave like PostgreSQL and mocks don't verify integrations. A four-layer test strategy for enterprise Java: unit tests, Testcontainers against a real database, contract tests and a handful of end-to-end tests, with ratios, a test-data approach and CI time targets.
There is a long distance between "we have tests" and "we don't get surprises in production". What we see most often in enterprise Java projects is this: hundreds of unit tests, 80% coverage, and still a production incident every release caused by a database constraint, an integration contract or transaction behavior. The cause is not the number of tests but the absence of a test strategy: H2 doesn't behave like PostgreSQL, a mocked ERP client doesn't know what the real ERP returns, and end-to-end tests either don't exist or nobody trusts them.
This article proposes a four-layer test strategy for enterprise Java: verifying business rules with unit tests, data access against a real database with Testcontainers, external integrations with contract tests, and critical flows with a few end-to-end tests. We give the ratios, the test-data approach and the CI time targets, with code.
Four layers and their ratios
Layer | What it verifies | Tooling | Time / test | Share of total |
|---|---|---|---|---|
Unit | Business rules, calculations, state transitions | JUnit 5, AssertJ; no Spring context | ms | 60–70% |
Integration | Repository queries, transaction boundaries, migrations, message listeners | Spring Boot test slices + Testcontainers | 100 ms – 2 s | 20–30% |
Contract | Request/response contracts with external systems (ERP, payments, e-invoicing) | WireMock, Pact, Spring Cloud Contract | 100 ms – 1 s | 5–10% |
End-to-end | Critical business flows (order → invoice), authentication | Real environment, Playwright / REST | s – min | 1–5% |
The ratios mean this: fast, stable tests must be the majority; slow, fragile tests few but well chosen. "Let's test everything end to end" ends two months later with a suite nobody runs.
Layer 1: Unit tests — without Spring
A business rule should not need Spring; if it does, there is a design problem. Pricing, credit limits and state machines live in plain Java classes and are tested in milliseconds:
class CreditLimitRuleTest {
@Test
void requiresApprovalWhenOpenBalancePlusOrderExceedsLimit() {
var rule = new CreditLimitRule();
var decision = rule.evaluate(
Money.of("120000"), // credit limit
Money.of("95000"), // open balance
Money.of("30000")); // new order
assertThat(decision).isEqualTo(Decision.APPROVAL_REQUIRED);
}
}Mocks are used only at the outer boundary (clock, randomness, external service interfaces). "Unit" tests written by mocking repositories and services are the most expensive kind of test that verifies nothing.
Layer 2: A real database with Testcontainers
A query that passes on H2 can break on PostgreSQL: JSONB, ON CONFLICT, SKIP LOCKED, window functions, collation. Testcontainers starts a real PostgreSQL (or Kafka, Redis) container at the beginning of the test; with Spring Boot 3.1+ the connection setup is a single annotation:
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // don't swap in embedded H2
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection // url/user/password wired automatically
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired OrderRepository repository;
@Autowired TestEntityManager em;
@Test
void locksAndReturnsPendingOrders() {
em.persist(OrderFixture.pending("O-1"));
em.persist(OrderFixture.completed("O-2"));
em.flush();
List<Order> result = repository.lockPending(10); // FOR UPDATE SKIP LOCKED
assertThat(result).extracting(Order::getNumber).containsExactly("O-1");
}
}Flyway migrations run in the test too, so a migration error shows up in CI before production. Container start-up time (a few seconds) is amortized with a single container shared across test classes: a static field plus an abstract base class, or withReuse(true).
public abstract class PostgresTestBase {
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine").withReuse(true);
static { POSTGRES.start(); } // one container for the JVM's lifetime
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", POSTGRES::getJdbcUrl);
r.add("spring.datasource.username", POSTGRES::getUsername);
r.add("spring.datasource.password", POSTGRES::getPassword);
}
}The same approach works for Kafka (KafkaContainer), Redis, RabbitMQ and most dependencies other than SAP. The rule: whatever runs in production runs in the test — version included.
Test slices
A @SpringBootTest that boots the whole application is the last resort. @DataJpaTest loads only the JPA layer, @WebMvcTest only the controller and validation layer, @JsonTest only serialization; run time drops by orders of magnitude and the failure location becomes clear. In a modular monolith, @ApplicationModuleTest applies the same idea at module level.
Layer 3: Contract tests — verifying the external system's promise
Testing code that talks to an ERP, a payment provider or an e-invoicing integrator has two traps: calling the real system in tests (slow, unstable, sometimes impossible) or trusting a hand-written mock (nobody knows how it differs from the real response). A contract test sits between the two: real response samples from the external system are recorded as stubs, the application is tested against those stubs, and the stubs are periodically verified against the real system to confirm they are still valid.
@SpringBootTest
@WireMockTest(httpPort = 8089)
class SapStockAdapterContractTest {
@Test
void mapsStockResponse() {
stubFor(get(urlPathEqualTo("/API_MATERIAL_STOCK_SRV/A_MatlStkInAcctMod"))
.withQueryParam("Material", equalTo("000000000000010001"))
.willReturn(okJson(readResource("sap/stock-10001.json")))); // response recorded from real SAP
StockStatus status = adapter.query(ProductCode.of("10001"), WarehouseCode.of("1000")).orElseThrow();
assertThat(status.quantity()).isEqualByComparingTo("240");
assertThat(status.unit()).isEqualTo(Unit.PIECE);
}
}The recorded response files (stock-10001.json) are the contract itself; when the provider changes its response format, the first to notice should be the nightly "stub verification" job. For the mobile or web teams consuming your own API, consumer-driven contracts with Pact or Spring Cloud Contract do the same job in reverse: the consumer writes its expectation, and it is verified in the provider's CI.
Layer 4: End-to-end — few and critical
End-to-end tests are written only for the flows where "if this breaks, the company loses money": sign-in, order creation, payment, invoice generation. Five to ten scenarios is typical; they run in the test environment against real integration sandboxes and their results are part of the release decision. An end-to-end suite that grows to fifty scenarios is the first suite to be abandoned.
Test data and cleanup
Builder / fixture classes: intent-revealing helpers such as
OrderFixture.pending("O-1"); no 30-line entity setup inside the test.No shared mutable data: Every test sets up its own data; the "depends on test order" class of failure disappears.
Cleanup strategy: Putting
@Transactionalon the test class hides flush and lazy-loading behavior; in integration tests prefer an@AfterEachthat clears the tables, orTRUNCATE.
CI time: the 10-minute rule
The suite that runs on a pull request must not exceed 10 minutes; if it does, developers stop waiting and tests get skipped. Ways to hold the line: a unit-test majority, container sharing, parallel test classes (JUnit 5 junit.jupiter.execution.parallel.enabled), test slices, and running the end-to-end suite in a separate pre-deployment stage. The policy for flaky tests is clear: quarantine, fix within a week, or delete.
Which failure is caught by which layer?
Production failure | Catching layer |
|---|---|
Wrong VAT calculation, invalid state transition | Unit |
Unique constraint violation, migration error, N+1, wrong query | Integration (Testcontainers) |
ERP response format changed, field mapping broke | Contract |
Order flow doesn't work after authentication | End-to-end |
Wrong transaction boundary, no rollback | Integration (real DB, test without |
Frequently asked questions
What should the coverage target be?
An indicator, not a target: 70–80% is a healthy sign, but 95% can be reached with meaningless tests too. Running a mutation-testing tool like PIT occasionally is more instructive for seeing whether tests actually catch bugs.
Does Testcontainers work in CI?
On any CI with Docker access (GitHub Actions, GitLab CI, Jenkins agents). For environments without Docker, Testcontainers Cloud or service containers started in the CI stage are the alternatives.
Where do we start on an old project without tests?
With characterization tests: integration tests that record the current behavior as it is (real DB, real queries), then unit tests in the module that changes most often. Increase coverage not all at once but in every piece of code you touch.
Conclusion
A reliable test suite is built on layer balance, not test count: rules with pure unit tests, data access against a real database, integrations with recorded contracts, critical flows with a few end-to-end tests. That balance makes most production failures visible at the PR stage, within 10 minutes.
To set up a test strategy for your current project, or make an untested codebase safe to change, see our maintenance and support and software design and architecture services.