Testing Spring Boot services at the right boundaries
A practical testing structure for Java and Spring Boot: pure unit tests, focused service tests, web slices, repository integration and fewer mocks that merely replay the implementation.
Different tests should answer different questions
A service test, controller test and repository test protect different risks. Treating all three as unit tests with mocked collaborators produces fast feedback but little evidence that validation, JSON, SQL or Spring wiring works together.
Begin with the behaviour at risk. Pure rules need ordinary JUnit tests. Service orchestration may need a stub repository. HTTP mapping belongs in a web test, while query and transaction behaviour require the real persistence adapter.
class AccountTest {
@Test
void closedAccountsCannotBeRenamed() {
var result = closedAccount.rename(new AccountName("Ada"));
assertEquals(
Either.left(RenameError.ACCOUNT_CLOSED),
result
);
}
}
// No application context is needed for a pure decision.Service tests should assert outcomes before choreography
Mock a collaborator when the interaction is itself important, such as ensuring a rejected command never writes. Do not verify every read and helper call merely because Mockito makes them visible.
A small stub or in-memory fake can let a test assert the returned result and stored state. It survives refactoring from two repository calls to one while still failing if the public behaviour changes.
@Test
void doesNotSaveWhenTheAccountIsClosed() {
when(repository.find(id)).thenReturn(Optional.of(closedAccount));
var result = service.rename(id, newName);
assertEquals(Either.left(RenameError.ACCOUNT_CLOSED), result);
verify(repository, never()).save(any(Account.class));
}
// Exact read counts and helper ordering are not part of this rule.Test the web contract through Spring MVC
Controller tests should prove request decoding, bean validation, status codes, headers and response JSON. Calling the controller method directly skips much of the machinery whose behaviour the endpoint promises.
A focused MVC slice can keep unrelated infrastructure out while retaining HTTP mapping. Mock the application service at this boundary because the test is concerned with translation, not service implementation.
@WebMvcTest(AccountController.class)
class AccountControllerTest {
@Autowired MockMvc mvc;
@MockBean AccountService service;
@Test
void missingAccountReturns404() throws Exception {
when(service.find(new AccountId("a-17")))
.thenReturn(new FindResult.NotFound());
mvc.perform(get("/accounts/a-17"))
.andExpect(status().isNotFound());
}
}Repository behaviour needs a real database boundary
A mocked JdbcTemplate or repository interface cannot prove that SQL joins, constraints, mappings and migrations agree. Repository integration tests should execute representative queries against a controlled database with realistic schema.
Test transactions where ownership matters. A method-level unit test cannot prove that a multi-write operation rolls back, nor that lazy access remains valid outside the transaction. Keep the integration suite focused enough to remain diagnosable.
@Test
void roundTripsAnAccount() {
repository.save(account);
var loaded = repository.find(account.id());
assertEquals(Optional.of(account), loaded);
}
@Test
void rejectsDuplicateExternalReference() {
repository.save(first);
assertThrows(DuplicateKeyException.class,
() -> repository.save(duplicate));
}Use the full application context selectively
A small number of SpringBootTest cases can prove that configuration and principal journeys assemble correctly. Loading the full context for every field validation or calculation increases suite time without increasing evidence.
Separate failures by boundary. When a full-context test fails, a narrower controller or repository test should usually locate the cause. If every test uses the entire application, failures become slower and harder to interpret.
- Pure domain rule: ordinary JUnit test
- Service workflow: stub, fake or narrow mock
- HTTP contract: MVC boundary test
- SQL and mappings: repository integration test
- Configuration and critical journey: selective full-context test
- Production confidence: deployment checks and observable behaviour
A useful suite follows risk rather than a pyramid quota
The familiar test pyramid is guidance about feedback and cost, not a required percentage. A database-heavy service may need more integration evidence; a calculation library may be mostly pure unit tests.
Measure whether the suite catches meaningful regressions, runs reliably and tells an engineer what failed. Coverage can expose untouched code, but maximising the number does not prove assertions, boundaries or failure paths are useful.