StackWise

Testing Spring Boot Applications

The testing pyramid, plain unit tests, slice tests (@WebMvcTest, @DataJpaTest), @MockitoBean, MockMvcTester, RestTestClient and @SpringBootTest.

intermediate 3 min readLesson 16 of 23

The pyramid

flowchart TB
  E2E["End-to-end / contract tests<br/>few, slow"] --> I["@SpringBootTest + Testcontainers<br/>some"]
  I --> S["Slice tests: @WebMvcTest, @DataJpaTest, @JsonTest<br/>more"]
  S --> U["Plain unit tests — no Spring<br/>most, milliseconds each"]

Constructor injection makes the bottom layer easy: most business logic should be testable without starting Spring at all.

class PricingServiceTest {
    private final TaxRates taxRates = mock(TaxRates.class);
    private final PricingService pricing = new PricingService(taxRates, Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), UTC));

    @Test
    void appliesVat() {
        given(taxRates.rateFor("GB")).willReturn(new BigDecimal("0.20"));
        assertThat(pricing.gross(new BigDecimal("10.00"), "GB")).isEqualByComparingTo("12.00");
    }
}

spring-boot-starter-test includes JUnit Jupiter, AssertJ, Mockito, Hamcrest, JSONassert/JsonPath and Spring Test. In Spring Boot 4, each technology also has a focused test starter (for example spring-boot-starter-webmvc-test, spring-boot-starter-data-jpa-test) that brings its slice annotations.

Mocks in the Spring context: @MockitoBean

Spring Boot 4 removed @MockBean and @SpyBean (deprecated in 3.4). Use the Spring Framework replacements:

Boot 3 (removed) Boot 4
@MockBean @MockitoBean
@SpyBean @MockitoSpyBean

They replace (or wrap) the bean of that type in the test’s application context.

Web layer: @WebMvcTest

Starts only MVC infrastructure — controllers, advice, converters, filters, security — not services or repositories.

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvcTester mvc;                 // AssertJ-friendly MockMvc (Framework 6.2+)
    @MockitoBean OrderService orders;

    @Test
    void createsOrder() {
        given(orders.create(any())).willReturn(new OrderResponse(42, "c-1", OrderStatus.NEW, new BigDecimal("39.98"), Instant.EPOCH));

        assertThat(mvc.post().uri("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"customerId": "c-1", "items": [{"sku": "BOOK-1", "quantity": 2}]}
                    """))
            .hasStatus(HttpStatus.CREATED)
            .hasHeader("Location", "http://localhost/api/orders/42")
            .bodyJson().extractingPath("$.total").asNumber().isEqualTo(39.98);
    }

    @Test
    void rejectsInvalidPayload() {
        assertThat(mvc.post().uri("/api/orders").contentType(MediaType.APPLICATION_JSON).content("{\"items\": []}"))
            .hasStatus(HttpStatus.BAD_REQUEST)
            .bodyJson().extractingPath("$.errors").asArray().isNotEmpty();
    }
}

Persistence layer: @DataJpaTest

Starts JPA, Spring Data repositories, Flyway/Liquibase and a TestEntityManager. Each test runs in a transaction that is rolled back afterwards.

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)   // use the real DB from Testcontainers, not H2
@Import(TestcontainersConfig.class)
class OrderRepositoryTest {
    @Autowired OrderRepository repository;
    @Autowired TestEntityManager em;

    @Test
    void findsLargePaidOrders() {
        var big = new Order("c-1"); big.addLine("DESK", 1, new BigDecimal("250")); big.markPaid();
        var small = new Order("c-2"); small.addLine("PEN", 1, new BigDecimal("2")); small.markPaid();
        em.persist(big); em.persist(small); em.flush(); em.clear();

        assertThat(repository.findLargeOrders(OrderStatus.PAID, new BigDecimal("100"), Limit.of(10)))
            .extracting(Order::getCustomerId).containsExactly("c-1");
    }
}

Other slices

Annotation Loads
@JsonTest Jackson configuration — test (de)serialisation of DTOs
@RestClientTest RestClient/RestTemplate builders + MockRestServiceServer
@WebFluxTest WebFlux controllers + WebTestClient
@DataJdbcTest, @DataMongoTest, @DataRedisTest The respective data layer
@GraphQlTest GraphQL controllers

Full integration: @SpringBootTest

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@Import(TestcontainersConfig.class)
class CheckoutFlowIT {

    @Autowired RestTestClient client;            // Spring Framework 7: one client for MockMvc or live servers

    @Test
    void placesAndFetchesOrder() {
        var location = client.post().uri("/api/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .body(new CreateOrderRequest("c-1", List.of(new LineItemRequest("BOOK-1", 1)), null))
            .exchange()
            .expectStatus().isCreated()
            .returnResult(Void.class).getResponseHeaders().getLocation();

        client.get().uri(location).exchange()
            .expectStatus().isOk()
            .expectBody().jsonPath("$.status").isEqualTo("NEW");
    }
}

Testing other concerns

  • Time — inject a Clock and use Clock.fixed(...) in tests
  • Security — spring-security-test: @WithMockUser, .with(jwt()), .with(csrf())
  • Async — Awaitility: await().atMost(5, SECONDS).untilAsserted(() -> ...)
  • Properties — @TestPropertySource, @DynamicPropertySource, or ApplicationContextRunner for auto-configuration
Finished this lesson?

Mark it complete to track your progress through Spring Boot 4.