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

assertThat(x).isEqualTo(y) — the modern default

Mockito

Mocks and stubs

5.x

Spring Boot Test

@SpringBootTest, @MockBean, slice annotations

Bundled

Testcontainers

Real Docker containers in tests (Postgres, Redis, Kafka)

1.20+

REST Assured

Fluent HTTP client for API tests

Optional, or use WebTestClient

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.

  • Clock as a dependency makes time deterministic. Never call Instant.now() in production code; inject a Clock.

  • AssertJ over JUnit’s assertEquals — chainable, better failure messages, works with collections.

  • verify() with argThat() 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 a RestClient / WebClient bean 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

  • @DirtiesContext is expensive — avoid unless mandatory. Prefer clearing state between tests via @Sql or transactional rollback.

  • Reuse the container across tests — declare it static and Testcontainers keeps it alive for the test class. For cross-class reuse, use Testcontainers.reuse.enable=true in ~/.testcontainers.properties.

  • One @SpringBootTest config per test tree — Spring reuses contexts with matching configs, so mixing @MockBean around 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 -alpine or explicitly ARM-native images (postgres:16-alpine is 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 (Thread.sleep(1000))

Flaky, slow

Awaitility with a timeout

Sharing mutable state across tests

Order-dependent, flaky

Fresh setup per test, or @Transactional on the test class

One giant @SpringBootTest for everything

Slow, hard to debug

Slice tests where possible, @SpringBootTest for happy-path E2E only

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

  1. Write a unit test for a PriceCalculator service using a fixed Clock. Prove it returns different results at 9am vs 5pm.

  2. Write a @WebMvcTest for a controller that returns 400 with a ProblemDetail body when the request is malformed. Assert the JSON path for the error field.

  3. Convert a @DataJpaTest from H2 to Testcontainers Postgres with @ServiceConnection. Note how long the first run takes vs subsequent runs.

  4. Write a @SpringBootTest that spins up Postgres AND Redis containers and verifies caching kicks in on the second call.

  5. 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