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
| Kind | Starts | Speed | Use for |
|---|---|---|---|
| Plain unit test | nothing | milliseconds | logic with no Spring in it |
@WebMvcTest | the web layer only | fast | controllers, validation, security rules |
@DataJpaTest | JPA + a database | fast | repositories and queries |
@SpringBootTest | everything | slow | wiring, 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: CustomerOrderServiceAn 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 impossibleWhat 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.
isEqualByComparingToforBigDecimal.- Test the gotchas, and name the test in the comment that describes them.
cleanbefore believing anything impossible.
Next: building with Gradle.