# Testing Spring Boot — JUnit 5, Mockito, @WebMvcTest, @DataJpaTest and @SpringBootTest

Source: https://skills101.in/learn/spring-boot/spring-boot-testing-junit-mockito — Skills101 free tutorial (Advanced Java & Spring Boot). Updated 2026-11-09.

Companies expect freshers to write tests. Good tests let you change code without fear — and every test on this page passes in our build.

![Testing levels: many JUnit and Mockito unit tests at the base, @WebMvcTest and @DataJpaTest slice tests in the middle, a few @SpringBootTest full-app tests at the top](https://skills101.in/learn-img/spring-boot/test-pyramid.svg)

`spring-boot-starter-test` (and Boot 4's per-module test starters like `spring-boot-starter-webmvc-test`) bring **JUnit 5**, **Mockito**, **AssertJ** and Spring's test support.

## 1. Unit test with Mockito (no Spring at all)

```java
// File: course-api/src/test/java/com/skills101/courses/course/CourseServiceTest.java
package com.skills101.courses.course;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

// A plain unit test: no Spring, no database — the repository is a Mockito mock
@ExtendWith(MockitoExtension.class)
class CourseServiceTest {

    @Mock
    CourseRepository repository;

    @InjectMocks
    CourseService service;

    @Test
    void findByIdReturnsTheCourse() {
        when(repository.findById(7L)).thenReturn(Optional.of(new Course("Git Basics", 0, 100)));

        CourseResponse course = service.findById(7L);

        assertThat(course.title()).isEqualTo("Git Basics");
        assertThat(course.fee()).isZero();
    }

    @Test
    void findByIdThrowsWhenMissing() {
        when(repository.findById(99L)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> service.findById(99L))
                .isInstanceOf(CourseNotFoundException.class)
                .hasMessage("Course 99 not found");
    }

    @Test
    void deleteChecksExistenceFirst() {
        when(repository.existsById(5L)).thenReturn(false);

        assertThatThrownBy(() -> service.delete(5L)).isInstanceOf(CourseNotFoundException.class);
        verify(repository, never()).deleteById(any());
    }
}
```

- `@Mock` creates a fake `CourseRepository`; `@InjectMocks` builds `CourseService` with it.
- `when(...).thenReturn(...)` decides what the fake returns.
- `verify(repository, never()).deleteById(any())` checks an interaction did **not** happen.
- Pattern: **Arrange → Act → Assert** (given / when / then). Runs in milliseconds.

## 2. Web layer slice: @WebMvcTest + MockMvc

```java
// File: course-api/src/test/java/com/skills101/courses/course/CourseControllerTest.java
package com.skills101.courses.course;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.skills101.courses.security.SecurityConfig;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

// A "slice" test: only the web layer starts; the service is replaced by a mock
@WebMvcTest(CourseController.class)
@Import(SecurityConfig.class)
class CourseControllerTest {

    @Autowired
    MockMvc mvc;

    @MockitoBean
    CourseService service;

    @Test
    void getOneCourseIsPublic() throws Exception {
        when(service.findById(1L)).thenReturn(new CourseResponse(1L, "Java Full Stack", 4999, 30));

        mvc.perform(get("/api/courses/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.title").value("Java Full Stack"))
                .andExpect(jsonPath("$.fee").value(4999));
    }

    @Test
    void createNeedsAToken() throws Exception {
        mvc.perform(post("/api/courses").contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\":\"Kotlin\",\"fee\":999,\"seats\":20}"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    void invalidBodyNeverReachesTheService() throws Exception {
        mvc.perform(post("/api/courses")
                        .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_ADMIN")))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\":\"\",\"fee\":-5,\"seats\":0}"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors.title").value("must not be blank"));
        verify(service, never()).create(any());
    }
}
```

- `@WebMvcTest(CourseController.class)` starts only MVC + security for that controller — no database.
- `@MockitoBean` replaces the real `CourseService` bean with a mock (Boot 4 / Spring 7 name; older code used `@MockBean`).
- `MockMvc` sends fake HTTP requests; `jsonPath("$.title")` checks the JSON.
- `jwt().authorities(...)` from `spring-security-test` pretends a valid token was sent.
- In Spring Boot 4 the annotation lives in `org.springframework.boot.webmvc.test.autoconfigure` — Boot 3 tutorials show the old package.

## 3. Repository slice: @DataJpaTest

We saw `CourseRepositoryTest` in lesson 7: `@DataJpaTest` starts JPA with an in-memory database and **rolls back** after each test.

## 4. Full integration test: @SpringBootTest

The real app — security, JPA, H2, seeded data — called through MockMvc. Each response is compared with the JSON shown in these lessons:

```java
// File: course-api/src/test/java/com/skills101/courses/CourseApiIntegrationTest.java (excerpt)
@SpringBootTest
@AutoConfigureMockMvc
@Transactional   // roll back after each test so they don't affect each other
class CourseApiIntegrationTest {

    @Autowired
    MockMvc mvc;

    String token(String email, String password) throws Exception {
        String body = mvc.perform(post("/auth/token").contentType(MediaType.APPLICATION_JSON)
                        .content("{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}"))
                .andExpect(status().isOk())
                .andReturn().getResponse().getContentAsString();
        String token = body.replaceAll(".*\"token\":\"([^\"]+)\".*", "$1");
        assertThat(token).startsWith("eyJ");   // a JWT: header.payload.signature
        return token;
    }

    @Test
    void listCoursesIsPublic() throws Exception {
```

```java
// File: course-api/src/test/java/com/skills101/courses/CourseApiIntegrationTest.java (excerpt)
void adminCreatesACourse() throws Exception {
    String admin = token("admin@skills101.in", "admin123");
    mvc.perform(post("/api/courses").header("Authorization", "Bearer " + admin)
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("{\"title\":\"Spring Boot Microservices\",\"fee\":5999,\"seats\":40}"))
            .andExpect(status().isCreated())
            .andExpect(header().string("Location", "/api/courses/3"))
            .andExpect(content().json(Expected.read("course-created.json"), JsonCompareMode.STRICT));
```

- `@Transactional` on a test class rolls back after each test, so tests don't affect each other.
- `JsonCompareMode.STRICT` fails on any extra or missing field.

Run everything with `./mvnw test`. Our Course API has **20 tests** across these four styles, and they all pass.

## 5. Good test habits

- Name tests after behaviour: `findByIdThrowsWhenMissing`.
- One reason to fail per test; no logic (loops, ifs) inside tests.
- Test the edge cases: empty input, not found, full course, wrong role.
- Prefer many fast unit tests, fewer slow full-app tests.

## Common mistakes

- Starting the whole app (`@SpringBootTest`) for every test — slow suites nobody runs.
- Mocking the class under test, or mocking value objects like records.
- Tests that depend on run order or on data from another test.
- Asserting nothing ("it didn't crash").

## Quick recap

- Unit: JUnit 5 + Mockito (`@Mock`, `@InjectMocks`, `when`, `verify`).
- Slices: `@WebMvcTest` + `MockMvc` + `@MockitoBean`; `@DataJpaTest`.
- Integration: `@SpringBootTest` + `@AutoConfigureMockMvc`, roll back with `@Transactional`.
- Pyramid: many unit tests, some slices, few full tests.


Practice quiz and free certificate: https://skills101.in/learn/spring-boot/spring-boot-testing-junit-mockito