Testing the Full Stack¶
Most “Java developers with 5 years of experience” have never written an integration test that hits a real Postgres. They’ve written unit tests that mock the repository, integration tests against H2, and shipped code that broke the moment it saw Postgres-specific SQL in production. This file makes sure that isn’t you.
The test pyramid still holds in 2026, but the mix matters more than the shape. Aim for: many fast unit tests, a solid layer of slice tests, and a thin but real layer of Testcontainers-backed integration tests. Skip end-to-end tests until the service is deployed — they cost more than they earn during development.
1. The Stack¶
Tool |
What it does |
Version |
|---|---|---|
JUnit 5 (Jupiter) |
Test runner, assertions, lifecycle |
5.10+ (bundled with Boot 3.4) |
AssertJ |
Fluent assertions |
|
Mockito |
Mocks and stubs |
5.x |
Spring Boot Test |
|
Bundled |
Testcontainers |
Real Docker containers in tests (Postgres, Redis, Kafka) |
1.20+ |
REST Assured |
Fluent HTTP client for API tests |
Optional, or use |
WireMock |
HTTP service virtualization for external APIs |
3.x |
Pitest |
Mutation testing |
Optional, run in CI weekly |
Awaitility |
Polling for async assertions |
For Kafka/scheduler tests |
All of these come with spring-boot-starter-test except Testcontainers, WireMock, and Pitest.
2. Unit Tests: The Boring Base¶
Unit tests exercise one class, with all collaborators mocked. Fast (< 10ms each), no Spring context.
class UrlShortenerServiceTest {
private final UrlRepository repo = mock(UrlRepository.class);
private final CodeGenerator codes = mock(CodeGenerator.class);
private final Clock clock = Clock.fixed(Instant.parse("2026-07-06T10:00:00Z"), ZoneOffset.UTC);
private final UrlShortenerService service = new UrlShortenerService(repo, codes, clock);
@Test
void createsShortUrlWithGeneratedCode() {
when(codes.next()).thenReturn("abc123");
when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));
var result = service.create("https://example.com");
assertThat(result.code()).isEqualTo("abc123");
assertThat(result.createdAt()).isEqualTo(Instant.parse("2026-07-06T10:00:00Z"));
verify(repo).save(argThat(u -> u.getTargetUrl().equals("https://example.com")));
}
@Test
void rejectsMalformedUrl() {
assertThatThrownBy(() -> service.create("not-a-url"))
.isInstanceOf(InvalidUrlException.class)
.hasMessageContaining("not-a-url");
}
}
Why this shape:
Constructor injection means we build the service with
new— no@SpringBootTest, no reflection.Clockas a dependency makes time deterministic. Never callInstant.now()in production code; inject aClock.AssertJ over JUnit’s
assertEquals— chainable, better failure messages, works with collections.verify()withargThat()confirms behavior, not just return values.
⚠️ What most people get wrong: Mocking value objects and DTOs. Mocks are for collaborators (dependencies with behavior), not for records or POJOs. Just use the real thing.
3. Slice Tests: The Missing Middle¶
Slice annotations load just enough Spring context to test one layer. Fast (~1s), realistic, no full app boot.
@WebMvcTest — controller slice¶
@WebMvcTest(UrlController.class)
class UrlControllerTest {
@Autowired MockMvc mvc;
@MockBean UrlService urlService; // service layer is mocked
@Test
void returns_400_for_invalid_url() throws Exception {
mvc.perform(post("/api/v1/urls")
.contentType(APPLICATION_JSON)
.content("""
{ "targetUrl": "" }
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.title").value("Validation Error"))
.andExpect(jsonPath("$.errors[0].field").value("targetUrl"));
}
@Test
void returns_201_and_location_header_on_create() throws Exception {
when(urlService.create(any()))
.thenReturn(new UrlResponse("abc123", "https://example.com", Instant.now(), 0));
mvc.perform(post("/api/v1/urls")
.contentType(APPLICATION_JSON)
.content("""
{ "targetUrl": "https://example.com" }
"""))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/v1/urls/abc123"));
}
}
@DataJpaTest — repository slice¶
By default, @DataJpaTest uses an in-memory H2 database. Don’t. Swap in Testcontainers Postgres immediately — see below.
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE) // do NOT swap our datasource for H2
@Testcontainers
class UrlRepositoryTest {
@Container
@ServiceConnection // Spring Boot 3.1+ magic — wires the container as the datasource
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired UrlRepository urls;
@Test
void findByCode_returnsMatch() {
urls.save(new UrlEntity("abc123", "https://example.com", Instant.now()));
assertThat(urls.findByCode("abc123")).isPresent();
assertThat(urls.findByCode("nope")).isEmpty();
}
}
Other slices worth knowing¶
@JsonTest— verify Jackson serialization/deserialization of DTOs@RestClientTest— test aRestClient/WebClientbean against a mocked server@JdbcTest— plain JDBC access without JPA context
4. @SpringBootTest — Full-Context Integration¶
Use sparingly. Loads the whole application context. Slow but real.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
@AutoConfigureMockMvc
class UrlShortenerIntegrationTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Container @ServiceConnection
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired MockMvc mvc;
@Test
void full_create_then_fetch_flow() throws Exception {
var createResponse = mvc.perform(post("/api/v1/urls")
.contentType(APPLICATION_JSON)
.content("""
{ "targetUrl": "https://example.com" }
"""))
.andExpect(status().isCreated())
.andReturn().getResponse().getContentAsString();
var code = JsonPath.read(createResponse, "$.code").toString();
mvc.perform(get("/api/v1/urls/" + code))
.andExpect(status().isOk())
.andExpect(jsonPath("$.targetUrl").value("https://example.com"));
}
}
Speeding it up¶
@DirtiesContextis expensive — avoid unless mandatory. Prefer clearing state between tests via@Sqlor transactional rollback.Reuse the container across tests — declare it
staticand Testcontainers keeps it alive for the test class. For cross-class reuse, useTestcontainers.reuse.enable=truein~/.testcontainers.properties.One
@SpringBootTestconfig per test tree — Spring reuses contexts with matching configs, so mixing@MockBeanaround explodes context count.
5. Testcontainers Deep Dive¶
Testcontainers spins up real Docker containers for the duration of a test. It’s the single most impactful change you can make to your testing practice.
The pattern¶
@Testcontainers
class KafkaConsumerTest {
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@DynamicPropertySource
static void kafkaProps(DynamicPropertyRegistry r) {
r.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Test
void consumes_messages() { ... }
}
@ServiceConnection (Spring Boot 3.1+) eliminates the @DynamicPropertySource boilerplate for supported containers (Postgres, Redis, Kafka, MongoDB, etc.).
Failure modes to know¶
Docker not running. Tests fail with
Could not find a valid Docker environment. Start Docker Desktop / OrbStack.Slow CI. First image pull adds 30s. Cache the Docker layer or pin image versions.
Ryuk permission denied on locked-down CI. Set
TESTCONTAINERS_RYUK_DISABLED=true— you lose auto-cleanup but tests run.M1/M2 Macs pulling amd64 images. Prefer
-alpineor explicitly ARM-native images (postgres:16-alpineis multi-arch).
6. Contract Tests (Optional but Signal-Rich)¶
Spring Cloud Contract or Pact let a consumer service declare “here’s the response shape I expect” and generate tests that fail on both sides when it drifts. Overkill for a single service, essential for microservice teams. Learn the concept; adopt when you have >3 services and >2 teams.
7. Mutation Testing with Pitest¶
Unit test coverage measures lines executed, not bugs caught. Mutation testing rewrites your code (flip > to >=, change true to false) and re-runs tests — if tests still pass, they’re weak.
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.16.0</version>
</plugin>
./mvnw org.pitest:pitest-maven:mutationCoverage
Aim for > 70% mutation score on core business logic. Run weekly in CI, not on every PR (it’s slow). Ignore the DTO/getter noise — mutations there are meaningless.
8. Testing Anti-Patterns to Avoid¶
Anti-pattern |
Why it’s bad |
Fix |
|---|---|---|
H2 as “lightweight Postgres” |
Different SQL dialect, hides real bugs |
Testcontainers Postgres |
Mocking your own service in an integration test |
Tests nothing real |
Mock only external boundaries (HTTP APIs) |
Sleeping for async operations ( |
Flaky, slow |
Awaitility with a timeout |
Sharing mutable state across tests |
Order-dependent, flaky |
Fresh setup per test, or |
One giant |
Slow, hard to debug |
Slice tests where possible, |
Testing frameworks instead of your code |
“Does Spring inject beans?” — yes, that’s Spring’s job |
Test your behavior |
No test for validation errors |
Half your bug surface |
Every DTO field should have a negative test |
9. What to Actually Assert¶
In unit tests: business logic outcomes. Given inputs X, function returns Y or throws Z.
In slice tests: the wiring at that layer. Controller returns the right HTTP status and body shape; repository translates method names to correct queries.
In integration tests: the seams. Does the whole request → controller → service → repo → DB → response flow work end-to-end for one happy path and one representative failure?
Don’t repeat the same assertion at three levels. Pick the cheapest test that catches the class of bug you care about.
Practice Exercises¶
Write a unit test for a
PriceCalculatorservice using a fixedClock. Prove it returns different results at 9am vs 5pm.Write a
@WebMvcTestfor a controller that returns 400 with a ProblemDetail body when the request is malformed. Assert the JSON path for the error field.Convert a
@DataJpaTestfrom H2 to Testcontainers Postgres with@ServiceConnection. Note how long the first run takes vs subsequent runs.Write a
@SpringBootTestthat spins up Postgres AND Redis containers and verifies caching kicks in on the second call.Stretch: Run Pitest on a small class you wrote. Look at the surviving mutants. Write tests to kill them.
Return to README.md · Previous: 03_data_access_jpa_and_beyond.md · Next: 05_security_the_practical_way.md