JavaRush /Курсы /Spring Test /Граница controller-теста

Граница controller-теста

Spring Test
13 уровень , 4 лекция
Открыта

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());

Важный момент: дальше вы обычно делаете 13 смысловых 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 обычно проверяют и руками, и глазами, и всем чем можно.

1
Задача
Spring Test, 13 уровень, 4 лекция
Недоступна
Карточка статьи без выхода за HTTP-слой
Карточка статьи без выхода за HTTP-слой
1
Задача
Spring Test, 13 уровень, 4 лекция
Недоступна
Сборка query-объекта на границе контроллера
Сборка query-объекта на границе контроллера
1
Опрос
Публичный контракт, 13 уровень, 4 лекция
Недоступен
Публичный контракт
Тесты границы HTTP API
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ