Back to blog
Backend Systemsintermediate

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.

LearnixoApril 16, 20265 min read
Spring BootTestingJUnit 5MockitoTestcontainersJavaTDD
Share:𝕏

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:

JAVA
@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:

JAVA
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.

XML
<!-- 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>
JAVA
@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:

JAVA
@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

JAVA
@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

YAML
# 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: DEBUG

Key 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.