JavaRush /Курсы /Spring Test /Проверка JSON: JsonPath

Проверка JSON: JsonPath и content () . json ( ... )

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

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, тест может падать на ровном месте, и вы будете искать проблему в контроллере, а она в ожидании.

1
Задача
Spring Test, 10 уровень, 3 лекция
Недоступна
`jsonPath` для page-ответа
`jsonPath` для page-ответа
1
Задача
Spring Test, 10 уровень, 3 лекция
Недоступна
Структурное сравнение JSON через `content().json(...)`
Структурное сравнение JSON через `content().json(...)`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ