Spring Boot – Testing

August 12, 20266 min readUpdated 8/18/2026

Spring Boot gives you several ways to test, and choosing between them is most of the skill. The rule worth internalising: reach for the smallest test that can fail honestly.

Setup

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

⚠️ In Boot 3 this was spring-boot-starter-test. Boot 4's split into per-technology modules reaches the test starters too. It brings JUnit 5, AssertJ, Mockito, Hamcrest and MockMvc.

The options, smallest first

KindStartsSpeedUse for
Plain unit testnothingmillisecondslogic with no Spring in it
@WebMvcTestthe web layer onlyfastcontrollers, validation, security rules
@DataJpaTestJPA + a databasefastrepositories and queries
@SpringBootTesteverythingslowwiring, and flows that cross layers

Plain unit tests

If a class has no Spring in it, do not start Spring:

class PriceRoundingTest {

    @Test
    void roundsHalfUpToTwoDecimals() {
        assertThat(Money.scale(new BigDecimal("1.005")))
                .isEqualByComparingTo("1.01");
    }
}

Milliseconds, no context, no database. Most of your tests should look like this, and the fact that they can is a sign the design is good — logic that needs the whole framework to be exercised is logic that is tangled up in it.

Slice tests

@WebMvcTest(ProductRestController.class)
class ProductRestControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean                       // was @MockBean before Boot 3.4
    private ProductService productService;

    @Test
    void returnsTheMenu() throws Exception {
        given(productService.getMenu()).willReturn(List.of(aProduct("Pepperoni")));

        mockMvc.perform(get("/api/products"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].name").value("Pepperoni"));
    }

    @Test
    void rejectsAProductWithNoSizes() throws Exception {
        mockMvc.perform(post("/api/admin/products")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"name":"Test","type":"PIZZA","sizes":[]}"""))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].field").value("sizes"));
    }
}

Only the web layer starts — no database, no services. Everything below is mocked with @MockitoBean.

That second test is the kind a slice does best: it proves the validation annotation, the exception handler and the error shape all agree, which no unit test can, and it does not need a database to do it.

@DataJpaTest
class ProductRepositoryTest {

    @Autowired
    private ProductRepository repository;

    @Test
    void findByPublicIdWithSizesFetchesTheSizes() {
        var found = repository.findByPublicIdWithSizes(TestIds.PEPPERONI_PIZZA);
        assertThat(found).isPresent();
        assertThat(found.get().getSizes()).hasSize(3);
    }
}

@DataJpaTest starts JPA and rolls back after each test. By default it replaces your datasource with an in-memory one — which is convenient and dishonest if you use vendor-specific SQL. The pizza API's reporting queries are MySQL, so testing them against H2 would prove nothing. @AutoConfigureTestDatabase(replace = NONE) keeps the real database.

Full integration tests

/** Proves the authorization rules actually hold at the HTTP layer. */
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@DisplayName("API security")
class ApiSecurityIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    // Constructed rather than @Autowired: Spring Boot 4 does not expose a plain ObjectMapper
    // bean in this context, and the test only needs to pluck one field out of a response.
    private final ObjectMapper objectMapper = new ObjectMapper();

    private String tokenFor(String email, String password) throws Exception {
        String body = mockMvc.perform(post("/api/auth/login")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"email":"%s","password":"%s"}"""
                                .formatted(email, password)))
                .andExpect(status().isOk())
                .andReturn()
                .getResponse()
                .getContentAsString();
        return objectMapper.readTree(body).get("token").asText();
    }
}

This one earns the cost. Authorization rules involve the filter chain, the JWT filter, the rule ordering and the controllers — a slice test with a mocked security context would prove that the mock works. Getting a real token by logging in, then using it, tests the thing that actually matters.

@Transactional on tests

Each test runs in a transaction that is rolled back afterwards, so tests do not pollute each other. Two caveats: it does not roll back anything outside the database (files, emails, queue messages), and it hides bugs that only appear on commit — a constraint violation deferred to commit time never fires. Occasionally worth a test without it.

Testing secured endpoints

@Test
@WithMockUser(roles = "ADMIN")
void adminCanListUsers() throws Exception {
    mockMvc.perform(get("/api/admin/users")).andExpect(status().isOk());
}

@Test
void anonymousCannotListUsers() throws Exception {
    mockMvc.perform(get("/api/admin/users")).andExpect(status().isForbidden());
}

@WithMockUser populates the SecurityContext directly. It is quick, and it skips the JWT filter entirely — so it tests your authorization rules, not your authentication. Both are worth testing; do not mistake one for the other.

Naming and structure

/**
 * Pricing is where the money is, so it gets the most direct tests.
 *
 * <p>Every expected figure below is written out by hand from the seeded menu prices — if the
 * implementation and the test ever agree on a wrong number, it will be because both were changed
 * deliberately.
 */
@SpringBootTest
@Transactional
@DisplayName("PricingService")
class PricingServiceTest {

    @Test
    @DisplayName("prices a plain large pizza with delivery")
    void pricesSimpleOrder() {
        // Large Pepperoni = 16.99. Tax 16.99 * 0.085 = 1.44415 -> 1.44. Delivery 3.99.
        var priced = pricingService.price(requestWith(
                OrderType.DELIVERY,
                new OrderCreateDTO.ItemDTO(TestIds.PEPPERONI_PIZZA, SizeName.LARGE, null, null, 1)));

        assertThat(priced.subtotal()).isEqualByComparingTo("16.99");
        assertThat(priced.tax()).isEqualByComparingTo("1.44");
        assertThat(priced.deliveryFee()).isEqualByComparingTo("3.99");
        assertThat(priced.total()).isEqualByComparingTo("22.42");
    }
}

That class comment states the most valuable testing principle on this page: expected values are computed by hand, not by the code under test. A test that asserts total == subtotal + tax + fee passes when the formula is wrong. Writing 22.42 out, with the arithmetic in a comment, means the test can catch the implementation being wrong.

Note also isEqualByComparingTo rather than isEqualTo for BigDecimal: equals compares scale too, so 1.44 and 1.4400 are unequal. This trips people up constantly.

What is worth testing

The pizza API's 60 tests concentrate where mistakes are expensive:

  • Pricing — it is the money, and it is the security boundary.
  • Authorization — proving the rules hold at the HTTP layer.
  • Anything a comment calls a gotcha. The soft-delete filtering in the reports is guarded by ReportServiceImplTest#softDeletedOrdersAreExcluded, and the DAO comment names that test. That cross-reference is worth copying: it tells the next person that removing the predicate has a specific, already-discovered consequence.

Aim for coverage of behaviour, not lines. A test asserting that a getter returns what was set raises the number and finds nothing.

⚠️ Two things that will waste your afternoon

Stale classes

java.lang.NoClassDefFoundError: CustomerOrderService
Caused by: java.lang.ClassNotFoundException: CustomerOrderService

An unqualified class name in that error is the signature. Nothing is wrong with your code; the build output is inconsistent. ./mvnw clean compile fixes it, and the pizza API's own documentation warns about it because it has cost several debugging sessions. If behaviour does not match the source, suspect this before you suspect the source.

Tests that assert seeded counts

Several tests assert absolute seeded counts (14 products, 6 drinks). Hiding or deleting a
seeded row through the admin UI while demoing will therefore break the suite, and the failure
points at the test rather than at what you clicked.

An honest trade-off, recorded rather than hidden. Absolute counts are simple and precise; they couple the suite to the seed data. If you take this approach, write that warning down, because the failure is genuinely misleading.

The related rule: tests must clean up what they create, or one failure poisons every later run.

Testcontainers

When you need the real database and do not want a shared one:

@SpringBootTest
@Testcontainers
class OrderIntegrationTest {

    @Container
    @ServiceConnection                 // Boot wires spring.datasource.* automatically
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4");
}

@ServiceConnection removes the @DynamicPropertySource boilerplate older examples show. You get a real MySQL, isolated per run, at the cost of Docker and a few seconds of startup — usually a good trade for anything touching vendor-specific SQL.

Running them

./mvnw test                                  # all 60
./mvnw test -Dtest=PricingServiceTest        # one class
./mvnw test -Dtest='*ServiceImplTest'        # a pattern
./mvnw clean test                            # when something looks impossible

What to take from this

  • Smallest honest test. Unit > slice > @SpringBootTest.
  • Boot 4: spring-boot-starter-webmvc-test, and @MockitoBean.
  • Compute expected values by hand, never with the code under test.
  • isEqualByComparingTo for BigDecimal.
  • Test the gotchas, and name the test in the comment that describes them.
  • clean before believing anything impossible.

Next: building with Gradle.