1. Архитектура MVC‑тестов
Когда MVC-тестов становится больше пары штук, проблема обычно не в том, что «не хватает аннотаций», а в том, что тесты начинают жить хаотично: куски public API перемешиваются с editor, рядом внезапно появляются attachment-сценарии, а через неделю никто не помнит, почему в базовом классе лежит мок какого-то сервиса, который половине тестов вообще не нужен. Архитектура suite — это способ сделать тесты предсказуемыми и быстрыми в поддержке, чтобы любой человек (включая вас через месяц) мог найти нужный сценарий за минуту, а не за полдня археологических раскопок.
В реальном проекте MVC-тесты часто растут «по потребности»: добавили endpoint — добавили тест. Это нормально. Ненормально, когда каждый новый тест добавляется в случайный файл, а общие штуки копируются в десяти местах. В какой-то момент вы получаете не suite, а «тестовую свалку», где каждый тест вроде бы зелёный, но менять что-то страшно: любая правка ломает пол-мира, потому что тесты слишком связаны.
Именно поэтому на сегодняшнем этапе курса мы фиксируем простую идею: MVC-suite должен отражать реальные поверхности API. В ContentHub эти поверхности уже явно разделены URI-префиксами (/api/public/**, /api/editor/**, /api/admin/**) и смыслом сценариев. Значит, и тестовая структура должна повторить эту картину, а не спорить с ней.
2. Зоны API: public, editor, admin
Если смотреть на API «глазами клиента», то public, editor и admin — это не просто разные контроллеры. Это разные ожидания от контракта: public API почти всегда про чтение и параметры поиска, editor API — про создание/изменение и прикладные ошибки пользователя, admin API — про административные действия и бизнес-конфликты статусов. Когда мы смешиваем это в один тестовый котёл, мы теряем смысловые границы и начинаем писать проверки «на всякий случай», а не по риску.
Чтобы этот разговор был конкретным, удобно держать в голове одну небольшую таблицу. Она помогает не путать «где мы вообще находимся» при чтении теста и какие assertions в этой зоне обычно важнее.
| Зона | Префикс URI | Типичные операции | Что обычно важно в MVC-тестах |
|---|---|---|---|
| public | /api/public/... | чтение опубликованных статей | пагинация/сортировка/фильтры, , JSON-форма ответа |
| editor | /api/editor/... | создание/редактирование, submit, attachments | 201/200/400/409, validation, multipart, download headers, error contract |
| admin | /api/admin/... | approve/reject/archive, admin listing | 200/409/404, правильные статусы и error payload |
Заметьте, мы сейчас сознательно не обсуждаем «права доступа» как предмет проверки. В реальном приложении public/editor/admin почти всегда связаны с безопасностью, но на уровне архитектуры MVC-suite нам важнее сначала разделить поверхности: иначе проверки доступа смешаются с обычными HTTP-сценариями и suite быстро потеряет форму.
Ещё один практический нюанс: каждая зона обычно имеет разный набор «любимых» helper’ов. Public чаще всего требует удобных утилит для query params (page, size, sort, category), editor — утилит для multipart и файловых фикстур, admin — удобных заготовок для команд типа approve/reject. Если всё смешано, helpers начинают разрастаться «под всё», превращаясь в мини-фреймворк, который в итоге скрывает HTTP-семантику и затрудняет чтение теста.
3. Правило: один контроллер — один тест
Когда у нас появляется соблазн написать «один большой тест на весь API», это обычно выглядит рационально: «меньше файлов, проще искать». На практике получается наоборот. Один большой тестовый класс очень быстро превращается в свалку контекстов: вам нужно замокать слишком много зависимостей, потому что контроллеров много; у вас появляются @BeforeEach с подготовкой, которая половине тестов не нужна; а ещё увеличивается шанс случайно сломать чужой тест, потому что вы поменяли общую настройку ради одного кейса.
Правило «один контроллер — один тестовый класс» работает как санитарная норма. Оно заставляет вас держать @WebMvcTest узким, а значит, быстрым и понятным. Вы открываете файл PublicArticleControllerWebMvcTest — и ваш мозг мгновенно понимает, что тут живут только сценарии GET /api/public/articles и GET /api/public/articles/{slug}. Никаких upload’ов, никаких admin approve. Просто приятно.
При этом «один контроллер — один класс» не запрещает внутри класса иметь несколько сценариев. Наоборот: внутри одного контроллера вы можете организовать тесты так, как вам удобно. Часто хорошо работают @Nested-классы: например, отдельная группа тестов для пагинации, отдельная — для сортировки, отдельная — для негативных параметров. Но ключевое — граница «контроллер → тестовый класс» остаётся чёткой.
Ниже — минимальный каркас, который отражает этот принцип и при этом не превращается в заготовку на 200 строк.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
// MVC-slice: поднимаем узкий контекст вокруг одного контроллера.
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
@Test
void shouldReturnPublishedArticles() {
// Здесь позже появятся проверки контракта ответа (JSON-форма, пагинация и т.д.).
}
}
Пока это выглядит слишком пусто, но в этом и смысл: мы строим архитектуру как «скелет», который потом обрастает мышцами. Если скелет кривой, мышцы тоже будут смотреться… своеобразно.
4. Структура тестов в src/test/java
Структура тестов — это не «красота ради красоты». Это способ сделать так, чтобы IDE и мозг работали вместе. Когда вы видите пакет ...api.controller.editor, вы сразу ожидаете editor endpoint-ы и их специфику. Когда вы видите ...api.controller.publicapi, вы не ждёте там MockMultipartFile. И это уменьшает когнитивную нагрузку сильнее, чем кажется: мозг перестаёт держать «всё сразу», потому что структура подсказывает контекст.
Самый простой и рабочий подход в ContentHub — зеркалить структуру production-пакетов на уровне тестов, но с добавлением «зон». Если у вас контроллеры лежат в ...api.controller, то в тестах логично сделать подпакеты publicapi, editor, admin. URI-префикс при этом остаётся /api/public/**, но Java package буквально public лучше не делать: это ключевое слово, поэтому нужен compile-safe alias.
src/test/java
└── com.example.contenthub
└── api
└── controller
├── publicapi
│ ├── PublicArticleControllerWebMvcTest.java
│ └── PublicArticleUrls.java
├── editor
│ ├── EditorArticleControllerWebMvcTest.java
│ ├── EditorArticleUrls.java
│ └── AttachmentTestFiles.java
└── admin
├── AdminArticleControllerWebMvcTest.java
└── AdminArticleUrls.java
Обратите внимание: helpers лежат рядом с тестами своей зоны. Это не догма, но это часто очень практично. Когда вы держите EditorArticleUrls рядом с EditorArticleControllerWebMvcTest, вы не создаёте глобального «utility-пакета», в который со временем начинают складировать всё подряд, включая «магические» константы и куски логики.
Если вам всё-таки нужен общий helper (например, загрузчик JSON-фикстур или маленькая утилита для assertions ApiProblem), лучше держать его в отдельном тестовом пакете, но с очень строгой ответственностью. Например, com.example.contenthub.testutil.json или ...testutil.web. Важно, чтобы это не стало «общей помойкой», а оставалось набором маленьких, честных инструментов.
Ещё одна практическая деталь: src/test/resources тоже стоит структурировать. Если у вас есть тестовые файлы для multipart (картинка, небольшой бинарник), не кладите их в корень ресурсов. Сделайте что-то вроде src/test/resources/files/attachments/cover.png. Тогда по репозиторию не будет ощущение «кто-то взорвал папку с ресурсами, и теперь мы живём среди обломков».
5. Helpers и фикстуры
Когда люди слышат «архитектура тестов», они часто представляют себе «давайте сделаем общий базовый класс на все тесты». Это звучит заманчиво: один раз настроили — и готово. Но в MVC-slice контексте это почти всегда приводит к обратному: общая база начинает тянуть лишние моки, лишние @Import, лишние настройки сериализации, и в итоге вы получаете больше контекстов, меньше прозрачности и больше загадочных падений.
Гораздо здоровее мыслить так: shared helper — это то, что уменьшает шум, но не меняет смысл. Например, вынести строки URL в отдельный класс — нормально: вы убираете дублирование, но не скрываете HTTP. А вот вынести «сделай запрос на editor endpoint с правильными заголовками, телом, дефолтными параметрами и проверками» — уже опасно, потому что вы начинаете прятать контракт внутри DSL.
Небольшой пример «безопасного» helper’а для URL. Он не умничает, он просто собирает строку, а тест по-прежнему явно указывает параметры.
final class PublicArticleUrls {
private PublicArticleUrls() {
// Utility-класс: экземпляры не нужны.
}
static String list() {
// Явно фиксируем public endpoint для списка, без "магии" и DSL.
return "/api/public/articles";
}
static String details(String slug) {
// slug — часть пути, а не query param: так проще читать тесты.
return "/api/public/articles/" + slug;
}
}
Похожий подход можно сделать и для editor/admin зон, но важно не смешивать. Идея простая: PublicArticleUrls не должен знать ничего про attachments, а EditorArticleUrls не должен знать ничего про сортировку public выдачи. Если helper начинает «знать слишком много», он перестаёт быть helper’ом и превращается в альтернативный API, который нужно поддерживать.
С фикстурами та же логика. Для editor upload/download удобно иметь маленький класс, который создаёт MockMultipartFile с предсказуемыми параметрами. Но пусть он будет локальным для editor пакета, чтобы public тесты не «случайно» начали таскать в себе файловые сценарии.
import org.springframework.mock.web.MockMultipartFile;
final class AttachmentTestFiles {
private AttachmentTestFiles() {
// Utility-класс: фабрика тестовых файлов.
}
static MockMultipartFile smallPng() {
// Минимальный "псевдо-файл": важны имя поля, filename и content-type.
return new MockMultipartFile(
"file",
"cover.png",
"image/png",
new byte[] { 1, 2, 3 } // Контент неважен, если мы не тестируем реальную обработку изображения.
);
}
}
Это коротко, понятно и не превращает тесты в «магический театр».
6. Настройка @WebMvcTest по зонам
Самая частая причина, почему MVC-slice «вдруг стал медленным и странным», — это бесконтрольный рост конфигурации. В какой-то момент кто-то добавил @Import на пол-проекта, потом добавил ещё один @Import, потом появился общий base class, который «на всякий случай» подключает всё. Итог: @WebMvcTest превращается в почти-полный контекст, только без репозиториев, но с тем же уровнем боли.
Правильная интуиция здесь такая: у каждой зоны есть свой минимальный набор инфраструктуры. В public тестах вам обычно нужен контроллер, его сервис (замокан), @ControllerAdvice для ошибок и, возможно, конфигурация JSON/объектного маппера (которая и так приходит из Boot auto-config). В editor тестах добавляется multipart-часть, но всё равно контроллер и его зависимости остаются основой. В admin тестах чаще всего ничего «особого» не нужно — кроме тех же базовых вещей.
Пример аккуратного public MVC slice с явно добавленным exception handler и замоканным сервисом. Здесь видно, что тест держится вокруг контроллера, а не вокруг полуприложения.
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@WebMvcTest(PublicArticleController.class)
// Подключаем общий обработчик ошибок, чтобы контракт ошибок тоже был стабилен в тестах.
@Import(ApiExceptionHandler.class)
class PublicArticleControllerWebMvcTest {
@MockitoBean
private PublicArticleService publicArticleService; // Мокаем зависимость контроллера, не поднимая сервисный слой.
}
Editor suite выглядит похоже, только моки другие. Суть в том, что зависимости должны отражать реальный контроллер: если editor контроллер работает с attachment-операциями через отдельный сервис — мокайте его. Если он делегирует всё в один фасад — мокайте фасад. Но не подтягивайте чужие сервисы «на всякий случай», иначе вы очень быстро начнёте тестировать не контракт, а свои моки.
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@WebMvcTest(EditorArticleController.class)
@Import(ApiExceptionHandler.class) // Для editor зоны особенно важно тестировать error contract (валидация, 409 и т.д.).
class EditorArticleControllerWebMvcTest {
@MockitoBean
private EditorArticleService editorArticleService; // Ровно те зависимости, которые нужны этому контроллеру.
}
Admin suite — отдельный класс. Даже если там «всего два теста», отдельный класс всё равно лучше. Он станет естественным местом, куда лягут будущие проверки административных действий, и вам не придётся потом переселять тесты, как жильцов в процессе ремонта.
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@WebMvcTest(AdminArticleController.class)
@Import(ApiExceptionHandler.class) // Конфликты статусов и бизнес-ошибки тоже должны иметь единый формат.
class AdminArticleControllerWebMvcTest {
@MockitoBean
private AdminArticleService adminArticleService; // Админ-действия часто завязаны на сервисные команды.
}
И ещё один нюанс про свойства. Иногда зоны отличаются лимитами и конфигурацией (например, attachment size limit). Если вам нужно переопределить property для конкретного класса теста, делайте это локально, чтобы не заразить остальные suites. Здесь важно удержать принцип: конфигурация — тоже часть контракта зоны, но не надо превращать property overrides в глобальную кашу.
Мини-шаблон для трёх suites
Когда структура пакетов и конфигурация зафиксированы, становится проще писать тесты: вы уже знаете, куда добавить новый кейс, и какой style там ожидается. В идеале любой новый тест «естественно» ложится в правильный пакет, и вам не нужно думать о том, где он будет жить. Это как хорошо расставленные ящики на кухне: ножи не хранятся в холодильнике, даже если «так ближе».
Ниже — короткий пример того, как могут выглядеть каркасы тестов по зонам. Я намеренно показываю по одному небольшому тесту, чтобы была видна идея «разные поверхности → разные сценарии», а не чтобы мы переписали половину проекта.
Public suite: акцент на query params и page metadata.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
class PublicArticleControllerWebMvcTest {
@Autowired
private MockMvcTester mvc; // Тестируем HTTP-границу, поэтому работаем через MockMvc.
@Test
void shouldReturnDefaultPageWhenNoParams() {
// Public: типичный сценарий — запрос без параметров и базовая "страница по умолчанию".
mvc.perform(get(PublicArticleUrls.list()))
.assertThat()
.hasStatusOk(); // Минимальная проверка: статус. JSON-проверки добавятся в профильных тестах.
}
}
Editor suite: акцент на multipart и контракт поля file. Тут прекрасно видно, что это другой мир, чем public выдача.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
class EditorArticleControllerWebMvcTest {
@Autowired
private MockMvcTester mvc; // Здесь важно видеть multipart как "настоящий" HTTP-запрос.
@Test
void shouldUploadAttachment() {
// Editor: multipart — часть контракта, поэтому в тесте это должно быть явно.
mvc.perform(multipart("/api/editor/articles/{id}/attachments", 10L)
.file(AttachmentTestFiles.smallPng())) // Поле "file" и content-type должны совпадать с контрактом.
.assertThat()
.hasStatusOk();
}
}
Admin suite: акцент на административное действие. Да, тест выглядит простым — и это нормально. Архитектура suite не обязана быть сложной, чтобы быть полезной.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
class AdminArticleControllerWebMvcTest {
@Autowired
private MockMvcTester mvc; // Admin сценарии часто "командные": POST на действие.
@Test
void shouldApproveArticle() {
// Admin: важно тестировать именно endpoint действия и ожидаемый статус (часто 200 или 409).
mvc.perform(post("/api/admin/articles/{id}/approve", 10L))
.assertThat()
.hasStatusOk();
}
}
Здесь кто-то может спросить: «А где проверки JSON, заголовков и всего такого?» Они будут — и у вас уже есть лекции и инструменты, чтобы их писать. Но смысл этой лекции в другом: показать, что эти проверки должны жить в правильных местах. И тогда suite растёт не как снежный ком хаоса, а как аккуратная библиотека сценариев по зонам.
7. Типичные ошибки при архитектуре MVC-test suite
Ошибка №1: один огромный тестовый класс на всё API.
Сначала это кажется удобным: один файл, один контекст, «всё под рукой». Потом в этом файле появляется 40 тестов, 12 моков, общая подготовка в @BeforeEach, которую никто не понимает, и любой новый сценарий ломает что-то в неожиданном месте. MVC-suite лучше переживает рост, когда разделён по поверхностям.
Ошибка №2: общий базовый класс, который «помогает всем», но на самом деле мешает.
Base class почти всегда начинает тянуть лишнюю конфигурацию, лишние @Import и лишние моки. В результате @WebMvcTest перестаёт быть узким slice-тестом. Если вам очень хочется переиспользования, начинайте с маленьких final helper-классов и локальных утилит рядом с тестами, а не с наследования.
Ошибка №3: общий пакет util, куда сваливают всё подряд.
Сначала туда кладут Urls, потом — фабрики DTO, потом — «удобный» метод, который делает запрос и сразу проверяет половину ответа, потом — константы статусов, и в итоге util превращается в альтернативное приложение. Полезный helper должен либо быть маленьким и честным, либо быть локальным для своей зоны API.
Ошибка №4: helpers скрывают HTTP-контракт вместо того, чтобы уменьшать шум.
Есть тонкая грань между «убрали повторяющуюся строку URL» и «спрятали смысл теста». Если тест читался как HTTP-сценарий, а после рефакторинга стал читаться как вызов неизвестного DSL, вы победили копипасту, но проиграли понимание. В MVC-тестах это особенно болезненно: вы тестируете именно HTTP-границу, и она должна быть видна.
Ошибка №5: смешение зон через переиспользование “удобных” вещей.
Например, вы сделали helper для editor-запросов (с multipart, заголовками, какими-то дефолтами) и начали использовать его в public тестах «потому что удобно». Через время public suite неожиданно начинает зависеть от деталей editor API. Правильнее держать helpers и фикстуры «в зоне», иначе вы получите тестовую связанность там, где в продукте её нет.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ