1. Конфликт: @WebMvcTest просит бины
С сервисными зависимостями контроллера у нас уже есть рабочий дефолт: их мы закрываем через @MockitoBean. Но после этого всплывает другой класс missing beans — не внешние сервисы, а соседи самой web-границы: mapper, formatter, @ConfigurationProperties, @ControllerAdvice. Их не хочется тащить из полного контекста, но и мокать всё подряд тоже плохая идея. Здесь и начинается дозированное расширение slice.
Один из самых частых сценариев — рядом с сервисом у контроллера оказывается ещё и “мелкая инфраструктура” вроде mapper’а. Mapper — это не бизнес-логика, а механика преобразования доменных/внутренних моделей в DTO. В проде он часто помечен @Component, и в обычном runtime всё ок. Но в @WebMvcTest обычные @Component не обязаны попасть в контекст, потому что slice специально ограничен.
Посмотрим на минимальный пример (укороченный, чтобы сфокусироваться на проблеме):
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/public/articles")
class PublicArticleController {
private final PublicArticleService service;
private final PublicArticleMapper mapper; // <-- часто @Component и не попадает в slice автоматически
PublicArticleController(PublicArticleService service, PublicArticleMapper mapper) {
// В slice-тесте проще всего мокать service, а mapper — импортировать или тоже мокать
this.service = service;
this.mapper = mapper;
}
}
Если в тесте мы замокаем только сервис, а mapper забыли, контекст может не стартовать и бросит что-то в духе: «не найден бин PublicArticleMapper». Это не “плохой Spring”, а вполне честный результат: slice говорит вам «я поднимаю только web-часть; остальное — либо мокай, либо подключай явно».
Иногда проблема похожая, но ещё веселее: контроллер или какой-то web-компонент (например, validator/formatter) зависит от @ConfigurationProperties. В обычном приложении эти properties регистрируются через @ConfigurationPropertiesScan или @EnableConfigurationProperties где-то в конфигурации. Но @WebMvcTest эту конфигурацию не поднимает — и бин properties просто отсутствует.
И третий частый случай: вы ожидаете, что ошибки будут возвращаться в вашем стабильном формате (ApiProblem), но в тесте вдруг прилетает дефолтный error от Spring (или вообще 500 без тела). Причина часто банальна: ваш @ControllerAdvice не попал в slice-контекст, а значит, «переводчик исключений в JSON» просто не участвует в обработке запроса.
2. Принцип дозированной сборки slice-контекста
Если воспринимать @WebMvcTest как «маленькое приложение», вы неизбежно начнёте его «достраивать» до «почти большого приложения». А это путь к дорогим, хрупким и странным тестам. Поэтому полезно держать простую картинку: web-slice — это как маленькая квартира-студия. В неё можно добавить стол и стул, но если вы попытаетесь занести туда ещё и рояль, ванну, серверную стойку и трёх репозиториев — квартира перестанет быть студией.
В @WebMvcTest у нас есть базовый набор: MVC-инфраструктура, Jackson, конвертеры и сам контроллер. А дальше мы добавляем вещи тремя разными инструментами, каждый из которых решает свой класс проблем: @MockitoBean подменяет зависимости контроллера mock-объектами; @Import добавляет конкретные классы/конфигурации как бины; @EnableConfigurationProperties делает видимыми properties-классы (и даёт им возможность получить значения).
Удобно представить это так:
flowchart TD
T[Test class] --> W["@WebMvcTest MVC infra + Controller"]
T --> M["@MockitoBean mocks as beans"]
T --> I["@Import точечные beans/config"]
T --> P["@EnableConfigurationProperties properties beans"]
W --> R[MockMvc]
M --> W
I --> W
P --> W
Главная дисциплина здесь в том, что мы добавляем в контекст только то, что непосредственно участвует в web-поведении. Сервисную бизнес-логику мы не «поднимаем» — мы её мокируем. Репозитории и базу данных мы тоже не «тащим» сюда, иначе тест перестаёт быть slice-тестом.
И ещё один важный нюанс: «дозированно» — это не значит «пара аннотаций наугад». Это значит, что мы сначала формулируем: «какого поведения мне не хватает в тесте», а потом решаем, чем его добавить. Иногда правильный ответ — @MockitoBean, иногда — @Import, иногда — @EnableConfigurationProperties, а иногда — «вообще не добавлять, потому что это уже не web-граница».
3. @Import в web-slice
@Import выглядит обманчиво просто: «импортируй класс — и будет бин». И это правда, но с важной оговоркой: @Import должен быть хирургическим инструментом, а не бензопилой. Его сила именно в том, что он позволяет точечно добавить то, чего не хватает, не включая случайно половину приложения.
Представим, что PublicArticleMapper — очень простой компонент. Его мокировать не хочется: мок mapper’а делает тест «шумнее», потому что нужно руками собирать DTO-ответы в каждом тесте. А mapper может быть вообще без зависимостей и с логикой «перенести 3 поля». Такой компонент обычно удобно подключить реальным.
Вот пример «учебно-реального» mapper’а (обрезанного до минимума):
import org.springframework.stereotype.Component;
@Component
class PublicArticleMapper {
ArticleSummaryResponse toSummary(ArticleSummary summary) {
// Маппинг прост: тесту проще использовать реальный код, чем мокать
// Важно: этот бин относится к web-границе (формирует DTO), а не к бизнес-логике
return new ArticleSummaryResponse(summary.slug(), summary.title());
}
}
Теперь тестовый класс может явно импортировать mapper, оставаясь при этом web-slice тестом:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
@WebMvcTest(PublicArticleController.class)
@Import(PublicArticleMapper.class) // Явно добавляем бин, который slice не обязан сканировать
class PublicArticleControllerWebMvcTest {
// здесь будет MockMvc и @MockitoBean сервисов
}
В этом месте важно держать в голове границу: @Import должен добавлять «web-соседей» контроллера, а не его бизнес-часть. Импортировать PublicArticleMapper — ок, он помогает сформировать response. Импортировать ArticleWorkflowService — уже сомнительно: это бизнес-оркестратор, и вы начинаете тестировать не контроллер, а кусок приложения.
Отдельная ловушка новичка — «импортировать главный конфиг приложения». Это выглядит так: «ну раз у меня чего-то не хватает, давайте импортируем ContentHubApplication или какой-нибудь AppConfig». Формально это может решить проблему отсутствующих бинов, но вы только что сделали из slice-теста почти-полный контекст (дорого, сложно, падает не локально, контекст начинает “случайно” зависеть от лишних вещей).
Я люблю объяснять это так: @Import — как соль. Щепотка делает блюдо вкуснее, пачка соли превращает ужин в квест «где ближайшая вода».
Если вам нужно импортировать несколько классов, делайте это явно, чтобы тест читался как декларация границы:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
@WebMvcTest(PublicArticleController.class)
@Import({ PublicArticleMapper.class, ApiExceptionHandler.class }) // Подключаем только то, что влияет на web-контракт
class PublicArticleControllerWebMvcTest {
}
Такой тестовый класс буквально говорит читателю: «я тестирую PublicArticleController, использую реальный mapper и реальный error handler, а всё остальное (сервисы) будет подменено моками».
4. @EnableConfigurationProperties в web-slice
Конфигурационные properties — штука, которая в обычном приложении «просто работает»: вы написали application.yml, сделали @ConfigurationProperties, и Spring всё аккуратно связал. В slice-тесте это внезапно может перестать происходить, потому что @WebMvcTest не обязан поднимать ваш production-конфиг, где включён @ConfigurationPropertiesScan. И это снова не баг, а особенность: slice экономит контекст, а значит, экономит и «автосканирование настроек».
Рассмотрим упрощённый пример: у нас есть настройки ограничений вложений. В проекте ContentHub это реальная часть домена (лимиты размера и т.п.), и где-то рядом с web-слоем эти настройки могут быть нужны. Например, контроллер/сервис может использовать их для валидации входа или формирования понятной ошибки.
Минимальный properties-класс:
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "contenthub.attachments")
public record AttachmentProperties(long maxSize) {
// maxSize — лимит, который влияет на web-поведение (валидация/ошибка/ответ)
}
Если в @WebMvcTest контексте Spring не знает, что этот класс надо зарегистрировать как bean, вы получите ошибку старта контекста. Решение — включить его явно:
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@WebMvcTest(controllers = EditorAttachmentController.class,
properties = "contenthub.attachments.max-size=1048576") // Локально задаём значение, чтобы тест был самодостаточным
@EnableConfigurationProperties(AttachmentProperties.class) // Явно регистрируем properties как бин
class EditorAttachmentControllerWebMvcTest {
}
Обратите внимание на две детали, которые в связке делают slice-тест очень «честным» и предсказуемым.
Во‑первых, @EnableConfigurationProperties(AttachmentProperties.class) говорит: «зарегистрируй этот тип как properties-bean». Без этого Spring может даже видеть значения properties, но не создать bean, который вы инжектите в контроллер/компонент.
Во‑вторых, мы задали значение через properties прямо в @WebMvcTest. Это удобно именно для slice: тест становится самодостаточным, и вам не нужно гадать, какое значение пришло из application-test.yml. Да, глобальный YAML хорош, но slice-тест часто выигрывает от локализации важных настроек рядом с тестом.
Типичный «новичковый» провал здесь звучит так: вы включили @EnableConfigurationProperties, но забыли задать значение, и binder берёт дефолт (или не может связать вовсе). Если properties валидируются, то контекст может не стартовать по причине невалидной конфигурации. Это неприятно, но честно: приложение в таком виде и в проде бы стартовало плохо.
И ещё нюанс: если вы понимаете, что properties нужны только потому, что ваш контроллер зависит от какого-то компонента, который зависит от properties, иногда проще замокать этот компонент на границе контроллера. Но если properties действительно участвуют в web-поведении (например, задают limit, который отражается в ошибке или в ответе), то включать их в slice — разумно.
5. @ControllerAdvice и обработка ошибок
Контроллер без обработки ошибок — это как касса без кассира: формально касса стоит, но если покупатель пришёл с проблемой, начинается импровизация. В REST API мы обычно хотим не импровизацию, а стабильный error contract. В ContentHub он описан как ProblemDetail-совместимая структура (ApiProblem) с errorCode, violations и т.п. И именно @ControllerAdvice чаще всего превращает исключения из сервиса в понятный JSON-ответ.
Минимальный пример ApiProblem (упрощённый ради фокуса на механике):
record ApiProblem(String errorCode, String detail) {
// errorCode — машинно-читаемый код ошибки для клиента
// detail — человеко-читаемое описание (обычно message исключения)
}
И минимальный обработчик ошибок:
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(ArticleNotFoundException.class)
ResponseEntity<ApiProblem> handleNotFound(ArticleNotFoundException ex) {
// Здесь мы фиксируем внешний контракт: 404 + тело с errorCode
var body = new ApiProblem("ARTICLE_NOT_FOUND", ex.getMessage());
return ResponseEntity.status(404).body(body);
}
}
Теперь главный вопрос: как сделать так, чтобы в @WebMvcTest этот advice реально участвовал? В некоторых проектах @ControllerAdvice может подхватиться автоматически, но в практике обучения и в реальных кодовых базах удобно контролировать это явно: если тест должен «видеть» ваш error contract, то advice должен быть частью slice.
Чтобы не гадать, держим простое правило: если для сценария важен ApiExceptionHandler и сам error contract, мы часто подключаем advice явно через @Import, чтобы состав slice читался глазами. Если конкретный setup уже auto-detects advice, второй раз его не импортируем. Нам здесь важна не магия автоподхвата, а управляемое поведение error-layer.
Самый прямой и читаемый путь — импортировать конкретный advice:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
@WebMvcTest(PublicArticleController.class)
@Import(ApiExceptionHandler.class) // Явно включаем advice, когда хотим сделать error contract частью slice
class PublicArticleControllerWebMvcTest {
}
После этого вы можете проверить не «всю философию ошибок», а очень маленький, но важный факт: исключение переводится в корректный HTTP status и ожидаемое поле errorCode.
Отдельно покажу, как выглядит минимальный тест «на присутствие advice» (обратите внимание: здесь логика сервиса не тестируется — она мокируется, а контроллер + advice показывают внешнее поведение):
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
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.*;
@WebMvcTest(PublicArticleController.class)
@Import(ApiExceptionHandler.class) // Подключаем advice, чтобы проверять формат ошибки
class PublicArticleControllerWebMvcTest {
@Autowired
MockMvc mockMvc; // Основной инструмент для проверки HTTP-слоя в slice-тесте
@MockitoBean
PublicArticleService service; // Бизнес-логика мокается: нам важно поведение web-границы
@Test
void returns404_andApiProblem_whenArticleMissing() throws Exception {
// Настраиваем мок: сервис кидает доменное исключение
given(service.getBySlug("missing"))
.willThrow(new ArticleNotFoundException("missing"));
// Проверяем, что web-слой + advice превратили исключение в стабильный контракт
mockMvc.perform(get("/api/public/articles/missing"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.errorCode").value("ARTICLE_NOT_FOUND"));
}
}
Да, это уже похоже на «тест ошибок», и мы ещё будем делать такие тесты системно позже. Но в рамках текущей лекции нам важна мысль: @ControllerAdvice — часть web-границы, и если вы хотите, чтобы slice-тест отражал поведение API, иногда вам нужно включить advice явно.
И маленькое предостережение: если advice уже автоматически попадает в slice, а вы его ещё и импортируете, можно получить конфликт определений бинов. Поэтому держите подход единым: либо вы полагаетесь на автоподхват, либо делаете advice частью явной границы теста через @Import (а автоподхват стараетесь контролировать).
6. Шаблон расширенного web-slice
Когда вы впервые «достраиваете» slice, легко уйти в две крайности. Первая — «мокаем всё, включая mapper и собственный мозг». Вторая — «тащим в контекст всё, включая репозиторий и PostgreSQL, потому что так проще». Нам нужен спокойный, повторяемый шаблон: как собрать slice, который достаточно похож на реальность на уровне web-границы, но остаётся узким и быстрым.
Рабочее правило звучит так: мокируем бизнес-зависимости (service layer), а реальными оставляем web-инфраструктурные штуки, если они простые и напрямую влияют на внешний контракт. Mapper — часто да. @ControllerAdvice — часто да. @ConfigurationProperties — только если они реально участвуют в поведении контроллера/адвайса или в форматировании ответа.
Ниже маленькая таблица-подсказка, которая помогает не спорить с самим собой по полчаса:
| Симптом в @WebMvcTest | Что это обычно значит | Что добавить минимально |
|---|---|---|
| NoSuchBeanDefinitionException на mapper/formatter | Slice не сканирует обычные @Component как в full app | @Import (YourMapper.class) или @MockitoBean |
| NoSuchBeanDefinitionException на *Properties | Properties не зарегистрированы как bean | @EnableConfigurationProperties (...) + задать properties=... |
| Ошибка/ответ не в формате ApiProblem | Advice не участвует в обработке | @Import (ApiExceptionHandler.class) (или убедиться, что advice включён) |
| Очень много @Import и зависимостей | Slice расползается | Вернуться к границе: мокнуть ближайшую зависимость контроллера и не тянуть глубже |
И вот пример «собранного» тестового класса, который показывает идею без перегруза:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
@WebMvcTest(PublicArticleController.class) // Что именно тестируем в web-слое
@Import({ PublicArticleMapper.class, ApiExceptionHandler.class }) // Какие реальные участники контракта добавляем
class PublicArticleControllerWebMvcTest {
// service будет @MockitoBean, остальное — web-инфраструктура
}
Обратите внимание, насколько это читабельно: одна строка @WebMvcTest задаёт целевой контроллер, @Import честно перечисляет «добавленные детали web-границы». Никаких «импортируем весь AppConfig», никаких репозиториев, никакой базы.
Когда контекст собран и стабилен, можно наконец-то писать полноценные web-slice тесты для публичных endpoint-ов — с MockMvc, статусами и несколькими ключевыми JSON-полями, не утонув в инфраструктурных ошибках старта контекста.
7. Типичные ошибки при расширении web-slice
Ошибка №1: импортировать «главную конфигурацию приложения» ради одного отсутствующего бина.
Это выглядит как быстрый фикс: тест стартует, зелёный, ура. Но цена — вы фактически ломаете идею slice. Контекст становится большим, медленным, начинает зависеть от лишних бинов, а падать может «где-то далеко» от тестируемого контроллера. В итоге вы получаете почти интеграционный тест, но без честного признания, что вы делаете интеграционный тест.
Ошибка №2: лечить любой NoSuchBeanDefinitionException добавлением новых @Import, пока тест не заведётся.
Так рождается «контекст Франкенштейна»: куча импортов, половина из которых добавлена «потому что было надо в момент отчаяния». Правильнее остановиться, сформулировать границу и задать себе вопрос: это компонент web-слоя (тогда импортируем), или это бизнес-зависимость контроллера (тогда мокируем ближайший сервис, а не тянем глубже).
Ошибка №3: включить @EnableConfigurationProperties, но забыть задать значения свойств, от которых зависит запуск.
Часто это проявляется как «Failed to bind properties…» или как провал валидации properties. В slice-тесте лучше держать важные значения рядом с тестом через properties = ..., чтобы было ясно, почему контекст стартует именно так. И да, если значения не заданы — это не «тест мешает», это сигнал, что конфигурация для этого поведения не определена.
Ошибка №4: дублировать @ControllerAdvice — и получить конфликт бинов.
Если advice уже автоматически попал в контекст (через механизмы slice), а вы ещё и импортируете его вручную, можно словить проблему с повторной регистрацией. Лучше выбрать один стиль. В учебном проекте обычно полезнее явность, но тогда стоит следить, чтобы advice не подхватывался «параллельно» другим способом.
Ошибка №5: мокировать то, что тесту выгоднее оставить реальным (и наоборот).
Когда вы мокируете простой mapper, тест превращается в «ручную сборку JSON-ответа» и теряет смысл как проверка web-границы. Но когда вы оставляете реальным тяжёлый сервис с кучей зависимостей, slice расползается и перестаёт быть slice. Здесь помогает простое правило: мок — для бизнес-оркестрации, реальный бин — для лёгкой и важной web-инфраструктуры, которая влияет на контракт.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ