JavaRush /Курсы /Spring Test /Один контроллер — один test class

Один контроллер — один test class

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

1. Один контроллер — один test class

Если смотреть на @WebMvcTest как на «микроскоп», то контроллер — это объект, который мы кладём на предметный столик. Можно, конечно, попытаться засунуть туда сразу три объекта, пару гайек и кота (кот всегда найдёт, как попасть в кадр), но тогда вы перестаёте понимать, что именно вы рассматриваете. Правило «один контроллер — один test class» — это попытка сохранить фокус: один тестовый класс защищает один HTTP-контракт и одну зону ответственности.

На практике это правило решает сразу две бытовые проблемы. Во‑первых, оно резко уменьшает количество «случайных зависимостей»: когда вы тестируете публичный controller, вам не нужны моки и wiring от editor/admin контроллеров. Во‑вторых, оно делает падения тестов диагностируемыми. Если упал PublicArticleControllerWebMvcTest, вы почти всегда начинаете искать проблему именно в public web-слое, а не в «каком-то из 27 endpoint-ов, которые мы зачем-то тестировали в одном классе».

Давайте быстро зафиксируем, что мы подразумеваем под «одним контроллером» в рамках нашего проекта ContentHub. У нас есть разные поверхности API: публичная (анонимное чтение опубликованных статей), editor (создание/редактирование/отправка на ревью) и admin (модерация, публикация, архивирование). Эти поверхности могут работать с одним и тем же доменным словарём («Article»), но это всё равно разные контракты. Именно поэтому тесты тоже должны жить раздельно.

Вот правильный «скелет» тестового класса под один контроллер:

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

// Важно: явно указываем контроллер, который кладём под «микроскоп»
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
    // Здесь будут только зависимости и сценарии public-контракта
}

И вот формально рабочий, но методически опасный вариант:

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

// Опасно: без параметров Spring Boot поднимет все найденные контроллеры
@WebMvcTest
class ArticleApiWebMvcTest {
    // Контекст разрастается, и тест внезапно становится «про всё на свете»
}

Во втором случае вы говорите Spring Boot: «Подними мне web-slice, но контроллеры я не уточню — догадайся сам». Spring Boot догадается: он поднимет все найденные контроллеры в этом slice. А дальше начинается типичная цепочка событий: тестов вроде бы у вас один класс, но зависимостей и “почему он вообще не стартует” — как будто вы решили поднять половину приложения.

2. Риски универсального @WebMvcTest

Есть очень человеческое желание: «Сделаю один большой тестовый класс, чтобы не плодить файлы». Это желание родное, понятное и даже экономит пару секунд на создании класса. Но в тестах такая “экономия” часто превращается в кредит под 300% годовых: проценты вы платите каждую неделю, когда что-то меняется в коде. Универсальный @WebMvcTest почти всегда размазывает границы slice и превращает узкий web-тест в странного гибрида «тестируем всё и ничего».

Самый частый эффект выглядит так: контекст не стартует, потому что один из контроллеров в slice тянет зависимость, которой в @WebMvcTest по умолчанию нет. И вы внезапно чините не тест публичного API, а wiring для админского endpoint-а — просто потому что оба оказались в одном контексте.

Представим упрощённую картину. Когда вы запускаете один “универсальный” @WebMvcTest, фактически вы делаете вот это:

flowchart TD
  Test["ArticleApiWebMvcTest (@WebMvcTest без списка)"] --> C1["PublicArticleController"]
  Test --> C2["EditorArticleController"]
  Test --> C3["AdminArticleController"]
  C1 --> S1["PublicArticleService (нужен мок)"]
  C2 --> S2["EditorArticleService (нужен мок)"]
  C3 --> S3["AdminArticleService (нужен мок)"]

Вы можете сказать: «Ну и что? Замокаю всё». Да, можно. Но тогда тестовый класс становится не про один контракт, а про сборку коллекции моков. А если вы случайно добавили новый контроллер или новый аргумент в конструктор контроллера — этот “универсальный” тест начинает падать, даже если вы вообще не писали ни одного тестового метода под новый endpoint. В итоге тестовый класс начинает вести себя как сигнализация в подъезде: орёт не только когда пожар, но и когда кто-то открыл окно.

Почти всегда это приводит к таким последствиям, и это важно прочувствовать, а не просто запомнить как лозунг. Во-первых, в одном тестовом классе начинает копиться слишком много @MockitoBean. Даже если каждый мок «невинный», общая картина превращается в шум. Во-вторых, тесты становятся логически связанными: вы правите admin-контроллер, а у вас вдруг падают public-тесты, потому что контекст теперь требует ещё один бин. В-третьих, вы незаметно теряете смысл slice: он становится шире, чем нужно для конкретной проверки.

Вот короткая иллюстрация того, как «универсальный» тест вынуждает вас тянуть моки не по делу:

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

@WebMvcTest
class ArticleApiWebMvcTest {

    // И это только начало...
    // public, editor, admin — всё в одной коробке
    // Чем больше контроллеров попало в slice, тем больше моков потребуется просто для старта контекста
}

А теперь сравните с фокусным подходом:

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

// Хорошая практика: тестируем один контракт и один контроллер
@WebMvcTest(AdminArticleController.class)
class AdminArticleControllerWebMvcTest {
    // Здесь вы думаете только про admin контракт,
    // а не про весь API сервиса.
}

Разница не в количестве строк кода. Разница в том, о чём вы вынуждены думать, открыв этот файл.

3. Поверхности API: public / editor / admin

В ContentHub есть простой, но очень жизненный факт: один и тот же ресурс «статья» выглядит по‑разному в разных зонах доступа. Публичное API — про чтение опубликованного. Editor API — про создание и редактирование «своего». Admin API — про принятие решений и управление статусами. Даже если URL-ы похожи, смыслы разные: разные DTO, разные статусы ошибок, разные ожидания клиента. Поэтому тесты лучше организовывать не «по сущности Article», а по реальным web-поверхностям.

Правило «один контроллер — один test class» идеально ложится на эту модель. У вас появляются три ключевых тестовых файла, и каждый из них становится маленькой документацией своей поверхности API. Вы смотрите на название — и уже понимаете, куда вы попали и зачем. Это очень похоже на хорошую структуру папок в компьютере: когда есть Фото/2025/Отпуск, вы не храните туда же «Налоги» и «Рецепт борща». (Хотя борщ — важная часть выживания, спорить не буду.)

Практически это выглядит как отдельные тестовые классы:

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

// Public: только публичные endpoints
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
}
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

// Editor: только editor endpoints
@WebMvcTest(EditorArticleController.class)
class EditorArticleControllerWebMvcTest {
}
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

// Admin: только admin endpoints
@WebMvcTest(AdminArticleController.class)
class AdminArticleControllerWebMvcTest {
}

Здесь важная тонкость: мы сейчас говорим именно про структуру suite, а не про механику запросов. Поэтому в этой лекции мы не пытаемся «в одном тесте доказать всё». Мы строим каркас, в котором каждому контракту есть своё место, и это место не конфликтует с соседями.

Если хочется чуть более наглядной картинки, то тестовый набор по контроллерам можно представить так:

flowchart LR
  P["PublicArticleControllerWebMvcTest"] --> PC["PublicArticleController"]
  E["EditorArticleControllerWebMvcTest"] --> EC["EditorArticleController"]
  A["AdminArticleControllerWebMvcTest"] --> AC["AdminArticleController"]

Эта схема кажется очевидной… пока вы не увидите реальный проект, где есть ArticleApiTest на 1500 строк, который «вроде проверяет всё», но при падении вы полчаса выясняете, какой endpoint вообще упал и почему это связано с тестом, который вы даже не трогали.

4. Имена и пакеты WebMvcTest

Когда тестов становится больше десятка, “где они лежат” начинает влиять на скорость разработки не меньше, чем выбор библиотек. Причём это не шутка: если тестовая структура неудобная, вы просто реже запускаете тесты и чаще “проверяете глазами”. А глаза — инструмент хороший, но к вечеру они любят галлюцинировать. Поэтому в web-slice тестах мы так же дисциплинированно относимся к имени и месту файла, как в production-коде.

В рамках ContentHub мы держим простую и предсказуемую модель: тестовый пакет зеркалит production-пакет, а имя теста чётко показывает что тестируем и каким типом теста. Это сильно помогает не спутать @WebMvcTest с будущими более широкими тестами, и одновременно облегчает поиск. Вы не должны играть в квест «найди тест по названию», когда у вас горит срок задачи.

Пример структуры директорий (без фанатизма, просто “чтобы находилось”):

src
├─ main/java/com/example/contenthub/api/controller
│  ├─ PublicArticleController.java
│  ├─ EditorArticleController.java
│  └─ AdminArticleController.java
└─ test/java/com/example/contenthub/api/controller
   ├─ PublicArticleControllerWebMvcTest.java
   ├─ EditorArticleControllerWebMvcTest.java
   └─ AdminArticleControllerWebMvcTest.java

Название PublicArticleControllerWebMvcTest здесь делает две вещи. Оно говорит «я тестирую PublicArticleController», и сразу же уточняет «я делаю это как WebMvc slice». Это экономит удивительно много времени на чтении. Особенно когда вы возвращаетесь к проекту через месяц и пытаетесь понять, почему тест падает: вы не гадаете, какой контекст поднимается и какого масштаба этот тест.

Чтобы закрепить идею, сравните два названия:

PublicArticleControllerWebMvcTest   // сразу ясно: controller + slice
ArticleControllerTest               // не ясно: какой controller? какой уровень?

Если в проекте появятся более дорогие тесты (полный контекст, live server и т.д.), то “размытые” имена начинают реально мешать. Но даже без будущих уровней, уже сейчас важно одно: тесты должны читаться как документация, а документации очень вредно быть загадочной.

Кстати, это отличное место применить то, что вы уже знаете из JUnit: @Nested и @DisplayName. Внутри одного controller-test класса вы можете группировать тесты по endpoint-ам. Важно лишь, чтобы это не превращалось в “второй контроллер внутри того же класса”. Группировка по endpoint-ам одного контроллера — отлично. Группировка “и public, и editor, и admin” — уже тревожный звоночек.

5. Общий код без каши

Как только вы разделили тесты по контроллерам, немедленно возникает следующий соблазн: «А давайте всё общее вынесем в базовый класс, чтобы не повторять @Autowired MockMvc, ObjectMapper, константы URL и т.д.» Этот соблазн особенно силён у начинающих, потому что повторение кажется “плохим”, а абстракция кажется “красивой”. В тестах всё чуть хитрее: повторение иногда действительно плохо, но преждевременная абстракция почти всегда хуже. Потому что она прячет смысл сценария и размывает границы.

Практическое правило здесь такое: общий код допустим, если он не меняет смысл теста и не заставляет вас думать о “магии”. Например, общая константа базового URL или маленький метод, который превращает объект DTO в JSON-строку, обычно нормальны. А вот общий base class, который в @BeforeEach делает кучу stubbing для трёх разных контроллеров, почти гарантированно превращается в ловушку: тесты начинают зависеть от неочевидного “наследуемого” поведения, и вы ловите поломки там, где их не ожидали.

В @WebMvcTest особенно важно, чтобы каждый тестовый класс чётко показывал свою границу зависимостей. Поэтому полезно, чтобы моки объявлялись локально — рядом с тестами, которые их используют. Это не про «любовь к копипасте», это про честность контекста. Когда вы открываете PublicArticleControllerWebMvcTest, вы хотите увидеть, какой сервис подменяется и почему. Если это спрятано в AbstractWebMvcTestBase, вы превращаете тестовый код в детектив.

Хороший компромисс часто выглядит так: маленький helper без Spring-аннотаций, который живёт в src/test/java/.../testsupport и содержит 1–2 простых утилиты. Например, генерация тестового DTO или минимальный JSON-loader (если вы используете fixtures). Но даже здесь полезно держать дисциплину: helper должен помогать читать тест, а не уводить вас в “мини-фреймворк”.

Покажу пример допустимого мини-helper’а, который не ломает границу и не тащит контекст:

import com.fasterxml.jackson.databind.ObjectMapper;

final class JsonTestHelper {

    private JsonTestHelper() {
        // Утилитный класс: экземпляры создавать не нужно
    }

    static String toJson(ObjectMapper mapper, Object value) throws Exception {
        // Важно: сериализация должна быть такой же, как в приложении (тот же ObjectMapper из контекста)
        return mapper.writeValueAsString(value);
    }
}

И пример того, что обычно начинает вредить (не потому что «так нельзя», а потому что быстро становится неуправляемо):

abstract class AbstractArticleApiWebMvcTestBase {
    // Опасно: в таком базовом классе быстро появятся моки для public/editor/admin,
    // общий setup, и через неделю никто не вспомнит, что и зачем.
}

Ещё одна тонкость: иногда у контроллеров повторяются URL-части. Например, у public endpoints есть /api/public/articles, у editor /api/editor/articles. Да, можно вынести строки в константы. Но если от этого тест начинает выглядеть как математическая формула, которую надо вычислять, чтобы понять URL, — лучше оставить строку прямо в тесте. Controller-тест — это тест контракта. Контракт полезно видеть глазами, а не “собирать из кусочков”.

6. Кейс: распиливаем «толстый» WebMvcTest

Лучше всего правило «один контроллер — один test class» видно на контрасте: когда вы берёте один “толстый” тест и превращаете его в три тонких. Представим, что мы начали с идеи «проверю все статьи одним тестом». Получился класс, в который попали и public list, и editor create, и admin approve. И в какой-то момент он начал требовать три разных сервиса и три разных набора stubbing — просто чтобы контекст поднялся.

Вот как может выглядеть типичный «толстый» скелет (даже без тестовых методов он уже пахнет перегревом):

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

// Намеренно «толстый» slice: три контроллера в одном контексте
@WebMvcTest({
    PublicArticleController.class,
    EditorArticleController.class,
    AdminArticleController.class
})
class AllArticleControllersWebMvcTest {
    // Чем больше контроллеров здесь, тем больше моков потребуется ниже
}

Чтобы он стартовал, вам придётся подменять зависимости всех трёх контроллеров. Упрощённо это быстро превращается в такое:

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

// Здесь в контекст попали минимум public + editor, и их зависимости нужно замокать
@WebMvcTest({PublicArticleController.class, EditorArticleController.class})
class AllArticleControllersWebMvcTest {

    @MockitoBean
    PublicArticleService publicArticleService; // Мок для зависимостей public-контроллера

    @MockitoBean
    EditorArticleService editorArticleService; // Мок для зависимостей editor-контроллера
}

И дальше “логика роста” почти неизбежна: добавили admin — добавили AdminArticleService. Добавили новую зависимость в editor controller — добавили ещё мок. Добавили ещё один контроллер — ещё мок. И так далее.

Теперь тот же смысл, но в трёх отдельных классах. Public:

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

// Узкий контекст: только public слой
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
}

Editor:

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

// Узкий контекст: только editor слой
@WebMvcTest(EditorArticleController.class)
class EditorArticleControllerWebMvcTest {
}

Admin:

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;

// Узкий контекст: только admin слой
@WebMvcTest(AdminArticleController.class)
class AdminArticleControllerWebMvcTest {
}

В результате каждый тестовый класс поднимает только то, что ему нужно, и требует только те моки, которые действительно стоят на границе этого контроллера.

Чтобы почувствовать, насколько это облегчает жизнь, представьте обычную рабочую ситуацию. Вы меняете editor endpoint (например, меняется request DTO). Если у вас один “толстый” тест, у вас есть риск, что он упадёт ещё до запуска тестов public, потому что контекст не соберётся. Если же editor тесты отдельно, то public тесты продолжат нормально работать и покажут: публичный контракт не пострадал. Это именно та локальность, ради которой мы вообще выбираем slice-тесты.

Наконец, маленькая «инженерная честность» про скорость. Да, три разных @WebMvcTest класса создадут три разных контекста. Это может быть чуть дороже, чем один “универсальный” контекст. Но обычно выигрывает другое: контексты меньше, тесты проще, диагностика быстрее, и вы меньше времени тратите на борьбу с “лишними” зависимостями. А если у вас когда-нибудь появится желание оптимизировать скорость — это стоит делать измерениями, а не угадыванием. Самый дорогой тест — это не тот, который выполняется 700 мс вместо 400 мс, а тот, который заставляет разработчика два часа искать проблему в неправильном месте.

Для удобства можно держать в голове небольшую табличку «симптомов расползания». Не как догму, а как сигнал «стоит остановиться и пересмотреть границу»:

Симптом в controller-тесте Что часто означает Как обычно лечится
В тестовом классе 4–6 @MockitoBean В контекст попали лишние контроллеры или контроллер делает слишком много Сузить @WebMvcTest до одного контроллера, проверить архитектуру контроллера
@WebMvcTest без параметров Включились все контроллеры проекта Указать конкретный контроллер в аннотации
Тесты падают из-за зависимостей endpoint-а, который вы не тестируете Тестовый контекст собран “на все случаи жизни” Разделить тесты по контроллерам
В проекте появился “общий базовый тестовый класс” с магией Вы начинаете строить свой тестовый фреймворк Упростить, вернуть моки и setup ближе к тестам

7. Типичные ошибки при @WebMvcTest

Ошибка №1: использовать @WebMvcTest без указания контроллера.
Технически это выглядит красиво: одна аннотация, минимум параметров. Практически — это включение всех контроллеров в slice. Дальше вы не тестируете “один контракт”, вы собираете “мини-картину всего API”, и любое изменение в любом контроллере может ломать ваш тестовый класс, даже если тестов под этот endpoint ещё нет.

Ошибка №2: пытаться сделать “один тест на все статьи”, потому что «так меньше файлов».
В web-layer тестах размер файла почти никогда не является главным параметром качества. Главный параметр — локальность ответственности. Когда вы смешиваете public/editor/admin в одном классе, вы платите сложностью: больше моков, больше случайных зависимостей, больше причин падения. В итоге файлов меньше, а боли больше.

Ошибка №3: делать огромный общий setup и прятать его в наследовании.
Наследование в тестах кажется удобным, пока вы не пытаетесь понять, почему в данном тесте сервис возвращает какие-то “дефолтные” данные. Когда stubbing и подготовка прячутся в базовом классе, тест перестаёт быть документом сценария и превращается в историю с пропущенными страницами. Лучше пусть в тесте будет на 3 строки больше, но будет видно, что происходит.

Ошибка №4: выносить URL и ожидания в слишком умные константы и «мини-DSL».
Controller-тест — это про HTTP-контракт. Контракт полезно видеть глазами: /api/public/articles/{slug} — это не “плохая строка”, это важная часть интерфейса. Если вы превращаете тест в набор вызовов publicApi().articles().details(slug) без необходимости, вы теряете ощущение реального HTTP. А потом удивляетесь, почему баг был “в URL”, а тест этого не показал.

Ошибка №5: не замечать архитектурный запах “God-controller”.
Иногда тестов много и всё равно хочется “объединить” их в один класс — потому что контроллер огромный и там 15 endpoint-ов. Это часто не проблема тестов, а сигнал в production-коде: контроллер взял на себя слишком много обязанностей. В рамках курса мы не устраиваем переписывание архитектуры, но как минимум стоит распознавать сигнал: если один контроллер трудно тестировать узко, возможно, он и по смыслу слишком широкий.

1
Задача
Spring Test, 9 уровень, 1 лекция
Недоступна
Два контроллера — два независимых WebMvcTest-класса
Два контроллера — два независимых WebMvcTest-класса
1
Задача
Spring Test, 9 уровень, 1 лекция
Недоступна
Один контроллер, один test class и группировка через `@Nested`
Один контроллер, один test class и группировка через `@Nested`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ