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 не нужно «выкидывать». Он остаётся сильным тогда, когда вам нужно:
- выразить проверку через очень конкретный matcher (например, вы уже привыкли к header().string(...) и он вам сейчас яснее, чем «пытаться сделать красиво»);
- сделать проверку настолько низкоуровневой, что 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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ