УРОК 18 / 30 0%

Spring Boot Test — slice-тесты Controller

🎯

Цель урока

Сделать полноценный CRUD для задач с правильными HTTP-статусами (201/204/404), обработкой ошибок, и подготовить Task к подключению JPA.

🧠

Теория · для собеса

1 Slice-тесты

Slice-тест = поднимает только нужный «слой» Spring, не весь контекст.

  • @WebMvcTest(TaskController.class) — только Controller-слой
  • @DataJpaTest — только JPA (с in-memory БД)
  • @RestClientTest — только RestTemplate / WebClient
2 MockMvc

MockMvc — имитация HTTP-запросов без реального сервера. Быстро, контролируемо.


mockMvc.perform(get("/api/tasks"))
    .andExpect(status().isOk())
    .andExpect(jsonPath("$[0].title").value("Task 1"));
3 @WebMvcTest
  • Загружает только Spring MVC инфраструктуру
  • @MockBean для подсовывания моков сервисов
  • @Autowired MockMvc — для выполнения запросов
  • Валидация (@Valid) работает
  • Security — НЕ загружается (если хотим — добавляем @Import(SecurityConfig.class))

---

💻

Практика: улучшаем CRUD

  1. 1
    test/.../controller/TaskControllerTest.java
    
    package com.taskflow.controller;
    
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.taskflow.dto.CreateTaskRequest;
    import com.taskflow.dto.TaskResponse;
    import com.taskflow.dto.UpdateTaskRequest;
    import com.taskflow.exception.ResourceNotFoundException;
    import com.taskflow.service.TaskService;
    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 java.time.Instant;
    import java.util.List;
    
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.ArgumentMatchers.eq;
    import static org.mockito.Mockito.*;
    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
    
    @WebMvcTest(TaskController.class)
    class TaskControllerTest {
    
        @Autowired private MockMvc mockMvc;
        @Autowired private ObjectMapper objectMapper;
    
        @MockBean private TaskService taskService;
    
        private TaskResponse sampleResponse(Long id, String title) {
            return new TaskResponse(id, title, "desc", false, 1L, "alice", Instant.now(), Instant.now());
        }
    
        @Test
        void shouldReturnListOfTasks() throws Exception {
            when(taskService.findAll()).thenReturn(List.of(
                sampleResponse(1L, "Task 1"),
                sampleResponse(2L, "Task 2")
            ));
    
            mockMvc.perform(get("/api/tasks"))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$").isArray())
                .andExpect(jsonPath("$.length()").value(2))
                .andExpect(jsonPath("$[0].id").value(1))
                .andExpect(jsonPath("$[0].title").value("Task 1"))
                .andExpect(jsonPath("$[1].title").value("Task 2"));
        }
    
        @Test
        void shouldReturnTaskWhenExists() throws Exception {
            when(taskService.findById(1L)).thenReturn(sampleResponse(1L, "Task 1"));
    
            mockMvc.perform(get("/api/tasks/{id}", 1L))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.title").value("Task 1"));
        }
    
        @Test
        void shouldReturn404WhenNotFound() throws Exception {
            when(taskService.findById(99L))
                .thenThrow(new ResourceNotFoundException("Task", 99L));
    
            mockMvc.perform(get("/api/tasks/{id}", 99L))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.status").value(404))
                .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("99")));
        }
    
        @Test
        void shouldCreateTaskAndReturn201() throws Exception {
            CreateTaskRequest req = new CreateTaskRequest("New task", "Description");
            when(taskService.create(any(CreateTaskRequest.class)))
                .thenReturn(sampleResponse(1L, "New task"));
    
            mockMvc.perform(post("/api/tasks")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isCreated())
                .andExpect(header().exists("Location"))
                .andExpect(header().string("Location", org.hamcrest.Matchers.containsString("/api/tasks/1")))
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.title").value("New task"));
    
            verify(taskService).create(any(CreateTaskRequest.class));
        }
    
        @Test
        void shouldReturn400WhenTitleIsBlank() throws Exception {
            CreateTaskRequest req = new CreateTaskRequest("", "Desc");  // title пустой
    
            mockMvc.perform(post("/api/tasks")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.status").value(400))
                .andExpect(jsonPath("$.fieldErrors").isArray())
                .andExpect(jsonPath("$.fieldErrors[0].field").value("title"));
    
            verify(taskService, never()).create(any());
        }
    
        @Test
        void shouldUpdateTask() throws Exception {
            UpdateTaskRequest req = new UpdateTaskRequest("Updated", "New desc", true);
            when(taskService.update(eq(1L), any(UpdateTaskRequest.class)))
                .thenReturn(sampleResponse(1L, "Updated"));
    
            mockMvc.perform(put("/api/tasks/{id}", 1L)
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.title").value("Updated"));
        }
    
        @Test
        void shouldDeleteTaskAndReturn204() throws Exception {
            doNothing().when(taskService).delete(1L);
    
            mockMvc.perform(delete("/api/tasks/{id}", 1L))
                .andExpect(status().isNoContent());
    
            verify(taskService).delete(1L);
        }
    
        @Test
        void shouldReturn404WhenDeletingNonExisting() throws Exception {
            org.mockito.Mockito.doThrow(new ResourceNotFoundException("Task", 99L))
                .when(taskService).delete(99L);
    
            mockMvc.perform(delete("/api/tasks/{id}", 99L))
                .andExpect(status().isNotFound());
        }
    }
    
  2. 2
    Тест валидации

    title с пустой строкой → 400, не доходит до сервиса.

  3. 3
    Тест с Security

    Если хотим тестировать с авторизацией:

    
    @WebMvcTest(TaskController.class)
    @Import(SecurityConfig.class)
    class TaskControllerSecurityTest {
    
        @Autowired private MockMvc mockMvc;
        @MockBean private TaskService taskService;
        @MockBean private JwtTokenProvider jwtTokenProvider;
        @MockBean private CustomUserDetailsService userDetailsService;
    
        @Test
        void shouldReturn401WithoutToken() throws Exception {
            mockMvc.perform(get("/api/tasks"))
                .andExpect(status().isUnauthorized());
        }
    
        @Test
        @WithMockUser(username = "alice", roles = "USER")
        void shouldReturnTasksForAuthenticatedUser() throws Exception {
            when(taskService.findAll()).thenReturn(List.of());
    
            mockMvc.perform(get("/api/tasks"))
                .andExpect(status().isOk());
        }
    }
    

    ---

🎯

Зачем это на собесе

После урока ты должен уметь ответить на:

HTTP-контракт — тесты гарантируют, что API отвечает правильными статусами и форматом JSON.
Валидация — проверяем, что @Valid работает, и фронт получит 400 + список ошибок.
Регрессии при refactor — поменяли TaskResponse? Тест сразу покажет, что сломалось.
Быстрее integration@WebMvcTest загружает ~10 бинов, не 100.

5 вопросов на углубление

Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.

1
@WebMvcTest vs @SpringBootTest + MockMvc

Разбор внутри: лёгкий slice controller-а и полный контекст приложения.

2
@MockBean deprecated: что использовать в Spring Boot 3.4+?

Разбор внутри: переход на @MockitoBean и почему старое имя путает.

3
Как тестировать загрузку файла через MockMvc?

Разбор внутри: MockMultipartFile без реального файла на диске.

4
Почему @WebMvcTest падает без мока зависимости controller-а?

Разбор внутри: загрузка контекста и обязательные зависимости.

5
Подхватывает ли @WebMvcTest @RestControllerAdvice?

Разбор внутри: component scan, @Import и тесты ошибок API.

Готов идти дальше?

Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.

🚀 Перейти к Уроку 19

Сначала пройди все секции и выбери хотя бы 1 вопрос

⬅️ Назад к Уроку 17
🎉
Новый тир
Новый тир достигнут!