Testing Spring Boot Applications
🎧 Focus sounds
Soft beat sounds that many learners like while reading. Pick one, press Play, and keep studying.
Keep the volume low. These sounds are for comfort while studying, not a medical treatment. If you have epilepsy or are sensitive to pulsing sounds, use the background noise only.
In this lesson
Tests let you change code without fear. Spring Boot gives you everything in one starter (spring-boot-starter-test: JUnit 5, Mockito, AssertJ, MockMvc, JSONPath) and test slices that start only the part of the app you’re testing, so tests stay fast.
The testing pyramid#
/\ few @SpringBootTest: whole app, real HTTP (slow, broad)
/ \
/----\ some slices: @WebMvcTest, @DataJpaTest (medium)
/ \
/--------\ many plain unit tests: no Spring at all (milliseconds)
We’ll test the course API from the DTO and validation lesson (CourseService, CourseController, CourseRepository, ApiExceptionHandler).
1. Unit tests with Mockito (no Spring)#
package com.vitalstack.courses;
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;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CourseServiceTest {
@Mock CourseRepository repo; // a fake repository
@InjectMocks CourseService service; // the real service, with the fake injected
@Test
void createsCourseWhenTitleIsNew() {
when(repo.existsByTitleIgnoreCase("Docker")).thenReturn(false);
when(repo.save(any(Course.class))).thenAnswer(inv -> inv.getArgument(0));
CourseResponse res = service.create(new CourseRequest(" Docker ", Level.BEGINNER, 399));
assertThat(res.title()).isEqualTo("Docker"); // trimmed
verify(repo).save(any(Course.class));
}
@Test
void rejectsDuplicateTitle() {
when(repo.existsByTitleIgnoreCase("Java")).thenReturn(true);
assertThatThrownBy(() -> service.create(new CourseRequest("Java", Level.BEGINNER, 1)))
.isInstanceOf(DuplicateCourseException.class)
.hasMessageContaining("already exists");
verify(repo, never()).save(any());
}
@Test
void missingCourseThrowsNotFound() {
when(repo.findById(42L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.get(42)).isInstanceOf(CourseNotFoundException.class);
}
}
These run in milliseconds. Constructor injection is what makes them so easy.
2. Controller tests with @WebMvcTest#
@WebMvcTest starts only the web layer (controllers, advice, JSON conversion, validation). Services are replaced with mocks:
package com.vitalstack.courses;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(CourseController.class)
class CourseControllerTest {
@Autowired MockMvc mvc;
@MockBean CourseService service; // Spring Boot 3.4+: @MockitoBean
@Test
void returnsCourseAsJson() throws Exception {
when(service.get(1)).thenReturn(new CourseResponse(1L, "Java Basics", Level.BEGINNER, 499));
mvc.perform(get("/api/courses/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Java Basics"))
.andExpect(jsonPath("$.price").value(499));
}
@Test
void returns404ProblemDetail() throws Exception {
when(service.get(99)).thenThrow(new CourseNotFoundException(99));
mvc.perform(get("/api/courses/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.detail").value("Course 99 was not found"));
}
@Test
void rejectsInvalidBody() throws Exception {
mvc.perform(post("/api/courses")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"\",\"level\":\"BEGINNER\",\"price\":-1}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors.title").value("Title is required"))
.andExpect(jsonPath("$.errors.price").value("Price can't be negative"));
}
@Test
void createReturns201WithLocation() throws Exception {
when(service.create(any())).thenReturn(new CourseResponse(7L, "Docker", Level.BEGINNER, 399));
mvc.perform(post("/api/courses")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"Docker\",\"level\":\"BEGINNER\",\"price\":399}"))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/courses/7"));
}
}
No server, no database: MockMvc calls the DispatcherServlet directly. If your app uses Spring Security, these tests also run through the security filters: add @WithMockUser(roles = "ADMIN") on tests that need a logged-in user.
3. Repository tests with @DataJpaTest#
package com.vitalstack.courses;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest // JPA + embedded H2 + rollback after each test
class CourseRepositoryTest {
@Autowired CourseRepository repo;
@Test
void findsTitlesIgnoringCase() {
repo.save(new Course("Spring Boot", Level.INTERMEDIATE, 799));
assertThat(repo.existsByTitleIgnoreCase("spring boot")).isTrue();
assertThat(repo.existsByTitleIgnoreCase("react")).isFalse();
}
}
Each test runs in a transaction that’s rolled back, so tests don’t affect each other.
4. Full integration tests with @SpringBootTest#
package com.vitalstack.courses;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CourseApiIntegrationTest {
@Autowired TestRestTemplate http;
@Test
void createThenFetch() {
var created = http.postForEntity("/api/courses",
new CourseRequest("Kubernetes", Level.ADVANCED, 999), CourseResponse.class);
assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
var fetched = http.getForEntity(created.getHeaders().getLocation(), CourseResponse.class);
assertThat(fetched.getBody().title()).isEqualTo("Kubernetes");
}
}
The whole app starts on a random port and the test talks to it over real HTTP.
Running the tests#
./mvnw test
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Testcontainers: test against the real database#
H2 behaves differently from MySQL/PostgreSQL in places. Testcontainers starts a real database in Docker for your tests:
@SpringBootTest
@Testcontainers
class PostgresIntegrationTest {
@Container
@ServiceConnection // Boot 3.1+: configures the DataSource automatically
static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16-alpine");
// tests now run against PostgreSQL 16
}
Name tests after behaviour (rejectsDuplicateTitle), test one thing each, and follow Arrange → Act → Assert. When a bug is reported, first write a test that fails because of it, then fix the code: the bug can never silently return.
Exercise#
- Add a unit test for
CourseService.updatethat checks the entity’s fields change. - Add a
@WebMvcTestforPUT /api/courses/{id}returning 404 for a missing course. - Add a
@DataJpaTestfor a derived query likefindByLevel. - Measure: run
./mvnw testand compare the time of the unit tests vs@SpringBootTestin the report.
Hint for 2
when(service.update(eq(5L), any())).thenThrow(new CourseNotFoundException(5)); then perform a PUT with a valid JSON body and expect status().isNotFound().
Interview corner#
- @SpringBootTest vs @WebMvcTest vs @DataJpaTest? Whole application context vs web layer only vs JPA layer only (with an embedded DB and rollback).
- @Mock vs @MockBean? @Mock is plain Mockito (unit tests); @MockBean (or @MockitoBean) replaces a bean inside the Spring test context.
- What is MockMvc? A way to test Spring MVC controllers by simulating HTTP requests without a server.
- Why Testcontainers? Tests run against the same database engine as production, catching dialect and migration issues.
Summary#
- Many fast unit tests with Mockito; fewer slice tests; a few full integration tests.
@WebMvcTest+ MockMvc for controllers,@DataJpaTestfor repositories,@SpringBootTestfor end-to-end.- Testcontainers gives real databases in tests.
Next: relationships, pagination and performance in a real API.
Quick quiz
3 questions. Check what stuck and earn 2 XP for each right answer.
Progress is saved in this browser. Create a free account to keep it on every device.
Get new lessons in your inbox
One email when we publish a new tutorial or guide. No spam, unsubscribe in one click.