Testing Spring Boot: JUnit 5, Mockito & Testcontainers
Write thorough tests for Spring Boot APIs β unit tests with JUnit 5 and Mockito, repository tests with Testcontainers + PostgreSQL, integration tests with MockMvc, and security testing.
Test Pyramid for Spring Boot
β± E2E β² Few, slow β test full stack
β±ββββββββββ²
β± Integrationβ² Moderate β test slices (web, data)
β±ββββββββββββββββ²
β± Unit β² Many, fast β test business logic
β±ββββββββββββββββββββββ²Spring Boot test slices let you test one layer at a time without loading the full application context.
Unit Tests with JUnit 5 + Mockito
Pure unit tests β no Spring context, just instantiate the class under test:
@ExtendWith(MockitoExtension.class)
class AppointmentServiceTest {
@Mock
private AppointmentRepository repository;
@Mock
private PatientService patientService;
@Mock
private ClinicRepository clinicRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
@InjectMocks
private AppointmentService service;
@Test
@DisplayName("create() saves appointment and publishes event")
void create_savesAndPublishesEvent() {
// Arrange
Clinic clinic = TestFixtures.clinic("CLN-001");
Patient patient = TestFixtures.patient("PAT-001");
AppointmentRequest request = new AppointmentRequest(
"CLN-001", "PAT-001",
LocalDateTime.now().plusDays(1),
"Routine exam", AppointmentType.ROUTINE
);
given(clinicRepository.findById("CLN-001")).willReturn(Optional.of(clinic));
given(patientService.findById("PAT-001")).willReturn(patient);
given(repository.existsByPatientIdAndDateTimeAndStatusNot(any(), any(), any()))
.willReturn(false);
given(repository.save(any())).willAnswer(inv -> {
Appointment a = inv.getArgument(0);
a.setId("APT-123");
return a;
});
// Act
AppointmentResponse result = service.create(request, "user-1");
// Assert
assertThat(result.id()).isEqualTo("APT-123");
assertThat(result.status()).isEqualTo(AppointmentStatus.SCHEDULED);
verify(repository).save(any(Appointment.class));
verify(eventPublisher).publishEvent(any(AppointmentCreatedEvent.class));
}
@Test
@DisplayName("create() throws when appointment conflicts")
void create_throwsOnConflict() {
given(clinicRepository.findById(any())).willReturn(Optional.of(TestFixtures.clinic("CLN-001")));
given(patientService.findById(any())).willReturn(TestFixtures.patient("PAT-001"));
given(repository.existsByPatientIdAndDateTimeAndStatusNot(any(), any(), any()))
.willReturn(true);
AppointmentRequest request = TestFixtures.appointmentRequest();
assertThatThrownBy(() -> service.create(request, "user-1"))
.isInstanceOf(AppointmentConflictException.class);
verify(repository, never()).save(any());
}
@Test
@DisplayName("cancel() throws when appointment is already completed")
void cancel_throwsIfCompleted() {
Appointment completed = TestFixtures.appointment("APT-001");
completed.setStatus(AppointmentStatus.COMPLETED);
given(repository.findById("APT-001")).willReturn(Optional.of(completed));
assertThatThrownBy(() -> service.cancel("APT-001", "changed mind"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("completed");
}
}Test Fixtures
Keep tests readable with a TestFixtures factory:
public class TestFixtures {
public static Clinic clinic(String id) {
Clinic c = new Clinic();
c.setId(id);
c.setName("Test Clinic");
c.setState("CA");
c.setActive(true);
return c;
}
public static Patient patient(String id) {
Patient p = new Patient();
p.setId(id);
p.setFirstName("Jane");
p.setLastName("Doe");
p.setEmail("jane@example.com");
return p;
}
public static AppointmentRequest appointmentRequest() {
return new AppointmentRequest(
"CLN-001", "PAT-001",
LocalDateTime.now().plusDays(1),
"Annual exam", AppointmentType.ROUTINE
);
}
}Repository Tests with Testcontainers
Test against a real PostgreSQL instance β catches SQL syntax errors, constraint violations, and index behaviour that in-memory databases miss.
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // don't replace with H2
@Testcontainers
class AppointmentRepositoryTest {
@Container
@ServiceConnection // Spring Boot 3.1+ β wires container to datasource automatically
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private AppointmentRepository repository;
@Autowired
private TestEntityManager em;
@Test
@Transactional
void findByClinicAndDateRange_returnsOnlyMatchingAppointments() {
Clinic clinic = em.persist(TestFixtures.clinic("CLN-001"));
Patient patient = em.persist(TestFixtures.patient("PAT-001"));
LocalDateTime base = LocalDateTime.of(2026, 4, 16, 9, 0);
Appointment inside = appointment(clinic, patient, base.plusHours(2));
Appointment before = appointment(clinic, patient, base.minusDays(1));
Appointment after = appointment(clinic, patient, base.plusDays(2));
em.persist(inside);
em.persist(before);
em.persist(after);
em.flush();
List<Appointment> result = repository.findByClinicAndDateRange(
"CLN-001", base, base.plusDays(1));
assertThat(result).hasSize(1);
assertThat(result.get(0).getId()).isEqualTo(inside.getId());
}
@Test
void existsByPatientAndDatetime_detectsConflict() {
Clinic clinic = em.persist(TestFixtures.clinic("CLN-001"));
Patient patient = em.persist(TestFixtures.patient("PAT-001"));
LocalDateTime dt = LocalDateTime.of(2026, 4, 16, 10, 0);
Appointment existing = appointment(clinic, patient, dt);
existing.setStatus(AppointmentStatus.SCHEDULED);
em.persist(existing);
em.flush();
boolean conflict = repository.existsByPatientIdAndDateTimeAndStatusNot(
"PAT-001", dt, AppointmentStatus.CANCELLED);
assertThat(conflict).isTrue();
}
private Appointment appointment(Clinic clinic, Patient patient, LocalDateTime dt) {
Appointment a = new Appointment();
a.setClinic(clinic);
a.setPatient(patient);
a.setDateTime(dt);
a.setStatus(AppointmentStatus.SCHEDULED);
a.setType(AppointmentType.ROUTINE);
return a;
}
}Web Layer Tests with MockMvc
@WebMvcTest loads only the web layer β no database, fast startup:
@WebMvcTest(AppointmentController.class)
@Import(SecurityConfig.class)
class AppointmentControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private AppointmentService service;
@Autowired
private ObjectMapper objectMapper;
@Test
@WithMockUser(roles = "USER")
void list_returns200WithAppointments() throws Exception {
Page<AppointmentResponse> page = new PageImpl<>(
List.of(TestFixtures.appointmentResponse()),
PageRequest.of(0, 20), 1
);
given(service.listAppointments(any(), anyInt(), anyInt())).willReturn(page);
mockMvc.perform(get("/api/v1/appointments")
.param("clinicId", "CLN-001")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content[0].id").value("APT-001"));
}
@Test
@WithMockUser(roles = "USER")
void create_returns201WithLocation() throws Exception {
AppointmentRequest request = TestFixtures.appointmentRequest();
AppointmentResponse created = TestFixtures.appointmentResponse();
given(service.create(any(), any())).willReturn(created);
mockMvc.perform(post("/api/v1/appointments")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", containsString("/appointments/APT-001")))
.andExpect(jsonPath("$.id").value("APT-001"))
.andExpect(jsonPath("$.status").value("SCHEDULED"));
}
@Test
@WithMockUser(roles = "USER")
void create_returns400WhenRequestInvalid() throws Exception {
// Missing required fields
String invalidBody = """{"notes": "exam"}""";
mockMvc.perform(post("/api/v1/appointments")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidBody))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.errors").isArray());
}
@Test
void list_returns401WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/v1/appointments"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void delete_returns403ForNonAdmin() throws Exception {
mockMvc.perform(delete("/api/v1/appointments/APT-001"))
.andExpect(status().isForbidden());
}
}Full Integration Test
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@Testcontainers
@ActiveProfiles("test")
class AppointmentIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private JwtService jwtService;
@Test
void fullAppointmentLifecycle() throws Exception {
String token = jwtService.generateAccessToken(
User.withUsername("staff@clinic.com").password("").roles("USER").build());
// Create
AppointmentRequest request = TestFixtures.appointmentRequest();
MvcResult result = mockMvc.perform(post("/api/v1/appointments")
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
String id = JsonPath.read(result.getResponse().getContentAsString(), "$.id");
// Read it back
mockMvc.perform(get("/api/v1/appointments/" + id)
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("SCHEDULED"));
// Cancel
mockMvc.perform(put("/api/v1/appointments/" + id + "/status")
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("""{"status":"CANCELLED","reason":"patient request"}"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("CANCELLED"));
}
}Test Configuration
# src/test/resources/application-test.yml
spring:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
security:
jwt:
secret: dGVzdC1zZWNyZXQtdGhhdC1pcy1sb25nLWVub3VnaC1mb3ItdGVzdHM=
access-token-expiry-ms: 3600000
refresh-token-expiry-ms: 86400000
logging:
level:
org.hibernate.SQL: DEBUGKey Testing Principles
| Rule | Why |
|------|-----|
| Test behaviour, not implementation | Don't assert internal method calls β assert outcomes |
| One assertion per test (mostly) | Easy to pinpoint failures |
| Use TestFixtures factories | Readable, DRY test setup |
| Testcontainers for DB | Real SQL behaviour, not H2 quirks |
| @WebMvcTest for controllers | Fast, isolated web layer tests |
| Arrange-Act-Assert | Clear test structure |
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.