Project: Testing a Spring Boot API
The previous nine lessons gave you every tool you need. This lesson assembles them into a single, cohesive layered test suite for a realistic REST API — a Task Manager service with Task and User entities. You will see exactly how the unit layer, the web-layer slice, the persistence slice, and the full-stack integration test complement one another, and you will understand the design decisions that keep the whole suite fast and maintainable.
The Domain at a Glance
The API exposes three endpoints:
POST /api/tasks — create a task (authenticated user)
GET /api/tasks/{id} — fetch a single task by hashed ID
DELETE /api/tasks/{id} — delete a task (owner only)
The relevant classes are Task (entity), TaskRepository (Spring Data JPA), TaskService (business logic), and TaskController (REST layer). That three-tier stack means three distinct testing layers, each with a different scope and a different Spring context.
Layer 1 — Unit Testing the Service
The service enforces a business rule: a user may only delete their own tasks. This rule lives entirely in Java and does not require any Spring context or database, making it a perfect candidate for a plain JUnit 5 + Mockito test.
@ExtendWith(MockitoExtension.class)
class TaskServiceTest {
@Mock TaskRepository taskRepository;
@InjectMocks TaskService taskService;
@Test
void createTask_persistsAndReturnsDto() {
User owner = new User(1L, "alice@example.com");
TaskCreateDto dto = new TaskCreateDto("Write tests", Priority.HIGH);
Task saved = new Task(42L, dto.title(), dto.priority(), owner, false);
when(taskRepository.save(any(Task.class))).thenReturn(saved);
TaskDto result = taskService.createTask(dto, owner);
assertThat(result.id()).isEqualTo(42L);
assertThat(result.title()).isEqualTo("Write tests");
verify(taskRepository).save(argThat(t ->
t.getOwner().equals(owner) && !t.isCompleted()
));
}
@Test
void deleteTask_throwsForbidden_whenCallerIsNotOwner() {
User alice = new User(1L, "alice@example.com");
User bob = new User(2L, "bob@example.com");
Task task = new Task(10L, "Alice task", Priority.LOW, alice, false);
when(taskRepository.findById(10L)).thenReturn(Optional.of(task));
assertThatThrownBy(() -> taskService.deleteTask(10L, bob))
.isInstanceOf(AccessDeniedException.class);
verify(taskRepository, never()).delete(any());
}
}
Why no Spring here? Starting even the lightest Spring context adds several hundred milliseconds. A service with injected mocks starts in single-digit milliseconds. Keep everything that does not touch HTTP or the database at this level.
Layer 2 — Web Layer with @WebMvcTest
@WebMvcTest(TaskController.class) spins up only the web layer — serialization, validation, security filters, and exception handlers — but does not load the persistence context. The TaskService bean is replaced with a @MockBean, so the test controls exactly what the service returns and can assert on HTTP status codes, JSON bodies, and response headers.
@WebMvcTest(TaskController.class)
@Import(SecurityConfig.class)
class TaskControllerTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
@MockBean TaskService taskService;
@MockBean UserDetailsService userDetailsService; // needed by Spring Security
@Test
@WithMockUser(username = "alice@example.com")
void getTask_returns200WithTaskDto() throws Exception {
TaskDto dto = new TaskDto(42L, "Write tests", Priority.HIGH, false);
when(taskService.getTask(42L)).thenReturn(dto);
mockMvc.perform(get("/api/tasks/42")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Write tests"))
.andExpect(jsonPath("$.priority").value("HIGH"));
}
@Test
void getTask_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/tasks/42"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "bob@example.com")
void deleteTask_returns403_whenNotOwner() throws Exception {
doThrow(new AccessDeniedException("not owner"))
.when(taskService).deleteTask(eq(42L), any());
mockMvc.perform(delete("/api/tasks/42"))
.andExpect(status().isForbidden());
}
}
Test security in the web layer. Security filters run as part of the servlet pipeline that @WebMvcTest loads. Verify 401/403 responses here, not in unit tests — the mock context respects your SecurityFilterChain bean when you @Import it.
Layer 3 — Persistence Layer with @DataJpaTest
The persistence test uses an in-memory H2 database to verify the custom JPQL queries on TaskRepository without loading the web layer or the full application context.
@DataJpaTest
class TaskRepositoryTest {
@Autowired TestEntityManager em;
@Autowired TaskRepository taskRepository;
@Test
void findByOwner_returnsOnlyOwnerTasks() {
User alice = em.persist(new User(null, "alice@example.com"));
User bob = em.persist(new User(null, "bob@example.com"));
em.persist(new Task(null, "Alice task 1", Priority.LOW, alice, false));
em.persist(new Task(null, "Alice task 2", Priority.HIGH, alice, true));
em.persist(new Task(null, "Bob task", Priority.LOW, bob, false));
em.flush();
List<Task> aliceTasks = taskRepository.findByOwner(alice);
assertThat(aliceTasks).hasSize(2)
.extracting(Task::getTitle)
.containsExactlyInAnyOrder("Alice task 1", "Alice task 2");
}
@Test
void countOpenByOwner_ignoresCompleted() {
User alice = em.persist(new User(null, "alice@example.com"));
em.persist(new Task(null, "Done", Priority.LOW, alice, true));
em.persist(new Task(null, "Pending", Priority.LOW, alice, false));
em.flush();
long open = taskRepository.countByOwnerAndCompletedFalse(alice);
assertThat(open).isEqualTo(1);
}
}
Layer 4 — Full-Stack Integration Test
The integration test starts the complete application context against a real (or Testcontainers-managed) database and exercises the full HTTP-to-database round trip. Use @SpringBootTest(webEnvironment = RANDOM_PORT) with TestRestTemplate for this.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class TaskApiIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configureDataSource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired TestRestTemplate restTemplate;
@Test
void createAndFetch_roundTrip() {
TaskCreateDto request = new TaskCreateDto("Integration task", Priority.MEDIUM);
ResponseEntity<TaskDto> created =
restTemplate.withBasicAuth("alice@example.com", "password")
.postForEntity("/api/tasks", request, TaskDto.class);
assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Long id = created.getBody().id();
ResponseEntity<TaskDto> fetched =
restTemplate.withBasicAuth("alice@example.com", "password")
.getForEntity("/api/tasks/" + id, TaskDto.class);
assertThat(fetched.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(fetched.getBody().title()).isEqualTo("Integration task");
}
}
Integration tests are slow by design. A Testcontainers-backed test may take 10–30 seconds for container startup. Keep them in a separate Maven/Gradle source set or profile (e.g. mvn verify -Pfailsafe) so the fast unit and slice tests run on every save while the integration suite runs in CI.
The Test Pyramid in Practice
The four layers above form a deliberate pyramid. Unit tests are the widest layer: fast, numerous, and focused on business logic. Web-layer tests validate HTTP contracts and security rules. Persistence tests guard your JPQL queries. Integration tests provide end-to-end confidence that all layers wire together correctly.
- Run on every file save: unit tests (milliseconds each).
- Run on every commit: unit + web-layer + persistence slice tests (seconds total).
- Run in CI on every push: all layers including integration tests.
Summary
A professional Spring Boot test suite layers four complementary strategies: pure unit tests with Mockito for business logic, @WebMvcTest slices for HTTP and security contracts, @DataJpaTest slices for persistence queries, and full-stack integration tests for end-to-end confidence. Each layer has a clear scope, a distinct Spring context (or no context), and a predictable execution cost. Matching the test type to the concern being tested is the design skill that separates a fast, trustworthy suite from a slow, fragile one.