Advanced Java & Spring Boot · Lesson 10 of 16 · Updated ·
Testing Spring Boot — JUnit 5, Mockito, @WebMvcTest, @DataJpaTest and @SpringBootTest
Companies expect freshers to write tests. Good tests let you change code without fear — and every test on this page passes in our build.
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)
// 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());
}
}
@Mockcreates a fakeCourseRepository;@InjectMocksbuildsCourseServicewith 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
// 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.@MockitoBeanreplaces the realCourseServicebean with a mock (Boot 4 / Spring 7 name; older code used@MockBean).MockMvcsends fake HTTP requests;jsonPath("$.title")checks the JSON.jwt().authorities(...)fromspring-security-testpretends 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:
// 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 {
// 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));
@Transactionalon a test class rolls back after each test, so tests don't affect each other.JsonCompareMode.STRICTfails 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.