Testing Spring Boot Applications

Project: Testing a Spring Boot API

18 min Lesson 10 of 13

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.