Skip to content
← All insights
Java testing14 min read

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.

Keep a domain rule outside the Spring containerjava
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.

Verify only the prohibited side effectjava / Mockito
@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.

Protect status and response shapejava / Spring Boot
@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.

Exercise the persistence contractjava / JUnit
@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.