JavaRush /Курсы /Spring Test /Дозированное расширение MVC slice

Дозированное расширение MVC slice

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

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-инфраструктуры, которая влияет на контракт.

1
Задача
Spring Test, 9 уровень, 3 лекция
Недоступна
Подключение `@ControllerAdvice` через `@Import`
Подключение `@ControllerAdvice` через `@Import`
1
Задача
Spring Test, 9 уровень, 3 лекция
Недоступна
Явное подключение `@ConfigurationProperties` в MVC slice
Явное подключение `@ConfigurationProperties` в MVC slice
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ