1. Граница controller-теста: экономия нервов
Если смотреть на тесты как на «магические зелёные галочки», границы не нужны: давайте проверим вообще всё везде, а потом удивимся, почему suite запускается 6 минут, а падает из-за лишнего пробела в JSON. Но если смотреть на тесты как на инструмент управления риском, то граница — это буквально «зона ответственности», как у пограничника: он проверяет паспорт (HTTP-вход), ставит штамп (HTTP-выход) и не обязан знать, как устроена металлургия в стране назначения.
Controller-тест — это самый быстрый способ зафиксировать контракт внешнего API в рамках Spring MVC: как мы принимаем запрос, как валидируем вход, как превращаем исключения в стабильный ApiProblem, какие заголовки и статус-коды отдаём и как выглядит тело ответа. Он полезен ровно потому, что не тянет за собой базу, файловую систему и бизнес-оркестрацию. Как только вы начинаете «доказывать» в MVC-тесте сортировку, SQL-запросы и запись файла на диск, вы превращаете быстрый тест в медленный и хрупкий — и при этом всё равно не получаете честного доказательства на уровне данных/хранилища, потому что эти вещи уже не в его контексте.
Ключевая фраза сегодняшней лекции будет звучать скучно, но спасёт вам десятки часов: controller-тест начинается HTTP-входом и заканчивается HTTP-выходом. Всё остальное — только по необходимости, в минимальном объёме и без желания «поймать вообще все баги мира одним тестом».
2. MVC slice как лаборатория HTTP-границы
Когда вы пишете @WebMvcTest, вы не тестируете «приложение». Вы тестируете «как наше приложение выглядит снаружи на уровне HTTP» — и это принципиально другой объект. Если держать эту картину в голове, легче не скатиться в две крайности: либо в тест «на 200 OK», либо в тест «на всю вселенную, включая рождение звёзд и запись файла на диск».
Давайте изобразим эту границу так, чтобы потом легко вспоминать, что именно вы проверяете.
flowchart TD
A[HTTP Request] --> B["Spring MVC: mapping/binding/validation"]
B --> C[Controller method]
C --> D["Service mocked"]
D --> C
C --> E["Response mapping: status/headers/body"]
E --> F[HTTP Response]
subgraph "MVC slice under test"
B
C
E
end
subgraph "Outside slice usually mocked"
D
end
Внутри MVC slice у вас живут вещи, которые реально принадлежат web-слою: @RequestMapping, @PathVariable, @RequestParam, @RequestBody, конвертация типов (включая page/size/sort), validation-аннотации на DTO, обработка ошибок через @ControllerAdvice, сериализация ответа в JSON и заголовки ответа. И вот это всё — честная цель controller-теста.
Снаружи slice обычно лежат ваши сервисы, которые делают «настоящую работу»: выбирают статьи, применяют бизнес-правила статусов, ходят в базу, общаются с файловым хранилищем или внешним moderation-клиентом. В controller-тесте эти зависимости чаще всего подменяются через @MockitoBean, потому что иначе тест перестаёт быть controller-тестом и превращается в очень странную смесь всего сразу.
Есть хорошее практическое правило: если при падении controller-теста вы открываете код репозитория или начинаете читать SQL — значит, границу вы уже потеряли.
3. Что проверять в controller-тесте
Controller-тест — это проверка контракта. Контракт — это не только URL. Контракт — это всё, что клиент может наблюдать: какие параметры мы принимаем, какие ошибки считаем «ошибкой клиента», какие считаем «ресурс не найден», какие считаем «конфликт состояния», и как всё это выглядит в ответе.
Чтобы не уйти в списки «в 25 пунктов», давайте оформим это как таблицу: что проверяем именно здесь, на MVC-слое, потому что это либо чистый HTTP-контракт, либо Spring MVC binding/validation, которые проявляются только на web-границе.
| Часть поведения | Что именно фиксируем тестом | Почему это web-layer зона |
|---|---|---|
| Маршрутизация | URI + HTTP method попадают в нужный handler | Это ответственность контроллера и @RequestMapping |
| Binding | page/size/sort/category преобразуются в правильные типы и значения | Это делает Spring MVC conversion/binding, и это часть контракта |
| Defaults | Отсутствующий параметр даёт значение по умолчанию | Клиент должен знать, что будет без параметров |
| Validation | Невалидный вход → 400 и ApiProblem с violations | Это «пограничный контроль» по входу |
| Ошибки формата | Malformed JSON/type mismatch → корректный error payload | Клиент должен получать стабильный формат ошибок |
| Status codes | 200/201/400/404/409 в правильных местах | Это язык API, и его нельзя «оставить на удачу» |
| Headers | Content-Type, Location, Content-Disposition | Заголовки — часть контракта, особенно для files |
| Body | JSON-поля ответа, минимум критичных значений | Клиент читает тело, значит мы обязаны его фиксировать |
| Error contract | ApiProblem.status, title, errorCode и т.д. | Если контракт ошибок «поплывёт», клиенты ломаются |
И теперь важный нюанс. Когда мы говорим «проверить body», это не значит «сравнить весь JSON посимвольно». В реальной жизни JSON часто слегка эволюционирует: добавляются поля, меняются незначимые детали сериализации, порядок полей (который JSON вообще-то не обещает). В controller-тесте нам обычно важны смысловые опорные точки: status, items, page, size, errorCode, ключевые поля элемента ответа. Всё остальное — по ситуации.
Мини-пример «правильного фокуса» на публичном списке: мы не проверяем весь wrapper до последней мелочи, но фиксируем именно те поля, которые делают API полезным клиенту.
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
// Arrange: подготавливаем сервисный слой (за пределами MVC slice) как "чёрный ящик".
given(articleService.findPublished(any()))
.willReturn(pageResponse);
// Act + Assert: проверяем только HTTP-контракт (маршрут, статус, ключевые поля JSON).
mockMvc.perform(get("/api/public/articles")
.param("page", "0")
.param("size", "5"))
.andExpect(status().isOk())
// Контракт: page wrapper обязан сохранить ожидаемую форму ответа.
.andExpect(jsonPath("$.page").value(0));
Здесь тест доказывает: маршрут работает, контроллер принимает запрос публичного списка, сервисный результат превращается в ожидаемый JSON-ответ, а статус корректный. Он не доказывает, что статьи действительно отобраны и отсортированы правильно внутри сервиса — и это нормально, потому что это уже другая зона риска.
4. Чего не доказывает controller-тест
Очень легко перепутать «контракт на вход/выход» с «внутренней логикой». Особенно когда вы только что освоили Mockito, и руки чешутся проверить вообще все вызовы, аргументы и порядок. Это нормально: мозг радуется, что теперь может «контролировать реальность». Но тестовая реальность от этого не становится полезнее — она становится хрупче.
Controller-тест не обязан доказывать, что сортировка реально сортирует так, как задумано внутри сервиса. На MVC-слое мы доказываем другое: что параметр sort=publishedAt,desc вообще принимается, валидируется, корректно маппится и дальше передаётся. Сама сортировка — ответственность слоя, который реально выбирает данные. Иначе у вас будет дублирование: вы тестируете сортировку в сервисных тестах и ещё раз тестируете сортировку в controller-тестах, но второй раз — на моках, то есть по сути на ваших же предположениях.
Controller-тест не обязан доказывать работу файловой системы. Для upload/download мы фиксируем HTTP-форму: как выглядит multipart-запрос, как отрабатывает ошибка, какие заголовки у скачивания и какие байты возвращаем, если зависимость контроллера уже дала нам payload. Если вы в MVC slice начинаете писать временные файлы в tmp и проверять, что они появились на диске — это уже тест файлового адаптера, а не контроллера.
Controller-тест не обязан проверять JPA-маппинги, уникальные constraints и SQL-диалект. Даже если вам очень хочется. Даже если «всё рядом». Даже если в прошлый раз из-за базы всё упало. У controller-теста другая цена и другая цель: быстрый feedback по контракту. Попробуете смешать — получите медленный тест, который всё равно не даёт честной уверенности.
Есть полезный индикатор: если тест начинает выглядеть так, будто вы пишете «сценарий спектакля» (контроллер вызвал сервис, сервис вызвал репозиторий, репозиторий вызвал… а потом все поклонились), то вы уже проверяете не поведение, а внутреннюю постановку. Это называется overspecification, и оно почти всегда ведёт к ненужным падениям тестов при безвредном рефакторинге.
5. Mockito на web-границе: когда остановиться
Mockito в controller-тесте нужен не «для красоты» и не «потому что так принято», а чтобы вы могли изолировать HTTP-контракт от всего остального. Но вот парадокс: самый полезный Mockito тут часто — самый маленький. Один given(...) и один verify(...) могут дать больше уверенности, чем двадцать проверок взаимодействий.
Обычно у controller-теста есть два основных Mockito-сценария. Первый — сервис возвращает результат, а мы проверяем, как контроллер превратил его в HTTP-ответ. Второй — сервис бросает доменное исключение, а мы проверяем, как @ControllerAdvice превратил его в ApiProblem.
Пример с 404 на download-пути показывает, что вы тестируете именно web-границу: ошибка не «вылезла стеком», а стала нормальным JSON-ответом.
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
// Arrange: зависимость контроллера сообщает, что вложение не найдено.
given(attachmentService.download(10L, 99L))
.willThrow(new AttachmentNotFoundException(99L));
// Act + Assert: на web-границе исключение обязано стать стабильным HTTP-ответом.
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 99L))
.andExpect(status().isNotFound())
// Контракт ошибок: клиент ожидает конкретный errorCode.
.andExpect(jsonPath("$.errorCode").value("ATTACHMENT_NOT_FOUND"));
Теперь про ArgumentCaptor. Это мощная штука, но опасная, как бензопила в руках человека, который хотел просто порезать хлеб. Captor полезен, когда вы хотите доказать: «контроллер правильно собрал параметры запроса в объект запроса к сервису». Не «сервис правильно отфильтровал», а именно «контроллер правильно собрал».
Вот пример с пагинацией и сортировкой: мы не доказываем сортировку, мы доказываем, что page/size/sort/category попали в запрос к сервису как ожидается.
import org.mockito.ArgumentCaptor;
import static org.mockito.BDDMockito.then;
// Captor нужен, чтобы проверить сборку параметров контроллером в объект запроса.
ArgumentCaptor<PublicArticleQuery> captor =
ArgumentCaptor.forClass(PublicArticleQuery.class);
// Act: отправляем HTTP-запрос с query params (это и есть зона ответственности MVC).
mockMvc.perform(get("/api/public/articles")
.param("page", "2")
.param("size", "5")
.param("sort", "publishedAt,desc")
.param("category", "java"))
.andExpect(status().isOk());
// Assert: проверяем, что контроллер делегировал вызов и передал собранный query-объект.
then(articleService).should().findPublished(captor.capture());
Важный момент: дальше вы обычно делаете 1–3 смысловых assert’а на captured-объект, и на этом останавливаетесь. Если вы начнёте проверять каждую мелочь (вплоть до «какие пробелы в строке sort» или «точно ли там ArrayList, а не List»), тест превратится в охрану внутренних деталей. А нам надо охранять контракт.
6. Разбор: list, upload и download
Сейчас мы пройдём по трём наиболее «скользким» зонам дня: публичный список (query params), upload (multipart) и download (file response). Смысл — не повторить прошлые лекции, а увидеть одну общую линию: что остаётся в controller-тесте, а что мы сознательно туда не заносим.
Начнём с публичного списка. Здесь контроллеру принадлежит несколько вещей: принять page/size/sort/category, отдать page wrapper (PageResponse) и вернуть 400, если параметры невалидны. При этом контроллер не обязан понимать, как именно статьи отфильтровались внутри. Поэтому хороший тест обычно делает так: сервис возвращает «какую-то страницу» (фиксированную), а мы проверяем форму.
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
// Arrange: сервис отдаёт фиксированный PageResponse, детали выборки тут не важны.
given(articleService.findPublished(any()))
.willReturn(pageResponse);
// Act + Assert: проверяем контракт страницы в JSON (page/size и т.п.).
mockMvc.perform(get("/api/public/articles")
.param("page", "0")
.param("size", "5"))
.andExpect(status().isOk())
// Контракт: контроллер возвращает "page" и "size" в ожидаемом виде.
.andExpect(jsonPath("$.page").value(0))
.andExpect(jsonPath("$.size").value(5));
Теперь upload. На web-границе мы проверяем: имя multipart-поля, статус, error contract для пустого/слишком большого файла, и (если endpoint возвращает метаданные) — ключевые поля метаданных. Реальная запись файла в хранилище сюда не входит.
import org.springframework.mock.web.MockMultipartFile;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
// Важно: имя поля ("file") — часть HTTP-контракта multipart-запроса.
MockMultipartFile file = new MockMultipartFile(
"file", "cover.png", "image/png", new byte[] {1, 2, 3}
);
// Act + Assert: проверяем статус и ключевые поля ответа, а не реальное сохранение файла.
mockMvc.perform(multipart("/api/editor/articles/{id}/attachments", 10L)
.file(file))
.andExpect(status().isOk())
// Контракт: ответ возвращает оригинальное имя файла.
.andExpect(jsonPath("$.originalFilename").value("cover.png"));
Если вы хотите добавить минимальный verify(...), чтобы подчеркнуть границу, он должен звучать как «контроллер делегировал обработку вложения», а не как «контроллер вызвал сервис, потом сервис вызвал ещё три метода». Один вызов — нормально, особенно если вы ловили баг «вообще не вызвали зависимость» или «вызвали не ту».
И наконец download. Тут гвоздь программы — заголовки. Вложение без Content-Disposition и без корректного Content-Type для клиента часто выглядит как «в браузере открылось что-то странное» или «скачалось без имени». И вот это — идеально для controller-теста, потому что заголовки формирует web-слой.
// Act + Assert: ключевая проверка download — заголовки ответа (это часть контракта).
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L))
.andExpect(status().isOk())
.andExpect(header().string(
"Content-Disposition",
// Контракт: браузер/клиент должен получить корректное имя файла.
"attachment; filename=\"cover.png\""
));
А проверка байтового тела — это отдельный «острый соус». Она уместна, если ответ маленький и полностью детерминированный. В учебном примере с new byte[] {1, 2, 3} — вполне. Но в реальной жизни, если тело большое или формируется потоково, вы обычно фиксируете более устойчивые вещи: длину, тип, факт ненулевого контента. Иначе тесты начинают падать от любой мелочи, которая пользователю вообще не заметна.
// Этот assert уместен, когда тело маленькое и полностью детерминированное.
mockMvc.perform(get("/api/editor/articles/{id}/attachments/{attachmentId}", 10L, 3L))
.andExpect(content().bytes(new byte[] {1, 2, 3}));
Важная линия: мы не проверяем «как сервис достал байты». Мы проверяем, что HTTP-ответ выглядит правильно, когда байты уже получены.
7. Карта решений для уровня теста
Иногда самый полезный результат лекции — не новый API, а способность остановиться и выбрать правильный тип теста. Для controller-слоя удобно держать в голове пару вопросов, которые быстро возвращают вас к границе. Представьте, что вы хотите написать тест, и спросите себя: «Я проверяю форму HTTP-взаимодействия или внутреннюю правду системы?».
Вот компактная таблица-навигатор для типичных «хочется проверить»:
| “Хочу проверить…” | Если это про HTTP-контракт | Если это про внутреннюю логику |
|---|---|---|
| Параметры page/size/sort | Controller-тест: binding, defaults, 400 на плохие значения | Сервис/данные: что реально и как сортируется |
| Фильтр category=java | Controller-тест: принимает параметр, валидирует формат | Сервис/данные: отбор, join-логика, порядок |
| Ошибку “не нашли ресурс” | Controller-тест: 404 + ApiProblem.errorCode | Данные/сервис: почему не нашли и какие условия |
| Upload пустого файла | Controller-тест: 400 + стабильный error contract | Сервис: бизнес-ограничения, лимиты, политика |
| Download вложения | Controller-тест: Content-Disposition, Content-Type, тело | Storage: чтение/стриминг/файловые нюансы |
И ещё одно правило «на слух». Если ваша проверка звучит как «должно быть 404 и вот такой JSON», это почти всегда controller-тест. Если она звучит как «должна быть выбрана правильная статья из базы по сложному условию», это почти всегда другой слой. Контроллер — переводчик и пограничник, а не библиотекарь, который ищет книгу в архиве.
8. Типичные ошибки в controller-тестах
Ошибка №1: MVC-тест доказывает бизнес-правду.
В controller-тестах чаще всего ломает не Spring, а наш собственный энтузиазм. Самая распространённая ошибка — писать MVC-тест так, будто он обязан доказать бизнес-правду. В результате вы делаете огромный тест с кучей моков, который проверяет не контракт, а сценарий внутренних вызовов. Любой рефакторинг сервиса (даже без изменения внешнего поведения) начинает ронять тесты, и suite превращается в наказание за хорошие инженерные привычки.
Ошибка №2: тест превращается в «проверку 200 OK».
Вторая частая ошибка — превращать controller-тест в «проверку 200 OK». Кажется, что тест есть, галочка есть, а потом оказывается, что клиент получает не те заголовки, page по умолчанию внезапно стал 1 вместо 0, sort сломался из-за смены формата, а ApiProblem «поплыл» и фронтенд не может нормально показать ошибку. Контракт состоит из деталей, и именно детали делают API пригодным.
Ошибка №3: чрезмерная любовь к verify(...).
Третья ошибка — чрезмерная любовь к verify(...). Один verify иногда помогает подчеркнуть границу и поймать баг «не делегировали обработку». Но десять verify почти всегда означают, что тест начал описывать внутреннюю оркестрацию. Особенно токсично проверять порядок вызовов и точные аргументы там, где это не часть контракта. Это превращает тест в хрупкий сценарий, а не в защиту поведения.
Ошибка №4: подмешиваются реальные ресурсы без нужды.
Четвёртая ошибка — подмешивать реальные ресурсы туда, где они не нужны. Типичный пример: download-тест, который реально создаёт файл на диске, чтобы потом его скачать. Да, технически это «работает», но цена — связность, медленнее запуск, проблемы на CI и куча боли при параллельном выполнении. В MVC slice достаточно, чтобы зависимость контроллера вернула вам байты и метаданные, а вы проверили HTTP-ответ.
Ошибка №5: не фиксируются негативные сценарии как часть контракта.
Пятая ошибка — не фиксировать негативные сценарии как часть контракта. Upload без проверки пустого файла, pagination без проверки size=0, download без 404-ветки — это «тест есть, а страховки нет». В реальном API именно негативные сценарии чаще всего ломают клиентские интеграции, потому что happy path обычно проверяют и руками, и глазами, и всем чем можно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ