JavaRush /Курси /Spring REST & MVC /@WebMvcTest: тестови...

@WebMvcTest: тестовий зріз web-layer

Spring REST & MVC
Рівень 30 , Лекція 0
Відкрита

1. Фокус на тестуванні web-layer

Якщо чесно, більшість API «ламається» не через чийсь злий намір. Зазвичай це стається через невелику правку «на пʼять хвилин»: змінили імʼя поля в DTO, трохи переінакшили статус-код, додали @Valid — і раптом клієнт перестав розуміти ваш сервер. Тому наше завдання сьогодні — навчитися тестувати саме зовнішній контракт контролерів, а не влаштовувати іспит усьому застосунку разом.

На рівні відчуттів здається: «Ну я ж перевірив у Postman, усе працює». Проблема в тому, що Postman — це чудова лупа для ручної перевірки, але дуже погана система сповіщення. Він не прокинеться вночі й не скаже: «Гей, ви випадково почали повертати 200 замість 404». Автотести якраз про це: вони не замінюють мислення, але роблять регресії помітними одразу, поки ви ще памʼятаєте, що змінювали.

Контракт у нас уже видно ззовні: URI, DTO, validation, ProblemDetail, multipart-сценарії та документація зібрані в одну зрозумілу API-поверхню. Тепер важливо, щоб випадкова правка не змінювала цю домовленість мовчки. Тому далі все будується за простою послідовністю: спершу обираємо розумну межу тестування, потім збираємо controller slice, фіксуємо позитивний сценарій, далі — негативний, а наприкінці дістаємося кінцевих точок для файлів і загального аудиту web-шару.

І ось тут виникає важлива дисципліна: що саме ми хочемо зафіксувати тестами в REST-курсі. У цьому курсі ми будували сервіс без бази даних і механізмів безпеки, і це свідоме обмеження. Тому для нас «головний продукт» — не persistence, не транзакції й не інфраструктура, а web/API contract layer: маршрутизація, binding, JSON-форма відповідей, validation, глобальна обробка помилок, коректні статуси та заголовки. Саме цей шар і має мати свою межу тестування — і в межах курсу цією межею буде web-layer slice через @WebMvcTest.

Щоб звучало зовсім по-людськи, можна уявити API як вхідні двері до квартири. Вам не потрібно щоразу, перевіряючи замок, заодно розбирати всю сантехніку й перекладати проводку. Але ви зобовʼязані бути впевнені, що двері відчиняються саме тим ключем, який потрібен, і що домофон не відповідає «200 OK» на будь-яке натискання.

2. Web-контракт у Task Tracker API

Коли ми говоримо «web-layer» у цьому курсі, ми маємо на увазі не абстрактні «контролери». Ми маємо на увазі публічний контракт, який бачить зовнішній клієнт: мобільний застосунок, фронтенд, інтеграція чи навіть скрипт на Python. Клієнту байдуже, як усередині влаштований ваш сервіс, але йому дуже не байдуже, що повернеться на GET /api/v1/tasks і що станеться на неправильному JSON.

У цього контракту є кілька вимірів, і вони дуже добре тестуються саме на рівні Spring MVC:

Частина контракту Приклад у проєкті Чому це критично Що реально перевіряє тест
Маршрут + HTTP-метод GET /api/v1/tasks/{taskId} Клієнт має потрапити «у правильний метод» status, іноді handler опосередковано через результат
Звʼязування вхідних даних taskId із path, page/size/sort із query Помилка часто виникає ще до сервісу що за неправильного параметра повертається коректна помилка
Статус-коди 201 Created, 204 No Content, 404 Not Found, 409 Conflict Це семантика, а не косметика status().isCreated(), isNoContent() тощо
Заголовки Location під час створення, Content-Disposition під час завантаження Клієнтський код часто завʼязаний на них header().string(...)
JSON shape PagedResponse<T>, detail DTO, error payload Стабільність контракту перевірки форми через jsonPath(...)
Validation @Valid для create/update, constraints для criteria Невалідний input не має доходити до логіки 400 + code=INVALID_INPUT + field errors
Error contract ProblemDetail + application code Помилки — частина API application/problem+json і поля відповіді

Зверніть увагу на важливий нюанс: майже всі ці речі перевіряються без справжнього запуску сервера на порту, без реальної бази даних і без реальної файлової системи. І це не «читерство», а нормальна інженерна економія: ви тестуєте саме те, що хочете закріпити.

Внутрішні деталі — як TaskService шукає задачу в in-memory репозиторії, як storage зберігає файл на диск — це вже інша зона відповідальності. Вона може мати свої тести, але в нашому курсі вони не є головною віссю. Зараз ми захищаємо шар домовленостей із зовнішнім світом.

3. @WebMvcTest vs @SpringBootTest

В екосистемі Spring Boot є кілька способів писати тести, і головна пастка новачка звучить так: «Я бачив @SpringBootTest, отже це і є правильний шлях». Приблизно як: «Я бачив кувалду, отже будь-який цвях — її робота». Кувалда, звісно, універсальна, але іноді ви просто хотіли повісити картину.

@SpringBootTest піднімає повний контекст застосунку. Це корисно, коли ви справді перевіряєте інтеграцію шарів: контролер → сервіс → репозиторій → конфігурація → інфраструктура. Але в повного контексту є ціна: повільніший старт, більше залежностей, більше причин, чому тест може впасти через несуттєві деталі. Для курсу з проєктування REST API це часто перетворюється на «шум замість сигналу».

@WebMvcTest — це інший стиль мислення. Це slice-тест web-рівня: тестовий режим, який піднімає лише MVC-частину застосунку (контролери, конвертери, validation, обробники помилок тощо) і дає вам MockMvc, щоб відправляти запити та перевіряти відповіді. Він спеціально створений для випадків, коли вас цікавить саме HTTP/JSON-контракт.

Зручно запамʼятати це порівнянням:

Анотація Що піднімається Що ми тестуємо Ціна (час/шум)
@WebMvcTest web-layer + MVC-інфраструктура HTTP-контракт контролерів зазвичай швидко й локально
@SpringBootTest увесь контекст застосунку інтеграцію шарів «як під час реального запуску» зазвичай повільніше й важче

І ось чому в цьому курсі ми обираємо @WebMvcTest як основний режим: наша мета — підтвердити, що зовнішній контракт відповідає тому, що ми проєктували протягом усього курсу. Ми не будуємо тут «бойове оточення», а створюємо тести, які захищають домовленості API.

Для контрасту покажу мінімальний «повний» тестовий каркас. Він не поганий, він просто іншого масштабу:

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;

// Піднімаємо ВЕСЬ контекст застосунку (дорожче за часом, більше залежностей).
@SpringBootTest
// Підключаємо MockMvc поверх повного контексту.
@AutoConfigureMockMvc
class TaskControllerFullContextTest {
    // Тут зазвичай будуть @Autowired MockMvc та/або інтеграційні перевірки.
}

А ось @WebMvcTest — якраз той самий «вузький прожектор», який світить туди, куди потрібно:

import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;

import com.example.tasktracker.api.controller.TaskController;

// Піднімаємо лише MVC-зріз навколо конкретного контролера.
@WebMvcTest(TaskController.class)
class TaskControllerWebMvcTest {
    // Тут ми тестуємо HTTP/JSON-контракт, а не сервіси/репозиторії/інфраструктуру.
}

Якщо коротко: @SpringBootTest перевіряє «як система працює цілком», а @WebMvcTest — «як двері розмовляють із клієнтом». У цьому курсі ми навчали саме цієї «мови дверей».

4. Що піднімає @WebMvcTest

Слово «slice» звучить трохи гастрономічно, але ідея проста: ми беремо застосунок і піднімаємо в тесті лише той шматок, який стосується web-layer. Це не магія, а свідоме обмеження контексту Spring. Саме тому тести виходять швидкими та сфокусованими.

Усередині @WebMvcTest зазвичай опиняються речі, без яких MVC просто не працює: DispatcherServlet, механіка маршрутизації, argument resolvers, які розуміють @PathVariable, @RequestParam, @RequestBody, HttpMessageConverter для JSON, Bean Validation, а також ваші контролери й @ControllerAdvice, якщо вони беруть участь у єдиному error contract.

Сервіси, репозиторії та інфраструктурні компоненти туди за замовчуванням не потрапляють. Це і є головна межа slice: контролер і MVC-пайплайн залишаються справжніми, а залежності контролера в тесті мають бути керованими.

Хороша ментальна картинка для @WebMvcTest у нашому проєкті виглядає так:

flowchart TD
  T["Тест JUnit Jupiter"] --> M["MockMvc"]
  M --> D["DispatcherServlet (MVC-пайплайн)"]
  D --> C["TaskController"]
  C --> S["TaskService (mock)"]
  C --> J["Jackson + HttpMessageConverter"]
  C --> V["Bean Validation"]
  D --> A["@ControllerAdvice (ProblemDetail)"]
  A --> M

У цій схемі навмисно немає in-memory репозиторію та локального file storage. У межах @WebMvcTest нас цікавить одна річ: контролер коректно перетворює запит у виклик сервісу і назад у HTTP-відповідь, застосовуючи validation та єдину обробку помилок. Якщо тесту вже потрібні диск, seed data й увесь інший runtime, це хороший сигнал, що ви вийшли на інший рівень перевірки.

5. MockMvc: HTTP без реального порту

Слово MockMvc іноді вводить в оману: здається, що це якась «ненасправжня імітація», якій не можна довіряти. На практиці MockMvc — це дуже чесний інструмент. Він не піднімає реальний сервер (Tomcat/Jetty) і не робить мережевий запит через TCP, але при цьому проганяє ваш запит через справжню MVC-механіку: мапінги, binding, message conversion, validation, exception handling. Тобто все, що стосується web-layer, працює «по-справжньому».

У результаті ви отримуєте майже ідеальну ситуацію для навчання і для захисту контракту: тест швидкий, передбачуваний, не залежить від вільного порту на вашій машині й не потребує «все підняти, все налаштувати». А перевіряєте ви рівно те, що хотів би побачити клієнт: статус, заголовки, content type, JSON.

Наймінімальніший тест зазвичай виглядає так: ви відправляєте запит і перевіряєте статус. Так, це ще не повноцінна перевірка контракту (ми пізніше дійдемо до заголовків і JSON shape), але це чудовий перший крок, щоб почати мислити «як HTTP-клієнт».

import org.junit.jupiter.api.Test; 
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void listTasks_returns200() throws Exception {
    // mockMvc зазвичай доступний із контексту @WebMvcTest — це наш «віртуальний HTTP-клієнт».
    mockMvc.perform(get("/api/v1/tasks"))
            // Фіксуємо частину контракту: endpoint має повертати 200 OK.
            .andExpect(status().isOk());
}

Важливо помітити стиль: ми не викликаємо метод контролера напряму як звичайну Java-функцію. Ми справді моделюємо вхід ззовні. Це і є причина, чому такі тести корисні: вони захищають API від «випадкової еволюції» й змушують нас думати категоріями контракту.

І так, MockMvc — це саме те місце, де зручно перевіряти дрібниці, які постійно забувають під час ручної перевірки. Наприклад, Location для 201 Created або Content-Disposition для завантаження. У Postman ви це побачите, але не завжди згадаєте переперевірити. У тесті ви це фіксуєте один раз — і далі живете спокійніше.

6. Каркас slice-тесту TaskController

Щоб @WebMvcTest був не абстракцією «десь там», а реальним інструментом у проєкті, корисно побачити мінімальний каркас. Він уже показує головне: контролер і MVC-пайплайн піднімаються по-справжньому, а сервісна залежність підміняється керованим біном.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import com.example.tasktracker.api.controller.TaskController;
import com.example.tasktracker.domain.service.TaskService;

// Піднімаємо MVC-зріз лише для TaskController.
@WebMvcTest(TaskController.class)
class TaskControllerWebMvcTest {

    // Головний інструмент для «HTTP-запитів» без реального порту.
    @Autowired
    MockMvc mockMvc;

    // Залежність контролера підміняємо mock-обʼєктом, щоб тест був детермінованим.
    @MockitoBean
    TaskService taskService;
}

Цього каркаса вже достатньо, щоб бачити механіку: MockMvc надсилає запити через справжній MVC-пайплайн, а сервісний шар підмінено керованим біном. На цьому ж каркасі далі тримаються thenReturn/thenThrow, робота з JSON body через ObjectMapper і всі contract-asserts.

7. Межа @WebMvcTest

У цього інструмента є дуже чітка межа. Він потрібен, щоб захищати зовнішній HTTP-контракт, а не одразу доводити коректність усього застосунку.

Тут доречно перевіряти статуси, заголовки, content type, JSON shape, validation і єдиний ProblemDetail.

Тут не варто доводити, як in-memory репозиторій сортує дані, як storage пише файл на диск або як ініціалізуються seed data.

Якщо тесту вже потрібні реальний диск, повний набір сервісів та інфраструктури, ви вийшли на інший рівень перевірки. @WebMvcTest від цього не стає «слабким» — він просто тримає фокус там, де контракт справді видно клієнту.

8. Типові помилки на старті з @WebMvcTest

Коли розробник уперше починає писати MVC slice-тести, він часто відчуває, що Spring «вередує»: то контекст не зібрався, то якась залежність не знайдена, то тест дивно падає при нібито простому запиті. Це нормальна стадія звикання, і вона майже завжди зводиться до кількох повторюваних помилок.

Помилка №1: використовувати @SpringBootTest «за звичкою», а потім дивуватися, що все довго й шумно.
Якщо мета тесту — перевірити, що контролер віддає правильний статус, заголовок і JSON-форму, повний контекст частіше заважає, ніж допомагає. Ви починаєте виправляти конфігурацію всього застосунку, хоча хотіли перевірити одні двері. У результаті тести стають повільними, а падіння — неочевидними: проблема може бути взагалі не в контролері, а в якомусь інфраструктурному біні, який вам у цьому тесті навіть не потрібен.

Помилка №2: намагатися через @WebMvcTest перевірити роботу сервісу, репозиторію та файлового сховища одночасно.
Це дуже часта психологічна пастка: здається, що «чим більше перевірю за один раз, тим краще». На практиці ви отримуєте тест, який погано локалізує проблему. Тест упав — і незрозуміло, що зламалося: routing, JSON mapping, validation, сервісна логіка чи робота файлової системи. Сенс slice-тесту якраз у тому, щоб тримати фокус на web-шарі й не змішувати рівні відповідальності.

Помилка №3: викликати методи контролера напряму як звичайні Java-методи й вважати це «тестом API».
Якщо ви викликаєте taskController.listTasks() напряму, ви пропускаєте половину того, що робить Spring MVC: binding параметрів, читання @RequestBody, роботу валідатора, message conversion, @ControllerAdvice. Такий тест може бути корисним як unit test контролера (хоча в нас контролери й тонкі), але він не перевіряє зовнішній контракт так, як його бачить HTTP-клієнт. У межах цього курсу ми хочемо саме «клієнтський погляд», тому MockMvc — основний інструмент.

Помилка №4: перевіряти в тестах лише статус, ігноруючи заголовки та форму JSON.
Перевірка status().isOk() — гарний початок, але якщо зупинитися лише на ній, тести будуть надто слабкими. Контракт ламається найчастіше саме в деталях: змінили Location, забули Content-Type, змінили shape відповіді, зламали поля ProblemDetail. Ці помилки «не видно» за одним лише кодом статусу. Тому web-layer тести цінні саме тим, що можуть фіксувати форму відповіді.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ