1. Body-assertions как часть HTTP-контракта
Если вы когда-нибудь радовались тесту, который проверяет только status().isOk(), то у меня есть две новости. Хорошая: вы уже на правильном пути. Плохая: такой тест часто доказывает примерно «контроллер не упал лицом в клавиатуру». Клиенту же — мобильному приложению, фронту, другому сервису — важно не только то, что запрос успешен, но и то, что тело ответа соответствует контракту: нужные поля присутствуют, называются правильно, имеют ожидаемые типы и несут ожидаемый смысл.
В проекте ContentHub это особенно заметно на публичных endpoint-ах. Например, GET /api/public/articles/{slug} может честно вернуть 200 OK, но при этом случайно переименовать поле publishedAt в publishDate, или начать отдавать status как число вместо строки, или «забыть» slug — а клиент как раз по slug строит ссылку. С точки зрения пользователя это будет выглядеть как баг «приложение сломалось», а с точки зрения backend-разработчика — «ну у нас же 200, чего вы».
В контексте MVC slice (@WebMvcTest) важно помнить границу ответственности. Мы сейчас не проверяем бизнес-логику сервиса, не проверяем базу данных и не проверяем реальные интеграции. Но мы абсолютно честно можем и должны проверять, что контроллер корректно формирует HTTP-ответ: правильный JSON, правильные поля, правильные форматы. Для этого и существуют body-assertions.
Небольшая схема, которая помогает держать порядок действий в голове и в тесте:
flowchart TD
A["mockMvc.perform(...)
собрали запрос"] --> B["andExpect(status / headers)
проверили семантику"]
B --> C["andExpect(jsonPath / content().json)
проверили JSON body"]
C --> D["Тест читается как контракт
и падает понятно"]
2. JsonPath в MockMvc: адреса в JSON
Когда вы проверяете JSON-ответ, у вас почти всегда есть два желания, которые конфликтуют. С одной стороны, хочется быть строгим и точным: «поле slug должно быть вот таким». С другой стороны, не хочется писать гигантское сравнение целого JSON на 200 строк, которое ломается от любой ерунды. JsonPath — это золотая середина: вы выбираете несколько опорных мест в JSON (load-bearing fields) и проверяете их точечно, без сравнения всего документа целиком.
Ментальная модель JsonPath очень простая и даже дружелюбная к начинающим: это «адрес» внутри JSON. Примерно как путь к файлу в папках, только вместо папок — объекты, вместо файлов — поля, а массивы выбираются по индексу. Корень документа обозначается символом $.
Чтобы не гадать, полезно сначала представить примерный ответ. Для ContentHub публичная карточка статьи может выглядеть примерно так:
{
"slug": "spring-mockmvc",
"title": "Spring MockMvc",
"summary": "Как тестировать MVC без поднятия сервера",
"status": "PUBLISHED",
"publishedAt": "2026-03-01T12:00:00Z",
"category": {
"code": "java",
"name": "Java"
}
}
Теперь становится очевидно, как «адресовать» нужные куски: $.slug, $.category.code, $.publishedAt и так далее.
Вот небольшая шпаргалка — не «вся мощь JsonPath», а именно то, что реально нужно в обычных MVC-тестах.
| JsonPath выражение | Что означает | Пример для ContentHub |
|---|---|---|
| $ | корень JSON | весь ответ целиком |
| $.slug | поле slug в корневом объекте | "spring-mockmvc" |
| $.category.code | вложенное поле | "java" |
| $.content[0].slug | поле у первого элемента массива content | slug первой статьи в списке |
| $.content | сам массив | список статей |
| $.publishedAt | поле даты/времени | строка ISO-8601 |
Мини-пример: проверяем важные поля карточки статьи
Ниже — типичный фрагмент @WebMvcTest, где мы мокируем сервис и проверяем JSON-ответ от контроллера. Обратите внимание: тест читается как «вызвали endpoint, получили JSON, в нём есть нужные поля и значения».
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.http.MediaType;
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)
class PublicArticleControllerWebMvcTest {
@Autowired
MockMvc mockMvc;
@MockitoBean
PublicArticleService publicArticleService;
@Test
void getBySlug_returnsArticleDetailsJson() throws Exception {
// Arrange: настраиваем мок сервиса, чтобы контроллер получил предсказуемые данные
given(publicArticleService.getBySlug("spring-mockmvc"))
.willReturn(new ArticleDetailsResponse(
"spring-mockmvc",
"Spring MockMvc",
"PUBLISHED"
));
// Act: выполняем HTTP-запрос через MockMvc (без поднятия реального сервера)
mockMvc.perform(get("/api/public/articles/{slug}", "spring-mockmvc")
.accept(MediaType.APPLICATION_JSON))
// Assert: проверяем код ответа (часть HTTP-контракта)
.andExpect(status().isOk())
// Assert: точечно проверяем ключевые поля JSON-контракта
.andExpect(jsonPath("$.slug").value("spring-mockmvc"))
.andExpect(jsonPath("$.title").value("Spring MockMvc"))
.andExpect(jsonPath("$.status").value("PUBLISHED"));
}
}
Да, здесь мы проверяем и status().isOk() — потому что договор «ответ был успешен» тоже часть контракта. Но фокус лекции — на том, что дальше мы добиваем контракт проверками по JSON.
Проверки наличия, типов и null
Иногда вам важно не конкретное значение, а факт, что поле присутствует и имеет нормальную форму. Это особенно полезно для дат и опциональных полей: вы не хотите в каждом тесте впечатывать конкретный timestamp, но хотите убедиться, что это строка и что она есть.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void getBySlug_hasPublishedAt_andNoRejectionReason() throws Exception {
mockMvc.perform(get("/api/public/articles/{slug}", "spring-mockmvc")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
// Поле должно присутствовать в ответе
.andExpect(jsonPath("$.publishedAt").exists())
// И иметь ожидаемый тип (например, ISO-строка)
.andExpect(jsonPath("$.publishedAt").isString())
// А этого поля по контракту быть не должно (важно: "нет поля" != "поле = null"
)
.andExpect(jsonPath("$.rejectionReason").doesNotExist());
}
Здесь есть важный нюанс, который часто ломает тесты новичкам: «поле отсутствует» и «поле есть, но равно null» — это разные контракты. Если у вас DTO настроен так, что null-поля не сериализуются (@JsonInclude(NON_NULL)), тогда doesNotExist() — честнее. Если же по контракту поле присутствует и может быть null, тогда проверка должна быть другой, например через value(Matchers.nullValue()). И именно в тестах вы это фиксируете.
3. Проверяем списки и page-ответы
Одиночный объект в JSON проверять приятно: $.slug, $.title — красота. Но как только появляется список, мозг начинает слегка дымиться: где там индекс, что лежит в content, что такое page, и почему я вообще стал взрослым человеком, который проверяет массивы в JSON. Это нормальная стадия. Списки — самое частое место, где тесты превращаются в кашу, если не договориться о минимально достаточных проверках.
В ContentHub публичный список статей — это не «сырой Page<T>», а наш собственный PageResponse, чтобы контракт был стабильным и не тащил наружу детали Spring Data. Обычно он выглядит примерно так:
{
"page": 0,
"size": 2,
"totalElements": 10,
"content": [
{ "slug": "spring-mockmvc", "title": "Spring MockMvc" },
{ "slug": "junit-6", "title": "JUnit 6" }
]
}
И вот здесь главная идея для MVC slice-теста: мы проверяем форму wrapper-а (page, size, content), и проверяем пару полей внутри content, но не превращаем тест в «сравнение всего мира». Потому что сервис замокан, и если вы проверяете, что второй элемент массива — junit-6, вы на самом деле проверяете… что вы сами в given(...).willReturn(...) написали junit-6. Это не баг, но и не супер-ценность. Ценность — в том, что JSON действительно так устроен и поля сериализованы правильно.
Мини-пример: page metadata + одна статья в списке
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void listPublicArticles_returnsPageResponse() throws Exception {
mockMvc.perform(get("/api/public/articles")
// Параметры пагинации — часть контракта запроса
.param("page", "0")
.param("size", "2")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
// Проверяем wrapper (метаданные страницы)
.andExpect(jsonPath("$.page").value(0))
.andExpect(jsonPath("$.size").value(2))
// Проверяем, что content — массив и он ожидаемого размера
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content", Matchers.hasSize(2)))
// И точечно проверяем один элемент, чтобы убедиться в форме элементов массива
.andExpect(jsonPath("$.content[0].slug").value("spring-mockmvc"));
}
Здесь мы используем один Hamcrest matcher (hasSize), потому что jsonPath(...).value(...) не всегда удобно выражает «в массиве ровно два элемента». И это нормально: в MVC-тестах Hamcrest исторически присутствует, и мы его используем точечно и по делу, без превращения теста в коллекцию матчеров всех видов.
Про индексы и читаемость
$.content[0] — это первый элемент массива. Дальше можно идти по полям: $.content[0].slug. Если у вас сложные элементы, например category как вложенный объект, путь будет $.content[0].category.code. И если это выглядит как много точек, то так и есть — зато тест прозрачен: видно, что вы проверяете.
Есть ещё более продвинутые выражения с * и фильтрами, но для junior-уровня в рамках курса лучше держаться простых адресов. Если вы в одном тесте уже используете три фильтра, две звёздочки и один .., то вы тестируете не API, а собственную способность писать заклинания.
4. content().json(...): сравнение JSON
Иногда jsonPath(...) получается слишком дребезжащим: много строк, много повторов, а мы хотели просто сказать «ответ содержит вот такой фрагмент JSON». В этот момент очень полезно вспомнить про content().json(...). Эта проверка не сравнивает строки посимвольно, то есть пробелы, переносы строк, порядок полей в объекте не должны вас мучить, а сравнивает JSON как структуру. По сути, это удобная обёртка вокруг JSONassert, которая уже живёт внутри spring-boot-starter-test.
Главный вопрос — какую строгость выбрать. В MockMvc есть перегрузка content().json(expected, strict), где strict = true — «строго», а strict = false — «мягче». На практике это означает, что в мягком режиме вы можете проверять только важный фрагмент, не требуя полного совпадения всего ответа.
Мини-пример: сравниваем фрагмент ответа (lenient)
Один из самых приятных приёмов в Java 25 — текстовые блоки. Это делает expected JSON читаемым, и вам не нужно экранировать кавычки как в плохом фильме про 2007 год.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void getBySlug_containsBasicFields() throws Exception {
// Ожидаем только важный фрагмент (остальные поля ответа нас сейчас не волнуют)
String expected = """
{
"slug": "spring-mockmvc",
"title": "Spring MockMvc"
}
""";
mockMvc.perform(get("/api/public/articles/{slug}", "spring-mockmvc")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
// strict=false: "lenient" режим — проверяем фрагмент, а не весь документ целиком
.andExpect(content().json(expected, false));
}
Здесь мы явно говорим: «Меня сейчас интересует, что в ответе есть slug и title именно с такими значениями». Даже если контроллер отдаёт ещё десяток полей (summary, publishedAt, category…), тест не станет хрупким.
Мини-пример: строго сравниваем маленький ответ
Строгий режим полезен, когда response действительно маленький, и вы хотите доказать, что нет лишних полей и что контракт ровно такой. Например, POST /api/editor/articles может возвращать короткий payload с id и slug — это зависит от дизайна, но для примера сгодится.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void createDraft_returnsIdAndSlug_only() throws Exception {
// JSON запроса обычно удобно писать текстовым блоком, чтобы не экранировать кавычки
String requestJson = """
{"title":"Intro to MockMvc","summary":"Short","body":"Text","category":"java"}
""";
// В строгом режиме важно, чтобы ответ совпал целиком и не содержал "лишних" полей
String expectedJson = """
{"id": 100, "slug": "intro-to-mockmvc"}
""";
mockMvc.perform(post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(requestJson))
.andExpect(status().isCreated())
// strict=true: контракт маленький — фиксируем его полностью
.andExpect(content().json(expectedJson, true));
}
Почему здесь строгий режим выглядит уместно? Потому что мы хотим, чтобы endpoint не начал внезапно возвращать полстатьи целиком или ещё какие-то поля, не договорившись об этом. На маленьких ответах строгие сравнения дают очень понятный сигнал: контракт изменился.
Выбор: jsonPath и content().json(...)
Самое приятное — вам не нужно выбирать «навсегда». В обычном тесте вы можете смешивать: сделать одну структурную проверку content().json(...) на базовый фрагмент и добавить пару точечных jsonPath(...) там, где важна форма или тип.
| Ситуация | Что удобнее | Почему |
|---|---|---|
| Проверить 2–3 поля | jsonPath(...) | быстро и прозрачно, видно адрес и значение |
| Проверить «ответ содержит фрагмент» | content().json(..., false) | меньше шума, не важно форматирование |
| Проверить маленький ответ «строго по контракту» | content().json(..., true) | сигналит о любых лишних или потерянных полях |
| Проверить массивы или страницы точечно | jsonPath + hasSize | структурное сравнение списков часто делает тест хрупким |
5. Достаточная строгость проверок
Самая частая болезнь MVC-тестов — не «не проверили JSON», а «проверили вообще всё, что можно, и теперь боимся лишний раз добавить поле в DTO». Тесты превращаются в охранников в ночном клубе, которые не пропускают даже здравый смысл: «у вас тут новая кнопка в интерфейсе, а в тесте она не описана — не пущу». Поэтому нам нужна дисциплина: проверять именно то, что несёт смысл и реально защищает контракт от регрессий.
Есть хороший практический ориентир: в @WebMvcTest с мокнутым сервисом мы в основном доказываем две вещи. Во-первых, что endpoint отвечает правильным HTTP-кодом и правильным media type. Во-вторых, что контроллер сериализует и отдаёт ожидаемый JSON-контракт: поля называются правильно, ключевые значения на месте, формат дат и enum-ов не сломался. Всё, что касается бизнес-логики и «правильного порядка статей», здесь не доказуемо честно — потому что источник данных замокан.
На практике это означает, что у каждого endpoint-а полезно выделить несколько опорных (load-bearing) проверок. Например, для GET /api/public/articles/{slug} это slug, title, status как строка enum, и, возможно, publishedAt как строка. Для GET /api/public/articles это wrapper-поля page и size, то, что content — массив, и что у одного элемента есть slug и title. Если вы начнёте проверять все поля каждого элемента массива, вы получите тест, который падает не на регрессию, а на любой нормальный рефакторинг DTO.
Хорошая прагматичная композиция выглядит так: один content().json(..., false) фиксирует базовый фрагмент, а 2–3 jsonPath добавляют точность там, где фрагмент не выражает важную мысль. Например, фрагмент проверяет slug/title, а jsonPath отдельно проверяет, что publishedAt вообще есть и это строка. Такой тест обычно и полезен, и не капризен.
Ещё один нюанс, который стоит проговорить честно: у нас в курсе уже есть отдельные JSON-контракт тесты через @JsonTest. Их смысл — защищать сериализацию DTO независимо от контроллера. Поэтому в controller-тестах мы не обязаны повторять те же проверки в полный рост. Мы просто убеждаемся, что через HTTP-границу дошёл ожидаемый контракт, а не проверяем детально каждый формат и каждый edge case сериализации. Иначе получится двойная бухгалтерия: тестов много, пользы примерно столько же.
6. Типичные ошибки при проверке JSON в MockMvc
Ошибки в body-assertions часто выглядят как «тест падает, и я не понимаю почему», хотя причина обычно довольно приземлённая. В этом месте полезно не ругать себя, а просто запомнить несколько типовых граблей — тогда в следующий раз вы наступите на что-то новое и интересное, а не на проверенную классику.
Ошибка №1: сравнивать JSON как обычную строку.
Иногда рука тянется сделать что-то вроде «получил response string и сравнил её через equals». Это почти гарантированно превратит тест в хрупкую конструкцию: пробелы, переносы строк, порядок полей, даже разные настройки pretty-print — и тест уже упал. Для JSON по смыслу почти всегда лучше jsonPath(...) или content().json(...).
Ошибка №2: проверять слишком много полей, которые не несут дополнительной ценности.
Если вы в @WebMvcTest мокируете сервис, то проверка каждого поля DTO часто доказывает только то, что вы правильно переписали значения из стаба в expected. Такой тест будет ломаться при любом нормальном изменении DTO, а ловить реальные регрессии будет редко. Гораздо лучше держать 2–5 опорных проверок на endpoint.
Ошибка №3: путать «поле отсутствует» и «поле равно null».
В JSON-контракте это разные ситуации. doesNotExist() падает, если поле всё-таки присутствует, а value(nullValue()) падает, если поля нет. Нужно заранее договориться и зафиксировать тестами, как проект себя ведёт. Для многих API удобнее не сериализовать null-поля, но тогда тест должен это отражать.
Ошибка №4: выбрать слишком строгий режим content().json(..., true) там, где контракт по смыслу допускает расширение.
Строгий режим хорош, когда response маленький и действительно должен совпасть пиксель в пиксель. Но если вы строгим режимом сравниваете большой ArticleDetailsResponse, то добавление нового поля, например readingTimeSeconds, будет ломать тесты, хотя контракт по сути стал богаче, а не хуже. В таких случаях чаще спасает strict = false или точечные jsonPath.
Ошибка №5: ожидать строку там, где в JSON число, и наоборот.
jsonPath("$.page").value("0") и jsonPath("$.page").value(0) — это не одно и то же. JSON числа — это числа, и лучше проверять их как числа. То же касается boolean. Если вы случайно проверяете "true" вместо true, тест может падать на ровном месте, и вы будете искать проблему в контроллере, а она в ожидании.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ