JavaRush /Курсы /Spring Test /Единый стиль MVC-suite

Единый стиль MVC-suite

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

1. Введение

Когда один и тот же HTTP-сценарий уже успел пройти через миграцию, инфраструктурные эффекты и binding, остаётся последний очень земной вопрос: что считать нормой в MVC-suite. Единый стиль нужен не ради эстетики, а чтобы тесты читались как документация и чтобы при падении вы тратили время на причину бага, а не на переключение между тремя диалектами assertions.

Если соседние классы описывают один и тот же HTTP-мир на разных языках, мозг постоянно переключает передачи. Это не трагедия, но это накопленная стоимость поддержки. Поэтому здесь нужно не ещё раз сравнивать API, а зафиксировать простое правило: какой стиль считается default, где допустимы исключения и какие helper-ы не превращают suite в магический лес.

raw MockMvc и MockMvcTester

Здесь достаточно держать в голове два факта. Во-первых, raw MockMvc и MockMvcTester работают поверх одного и того же MVC slice. Во-вторых, выбор между ними — это выбор синтаксиса и читаемости, а не “силы” теста.

Этого уже хватает, чтобы перейти к рабочему правилу. Подробно сравнивать механику здесь больше не нужно: дальше важнее само правило выбора.

2. Правило выбора: default и исключения

Когда в проекте есть два удобных инструмента, мозг естественно хочет использовать оба. Это нормально. Ненормально — использовать оба так, что тесты выглядят как случайная смесь. Поэтому правило, которое хорошо работает для MVC-suite в ContentHub, звучит просто: выбираем один стиль «по умолчанию» внутри пакета/класса, а второй оставляем как осознанный запасной инструмент.

Для такого MVC-suite логичный default — MockMvcTester. Он хорошо ложится на уже выбранный нами базовый стиль assertions (AssertJ) и обычно делает happy path и типовые checks короче. Но raw MockMvc не нужно «выкидывать». Он остаётся сильным тогда, когда вам нужно:

  1. выразить проверку через очень конкретный matcher (например, вы уже привыкли к header().string(...) и он вам сейчас яснее, чем «пытаться сделать красиво»);
  2. сделать проверку настолько низкоуровневой, что fluent-стиль начинает мешать (иногда тест проще читать как «и ожидать вот это, и ожидать вот это», чем как длинную цепочку).

Чтобы не превращать это в список «из 17 пунктов», полезнее держать в голове простую матрицу выбора:

Ситуация в тесте Предпочтительно Почему
Обычный HTTP-сценарий «запрос → статус → пару базовых утверждений» MockMvcTester Быстрее читается как сценарий, меньше boilerplate
Нужен очень специфичный matcher из привычного набора MockMvc raw MockMvc Яснее намерение, меньше «обёрток ради обёрток»
В одном тесте хочется проверить много разных аспектов ответа MockMvcTester Удобно писать chained assertions, но не увлекаться

Ключевой момент: мы выбираем инструмент не по принципу “красивее”, а по принципу “понятнее следующему читателю”. Следующий читатель — это либо ваш тиммейт, либо вы же через пару недель. И, к сожалению, «вы через пару недель» обычно не помнит, что вы имели в виду вчера в 23:47.

4. Смешивание MockMvcTester и raw MockMvc

Смешивание двух инструментов в проекте допустимо, но его нужно дисциплинировать, иначе получится классическая ситуация: «в одном тесте MockMvcTester, в следующем raw, потом опять tester, потом кто-то добавил helper-DSL, и теперь никто не знает, что считается нормой». Дисциплина здесь не про бюрократию, а про то, чтобы тесты были предсказуемыми.

Самая понятная граница — один стиль на один тестовый класс. То есть если PublicArticleControllerWebMvcTest написан через MockMvcTester, то «по умолчанию» все тесты в этом классе используют mvc (tester). Если вдруг нужен raw для одной супер-точной проверки, его можно применить точечно, но лучше сделать это явно и объяснимо (и не превращать «точечно» в «половина тестов класса»).

Технически вы можете заинжектить оба инструмента, это нормально. Главное — не превращать это в «двойное управление» без правила.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

class PublicArticleControllerWebMvcTest {

    @Autowired
    MockMvcTester mvc; // Основной инструмент по умолчанию: fluent/assertj-стиль поверх MockMvc

    @Autowired
    MockMvc rawMvc; // Запасной инструмент: точечные проверки через perform + andExpect
}

Здесь мы заранее честно говорим: у нас есть default (mvc) и запасной ключ от «старого замка» (rawMvc). И дальше держим дисциплину: внутри одного тестового метода не смешиваем два API. Это похоже на ситуацию с двумя языками программирования в одном микросервисе: можно, но если каждую функцию писать на своём языке, сопровождаемость закончится быстро.

Ещё один важный принцип — не пытаться спрятать HTTP-детали при смешивании. Иногда люди начинают делать так: «Раз MockMvcTester красивый, давайте завернём URI, headers, и ещё половину в helpers». Итог: тест становится коротким, но абсолютно нечитаемым, потому что по нему непонятно, какой endpoint вообще проверяем. Красота победила смысл, а смысл в тестах — это, вообще-то, главное.

5. Helper-методы без магии

Хелперы в MVC-тестах — вещь коварная. Они могут реально помочь, а могут незаметно превратить тесты в «мини-фреймворк поверх тестов», где чтобы понять один кейс, нужно открыть ещё пять файлов. В ContentHub мы хотим ровно противоположного: чтобы тест читался как сценарий HTTP и не прятал ключевые детали.

Хороший helper в controller-тестах обычно делает одну из двух вещей: либо помогает собирать повторяющиеся кусочки URI, либо помогает держать одинаковые технические настройки (например, JSON Accept) без копипасты — но так, чтобы это не скрывало смысл.

Пример «безопасного» helper’а — собрать URL, не пряча endpoint:

private String publicArticlesUri(int page, int size) {
    // Хелпер не скрывает endpoint: в строке явно виден путь и параметры запроса
    return "/api/public/articles?page=" + page + "&size=" + size;
}

Тест с таким helper’ом всё ещё читается нормально: вы видите, что это /api/public/articles, и видите, какие параметры туда попали.

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

@Test
void shouldUseTesterAsDefault() {
    // Запрос остаётся «HTTP-явным»: видно и URI, и Accept
    assertThat(mvc.get()
            .uri(publicArticlesUri(0, 10))
            .accept(MediaType.APPLICATION_JSON))
            .hasStatusOk(); // Проверяем самое важное: корректный HTTP-статус
}

А вот пример helper’а, который выглядит «круто», но обычно вреден. Он скрывает слишком много и превращает тест в угадайку:

private Object getPublicArticlesResult(int page, int size) {
    // Плохой признак: хелпер возвращает «что-то» и прячет детали запроса внутри себя
    return mvc.get()
            .uri(publicArticlesUri(page, size))
            .accept(MediaType.APPLICATION_JSON);
}

И потом тест становится таким:

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;

@Test
void shouldReturnOk_butWhatEndpointIsIt() {
    // По тесту не видно ни endpoint, ни метод, ни важные заголовки — приходится идти читать хелпер
    assertThat(getPublicArticlesResult(0, 10))
            .hasStatusOk();
}

Формально всё работает. Но смысл исчез. Через месяц вы будете смотреть на это и думать: «Ок, а это точно public? а не editor? а параметры точно те?». В итоге вы всё равно откроете helper, а значит, вы потеряли основную ценность теста — быть читаемым сразу.

Маленький практический ориентир: если helper скрывает HTTP method, endpoint, или делает тест «слишком универсальным», он почти наверняка ухудшает ситуацию. В идеале, даже с helper’ами в коде теста должны оставаться: URI (или его явная часть), метод запроса, и ключевые headers. Всё остальное — вторично.

6. Шаблон тест-класса для ContentHub

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

Покажем это на публичном контроллере, где мы читаем статью по slug. Мы не будем углубляться в тело ответа — нам сейчас важнее структура теста и выбор инструмента.

Минимальный каркас MVC slice-теста с default на MockMvcTester:

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

@WebMvcTest(PublicArticleController.class) // Поднимаем только MVC-слой вокруг указанного контроллера
class PublicArticleControllerWebMvcTest {

    @Autowired
    MockMvcTester mvc; // Дефолтный инструмент для читаемых HTTP-assertions
}

Если у контроллера есть зависимость на сервис, мы мокируем её как обычно. И здесь приятно, что миграция на MockMvcTester не меняет Mockito-часть вообще: stubbing остаётся stubbing’ом.

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

@WebMvcTest(PublicArticleController.class) // Всё ещё slice-тест: реальный MVC, но зависимости контроллера — моки
class PublicArticleControllerWebMvcTest {

    @Autowired
    MockMvcTester mvc;

    @Autowired
    MockMvc rawMvc; // Точечный запасной инструмент под редкие low-level проверки

    @MockitoBean
    ArticleQueryService articleQueryService; // Мокаем сервис, чтобы контролировать сценарии и ответы
}

Теперь тест, где мы сохраняем HTTP-детали явными: URI и Accept.

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

@Test
void shouldReturnPublishedArticle() {
    // Готовим стаб: контролируем, что вернёт сервис для конкретного slug
    given(articleQueryService.findBySlug("spring-basics"))
            .willReturn(articleResponse); // заранее подготовленный DTO

    // Явно показываем HTTP-контракт: endpoint и заголовок Accept
    assertThat(mvc.get()
            .uri("/api/public/articles/spring-basics")
            .accept(MediaType.APPLICATION_JSON))
            .hasStatusOk(); // Минимальная базовая проверка — HTTP 200
}

А теперь — пример «осознанного исключения». Допустим, у нас есть фильтр/инфраструктурный компонент, который добавляет заголовок зоны доступа (например, X-ContentHub-Zone: public). Raw MockMvc иногда в таких точечных header checks читается просто и привычно. Это не повод переключать весь класс обратно на raw; просто у этого одного low-level check синтаксис получается честнее.

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.beans.factory.annotation.Autowired;

@Test
void shouldExposePublicZoneHeader() throws Exception {
    // Здесь намеренно используем raw MockMvc: matchers для заголовков читаются очень прямо
    rawMvc.perform(get("/api/public/articles"))
            .andExpect(status().isOk()) // Проверяем статус
            .andExpect(header().string("X-ContentHub-Zone", "public")); // Проверяем инфраструктурный заголовок
}

Здесь важно не то, что "raw лучше". Важно, что у нас есть правило: tester — default, raw — точечный инструмент под конкретную форму проверки. В итоге пакет тестов выглядит единообразно: большинство сценариев читаются как AssertJ-style, а редкие технические проверки не ломают общую картину.

7. Типичные ошибки при выборе стиля MVC-suite

Ошибка №1: «У нас нет дефолта, у нас свобода».
Свобода без дефолта обычно заканчивается тем, что каждый пишет как привык. В итоге suite похож на лоскутное одеяло: где-то andExpect, где-то assertThat, где-то «супер-хелперы», где-то вообще непонятно что. Исправлять это потом тяжело, потому что спор уже не про код, а про привычки.

Ошибка №2: смешивать raw MockMvc и MockMvcTester внутри одного тестового метода.
Иногда хочется начать через tester («красиво»), а потом добавить пару andExpect («ну тут же один matcher»). В итоге тест превращается в гибрид, который сложно читать и сложно поддерживать: у него нет одного языка. Лучше выбрать один API на тест и придерживаться его.

Ошибка №3: прятать HTTP-семантику в helper’ы ради сокращения строк.
Сделать тест в три строки приятно. Но если эти три строки не показывают, какой endpoint вызван, какие заголовки важны и какие параметры переданы, тест перестаёт быть документацией. Это особенно опасно в controller-тестах, потому что их ценность как раз в явности HTTP-контракта.

Ошибка №4: превращать MockMvcTester в «одну огромную цепочку на 15 проверок».
Fluent-стиль провоцирует писать длинные chained assertions. Это хорошо до тех пор, пока цепочка остаётся читаемой. Но если вы в одной цепочке проверяете статус, заголовки, десять полей JSON и ещё что-то, тест начинает выглядеть как романы Толстого: вроде произведение великое, но читать каждый день тяжело. Лучше оставить только ключевые проверки и дробить по смыслу.

Ошибка №5: выбирать стиль по принципу «как меньше строк», а не по принципу «как яснее намерение».
Иногда raw MockMvc в конкретной проверке выглядит яснее. Иногда MockMvcTester делает сценарий ближе к человеческому чтению. Это нормально. Проблема начинается, когда критерий — только количество строк. Тесты — не соревнование по минификации. Они должны объяснять поведение, а не демонстрировать акробатику API.

1
Задача
Spring Test, 11 уровень, 4 лекция
Недоступна
Один default-стиль для MVC-класса
Один default-стиль для MVC-класса
1
Задача
Spring Test, 11 уровень, 4 лекция
Недоступна
Default через MockMvcTester и одно осознанное исключение
Default через MockMvcTester и одно осознанное исключение
1
Опрос
MVC Тесты, 11 уровень, 4 лекция
Недоступен
MVC Тесты
Контроллеры, фильтры, биндинг
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ