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

Проверка JSON: JSONassert и JsonPath

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

1. Ловушка посимвольного сравнения JSON

Если вы только начинаете, очень хочется сделать так: «у меня есть expectedJson и есть actualJson, значит, сравню их как строки». Логика выглядит железобетонной, но на практике быстро заканчивается нервным тиком, потому что JSON — это не текстовый роман, а структура. При строковом сравнении вас начнут «ломать» пробелы, переносы строк, порядок полей и дополнительные поля, которые вообще не меняют смысл ответа.

Давайте увидим проблему на простом примере из нашего домена ContentHub. Представим, что где-то (неважно где, сейчас мы не поднимаем Spring) мы получили JSON с краткой информацией о статье:

import org.junit.jupiter.api.Test; 

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

class JsonAsStringBadIdeaTest {

    @Test
    void comparing_json_as_string_is_brittle() {
        // expected: JSON в том виде, как мы записали его в тесте
        String expected = """
                {"slug":"java-basics","status":"DRAFT"}
                """;

        // actual: смысл тот же, но форматирование другое (пробелы + иной порядок полей)
        String actual = """
                { "status" : "DRAFT", "slug" : "java-basics" }
                """;

        // Падает не потому, что контракт сломан, а потому что строки разные посимвольно
        assertThat(actual).isEqualTo(expected);
    }
}

Смысл у JSON одинаковый, но тест упадёт. И это не «строгая проверка контракта», а «проверка, что строка отформатирована точно так же, как я себе придумал». Для API это почти никогда не то, что мы хотим.

Полезно держать в голове очень практическое правило: строковое сравнение JSON проверяет форматирование, а не контракт. Иногда форматирование действительно важно (например, в файлах конфигурации или в generated output), но для ответов бэкенда это редко является бизнес-риском.

Небольшая схема, чтобы зафиксировать это визуально:

flowchart TD
    A["actual JSON (String)"] --> B["строковое сравнение"]
    C["expected JSON (String)"] --> B
    B --> D["падает из‑за пробелов/порядка/лишних полей"]

    A2["actual JSON"] --> P["парсинг как JSON-структура"]
    C2["expected JSON"] --> P
    P --> E["структурная проверка"]
    E --> F["падает только если реально изменился смысл/контракт"]

Наша цель — жить во второй ветке: проверять структуру и значения, а не косметику.

2. JSONassert: сравнение JSON как структуры

JSONassert — это библиотека, которая сравнивает JSON по смыслу: как объект с полями и значениями, а не как набор символов. Её главный «кайф» (простите за технический термин) в том, что можно быть строгими там, где контракт действительно строгий, и мягче там, где контракт допускает вариативность. При этом вы всё равно проверяете структуру, а не просто «в строке есть подстрока».

Начнём с самого базового. У JSONassert есть класс JSONAssert, и чаще всего вы будете использовать assertEquals(expected, actual, mode). В режиме lenient тест допускает лишние поля и не зависит от форматирования, а в режиме strict — жёстче фиксирует форму ответа.

Первый пример: lenient-сравнение

Представим, что в ContentHub у нас есть ответ «детали статьи» (условно ArticleDetailsResponse), и мы хотим гарантировать, что контракт содержит хотя бы slug и status. При этом контроллер или сериализация могут добавить поля вроде authorUsername, и нас это не должно ломать в этом конкретном тесте.

import org.junit.jupiter.api.Test; 
import org.skyscreamer.jsonassert.JSONAssert;
import org.skyscreamer.jsonassert.JSONCompareMode;

class ArticleDetailsJsonAssertTest {

    @Test
    void compares_json_lenient_by_contract_part() throws Exception {
        // expected: минимальный контракт, который мы хотим зафиксировать в тесте
        String expected = """
                {"slug":"java-basics","status":"DRAFT"}
                """;

        // actual: реальный ответ может быть «шире» и содержать дополнительные поля
        String actual = """
                {
                  "slug":"java-basics",
                  "status":"DRAFT",
                  "authorUsername":"anna"
                }
                """;

        // LENIENT: допускаем лишние поля и не зависим от форматирования
        JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT);
    }
}

Здесь мы добились важного эффекта: тест доказывает, что важная часть контракта не исчезла и не изменилась. При этом он не превращается в «охранника пробелов и переносов строк».

Strict/lenient на практике

Когда люди впервые слышат про strict/lenient, они иногда выбирают режим по принципу «мне нравятся строгие тесты» или «я не люблю, когда тесты часто падают». Это плохая логика: режим выбирают не по настроению, а по тому, что является контрактом, а что — деталью реализации.

Чтобы это закрепить, удобно посмотреть на режимы сравнения JSONassert в таблице:

Режим JSONCompareMode Лишние поля в actual Порядок элементов в JSON-массивах Когда обычно уместен
STRICT запрещены важен когда вы фиксируете точную форму payload (как «протокол»)
LENIENT разрешены чаще всего неважен когда проверяете «важный смысловой фрагмент» и не хотите ломаться от расширения контракта
NON_EXTENSIBLE запрещены чаще всего неважен когда поля фиксированы, но массивы можно воспринимать как набор
STRICT_ORDER разрешены важен когда массив — это именно упорядоченный список, но JSON-объект может расширяться

Важно понимать: массивы в JSON — отдельная история. Порядок в массиве иногда является частью контракта (например, «первым показываем самые новые статьи»), а иногда нет (например, список violations в ошибке валидации может прийти в разном порядке). JSONassert как раз даёт вам способ это выразить, а не гадать.

3. JsonPath: точечные проверки в JSON

JSONassert хорош, когда вы хотите сравнить «кусок JSON как контракт». Но иногда вам не нужен целый контракт: нужно проверить ровно одну-две вещи. Например, что status равен PUBLISHED, что meta.totalElements равно 10, или что у первого элемента списка slug не пустой. И вот тут на сцену выходит JsonPath.

JsonPath — это по сути «адресация» внутри JSON, похожая на XPath для XML или на путь к файлу в папках. Вы пишете путь вроде $.items[0].slug, и библиотека достаёт значение. Затем проверяете его уже привычным AssertJ. Такой подход делает тест точечным и ясным: видно, какое правило вы проверяете и где именно в JSON оно находится.

Базовый пример: прочитать поле status из JSON

Возьмём тот же пример статьи и достанем статус:

import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test; 

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

class ArticleDetailsJsonPathTest {

    @Test
    void reads_single_field_by_json_path() {
        // Достаточно маленького JSON: мы тренируем точечную проверку
        String json = """
                {"slug":"java-basics","status":"DRAFT"}
                """;

        // Читаем конкретное поле по пути от корня ($)
        String status = JsonPath.read(json, "$.status");

        // Проверяем конкретное правило, а не «весь JSON целиком»
        assertThat(status).isEqualTo("DRAFT");
    }
}

Обратите внимание: здесь мы вообще не «сравниваем JSON». Мы задаём вопрос: «что лежит в $.status?» — и проверяем ответ.

Мини-шпаргалка по синтаксису JsonPath

JsonPath — штука очень практичная, но новичков пугает символами. Если читать путь слева направо, он становится понятнее: $ — корень документа, дальше идут поля и индексы.

Вот небольшая шпаргалка, которой обычно хватает на 80% задач:

Что хотим достать Пример пути Комментарий
Поле объекта $.status status на верхнем уровне
Вложенное поле $.meta.totalElements metatotalElements
Элемент массива по индексу $.items[0] первый элемент массива items
Поле элемента массива $.items[0].slug slug у первого элемента
Все значения поля в массиве $.items[*].slug список всех slug
Длина массива $.items.length() число элементов

Теперь давайте применим это к чуть более реалистичному JSON.

Пример с page-ответом: список опубликованных статей

В ContentHub есть публичный сценарий: показать список опубликованных статей. Обычно такие ответы содержат items и meta (страница, размер, total и т.п.). Мы сейчас не обсуждаем дизайн API — просто используем похожий JSON, чтобы потренировать проверки.

import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test; 

import java.util.List;

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

class PublicArticlesPageJsonPathTest {

    @Test
    void reads_items_and_meta_from_page_response() {
        // Имитация page-ответа: meta + items
        String json = """
                {
                  "meta": { "page": 0, "size": 2, "totalElements": 10 },
                  "items": [
                    { "slug": "java-basics", "status": "PUBLISHED" },
                    { "slug": "spring-intro", "status": "PUBLISHED" }
                  ]
                }
                """;

        // Достаём одно число из meta
        int page = JsonPath.read(json, "$.meta.page");

        // Достаём список значений поля slug из всех элементов items
        List<String> slugs = JsonPath.read(json, "$.items[*].slug");

        // Проверяем основную идею: «мы на нулевой странице и видим ожидаемые элементы»
        assertThat(page).isEqualTo(0);
        assertThat(slugs).containsExactly("java-basics", "spring-intro");
    }
}

Здесь есть важный учебный момент: JsonPath часто возвращает «уже типизированное» значение, но не всегда так, как вы ожидаете. page стал int (на самом деле там Integer под капотом), а slugs — списком строк. И дальше AssertJ делает то, что умеет лучше всего: даёт читаемые проверки коллекций.

Фильтрация по условию

Иногда в массиве элементов есть разные значения статуса, и вы хотите проверять только часть. JsonPath позволяет фильтровать элементы. Это уже «чуть продвинутее», но полезно хотя бы один раз увидеть, чтобы не думать, что JsonPath — только про индексы.

import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test; 

import java.util.List;

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

class JsonPathFilterTest {

    @Test
    void filters_items_by_status() {
        // В items лежат статьи с разными статусами
        String json = """
                {
                  "items": [
                    { "slug": "draft-1", "status": "DRAFT" },
                    { "slug": "java-basics", "status": "PUBLISHED" }
                  ]
                }
                """;

        // Оставляем только те элементы, у которых status == PUBLISHED, и берём их slug
        List<String> publishedSlugs = JsonPath.read(
                json,
                "$.items[?(@.status == 'PUBLISHED')].slug"
        );

        // Проверяем, что «публичными» считаются только опубликованные
        assertThat(publishedSlugs).containsExactly("java-basics");
    }
}

Если вы сейчас подумали «ого, а можно было и циклом», то да — можно. Но цикл спрятал бы смысл. А тут тест читается так: «возьми items, оставь только PUBLISHED, достань slug, сравни». Это ровно тот уровень декларативности, который делает тест похожим на документацию.

4. JsonPath + AssertJ без «100 проверок»

Когда вы освоили JsonPath, возникает очень типичное искушение: «Раз уж я могу достать любое поле, проверю вообще всё». Так появляются тесты, которые в одном методе проверяют двадцать полей, пять списков, три метаданных и один смысл жизни. Такие тесты плохо читаются и неприятно падают: вы видите красный, но в голове сразу вопрос — «а что именно сломалось?».

Хорошая привычка звучит скучно, но работает: один тест — одна основная идея. Если вы проверяете, что публичный список возвращает только PUBLISHED, то не обязаны в том же тесте проверять формат slug, наличие authorUsername и то, что meta.size совпадает с параметром запроса. Выберите смысл — и под него сделайте компактный набор проверок.

На практике это выглядит так: JsonPath достаёт значение, а AssertJ выражает смысловую проверку. Например, «status должен быть PUBLISHED», «количество items равно size», «в items есть нужный slug». Вот пример в стиле «проверка одной мысли»:

import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test; 

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

class PageConsistencyTest {

    @Test
    void page_size_matches_items_count() {
        // Здесь мы проверяем консистентность page-ответа: meta.size соответствует items.length()
        String json = """
                {
                  "meta": { "size": 2 },
                  "items": [
                    { "slug": "java-basics" },
                    { "slug": "spring-intro" }
                  ]
                }
                """;

        // Берём заявленный размер страницы
        int size = JsonPath.read(json, "$.meta.size");

        // Берём фактическое количество элементов в items
        int itemsCount = JsonPath.read(json, "$.items.length()");

        // Одна идея теста: эти значения должны совпадать
        assertThat(itemsCount).isEqualTo(size);
    }
}

И тест маленький, и ошибка будет говорящей: если itemsCount не равен size, вы поймёте, что сломался контракт страницы, а не что «где-то там JSON не совпал посимвольно».

5. JSONassert vs JsonPath: правило выбора

Здесь легко снова скатиться в спор «какой стиль лучше вообще», но внутри JSON обычно есть два разных вопроса, и под каждый удобен свой инструмент.

Если вы хотите доказать, что фрагмент JSON как контракт выглядит определённым образом (набор полей, структура, значения), то JSONassert обычно даёт более компактный и «контрактный» код. Вы буквально показываете «вот ожидаемая форма» и сравниваете.

Если вы хотите доказать, что одно конкретное правило внутри JSON выполняется (одно поле, один элемент массива, одно число в мета-информации), то JsonPath + AssertJ обычно читаются проще. Вы вытащили то, что важно, и проверили.

И ещё одна дисциплина, которая экономит много времени: старайтесь не проверять одно и то же двумя способами одновременно. Если вы уже сделали JSONassert-сравнение целого объекта, а потом ещё десять раз проверили те же поля через JsonPath, вы не стали вдвое надёжнее — вы стали вдвое более хрупкими.

6. Типичные ошибки при проверке JSON

Ошибка №1: сравнение JSON как строки «в лоб».
Это самая частая ловушка на старте: assertThat(actualJson).isEqualTo(expectedJson). Такой тест проверяет пробелы, переносы строк и порядок полей, хотя для JSON-объекта порядок полей семантически не важен. Итог — тест падает «на ровном месте» и учит вас не доверию к контракту, а ненависти к тестам. Если вы проверяете именно JSON-ответ, используйте либо JSONassert, либо JsonPath.

Ошибка №2: lenient-режим там, где контракт реально строгий.
Lenient — не «режим без боли», а режим «допускаем расширение и не фиксируем лишнее». Если вы тестируете payload, который должен быть строго определён (например, фиксированный error contract или фиксированное поле status в критичном ответе), слишком мягкое сравнение может скрыть поломку. В результате тест зелёный, а клиенту API уже больно.

Ошибка №3: строгий режим без понимания цены для массивов.
Когда включают строгий режим, часто забывают, что массивы начинают сравниваться с учётом порядка. Иногда это именно то, что нужно (например, сортировка публичного списка), а иногда это просто шум (например, список нарушений violations валидации может прийти в другом порядке). Если порядок не часть контракта, не заставляйте тест «защищать» его как священную реликвию.

Ошибка №4: JsonPath превращают в мини-язык программирования в одном тесте.
JsonPath умеет фильтры, функции, сложные выражения — и это прекрасно, пока тест остаётся читаемым. Но если ваш путь выглядит как заклинание из древнего гримуара, тест становится непонятен даже автору через неделю. В таких случаях лучше либо упростить JSON, который вы проверяете в тесте, либо достать несколько значений простыми путями и проверить их AssertJ-ом, чем писать «один путь на всё».

Ошибка №5: проверка «всего подряд» вместо проверки смысла сценария.
Очень соблазнительно проверить каждое поле каждого объекта. Но тест должен защищать риск. Если сценарий про то, что публичный список отдаёт только PUBLISHED статьи, то проверка двадцати полей статьи не делает тест полезнее — она делает его более хрупким при любых легитимных изменениях DTO. Лучше проверять те поля и связи, которые действительно выражают бизнес-смысл сценария.

1
Задача
Spring Test, 3 уровень, 2 лекция
Недоступна
JSONassert в lenient и strict режимах
JSONassert в lenient и strict режимах
1
Задача
Spring Test, 3 уровень, 2 лекция
Недоступна
JsonPath для page-ответа
JsonPath для page-ответа
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ