Всем привет, меня зовут Сергей Прощаев, и в этой статье расскажу про то, как заставить автотесты ловить изменения контракта API, а не делать вид, что всё в порядке.

Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce и преподаю на курсах разработки и архитектуры в OTUS. За последние годы я видел, наверное, все способы, которыми API‑тесты умеют врать: от «у нас всё зелёное» при сломанном проде до схем, которые выглядят строгими, а на деле пропускают почти всё.

Об этом и поговорим — предметно, с кодом и парой боевых историй.

Рис. 1. О чём эта статья: тест зелёный, а контракт уже поехал
Рис. 1. О чём эта статья: тест зелёный, а контракт уже поехал

Ситуация, в которой вы наверняка узнаете себя

Представьте: у вас есть набор API‑тестов на бэкенд. Они проверяют статус‑код, пару ключевых полей, время ответа. Всё зелёное, релиз едет. А через неделю с прода прилетает баг: мобильное приложение перестало показывать цену в корзине. Начинаете разбираться — оказывается, бэкенд переименовал поле price в unitPrice, выкатил, и никто не заметил. Тесты‑то прошли: статус 200, тело не пустое, время ответа в норме. Проверки на структуру не было.

Это и есть главная боль backend‑тестирования, которую я разбираю в этой статье. Тесты, которые дёргают эндпоинт и смотрят на пару полей, не защищают контракт. Они защищают ваше спокойствие — ровно до первого инцидента. А контракт API — это обещание: «я верну тебе объект вот с такими полями вот таких типов». И проверять надо именно обещание целиком, а не три поля из двадцати.

Дальше я покажу рабочий маршрут: как на REST Assured и JSON Schema Validator закрыть структуру ответа, как написать схему, которая реально ловит дрейф, и где в этой связке спрятан капкан, в который я сам когда‑то наступил. Код можно повторить у себя — он минимальный и без магии.

Исходные условия

Чтобы не было разночтений, фиксирую окружение на момент написания (июнь 2026):

  • Java 17+ — это сейчас baseline для свежих версий REST Assured.

  • REST Assured 6.0.0 — все примеры протестированы на этой версии (вышла 12 декабря 2025, подняла планку до Java 17, Groovy 5, добавила поддержку Spring 7 и Jackson 3).

  • JUnit 5 как раннер тестов.

  • Maven для сборки (на Gradle всё аналогично, отличается только синтаксис зависимости).

Тестировать будем условный сервис заказов: эндпоинт GET /api/v1/orders/{id}, который возвращает заказ с позициями, ценой и статусом. Ровно тот случай, где переименованное поле молча ломает расчёт суммы.

Подключаем две зависимости — сам REST Assured и модуль валидации схемы:

<dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>rest-assured</artifactId>
    <version>6.0.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>json-schema-validator</artifactId>
    <version>6.0.0</version>
    <scope>test</scope>
</dependency>

Маленькая, но важная деталь из практики: следите за версией Hamcrest. REST Assured подтягивает его транзитивно, и при несовместимых версиях вы получите конфликт матчеров — он падает не на компиляции, а в рантайме теста с NoSuchMethodError. Официальная дока советует объявлять rest-assured раньше JUnit в pom.xml, но полагаться на порядок зависимостей я бы не стал — это хрупко. Надёжнее зафиксировать версию Hamcrest явной зависимостью или через <exclusion>.

Маршрут решения

План у нас на пять шагов, каждый закрывает свой риск:

  • Написать базовый тест на эндпоинт — чтобы было от чего отталкиваться.

  • Описать JSON Schema ответа.

  • Подключить схему к тесту через REST Assured.

  • Сделать схему «строгой», чтобы она ловила лишние и переименованные поля.

  • Разобраться с капканом draft-04 — иначе шаги 2–4 могут оказаться бесполезными.

Шаг 1. Базовый тест: то, что есть у большинства

Вот типичный тест, который я вижу в проектах чаще всего:

import io.restassured.RestAssured;
import org.junit.jupiter.api.Test;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.notNullValue;

class OrderApiTest {

    @Test
    void shouldReturnOrder() {
        given()
            .baseUri("https://api.shop.internal")
        .when()
            .get("/api/v1/orders/42")
        .then()
            .statusCode(200)
            .body("id", equalTo(42))
            .body("totalPrice", notNullValue());
    }
}

Тест рабочий, но дырявый. Он проверяет статус, один id и наличие totalPrice. Если бэкенд переименует price внутри позиции, поменяет тип totalPrice со строки на число или выкинет половину полей — этот тест останется зелёным. Потому что он смотрит точечно, а структуру в целом не валидирует.

Что отсюда вынести: точечные ассерты проверяют значения, но не контракт. Их недостаточно, как только ответ сложнее трёх полей.

Шаг 2. Описываем схему ответа

JSON Schema — это описание структуры: какие поля есть, какого они типа, какие обязательны. Кладём файл в src/test/resources/schemas/order‑schema.json:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["id", "status", "items", "totalPrice"],
  "properties": {
    "id":         { "type": "integer" },
    "status":     { "type": "string", "enum": ["NEW", "PAID", "SHIPPED", "CANCELLED"] },
    "totalPrice": { "type": "number" },
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["sku", "price", "quantity"],
        "properties": {
          "sku":      { "type": "string" },
          "price":    { "type": "number" },
          "quantity": { "type": "integer", "minimum": 1 }
        }
      }
    }
  }
}

Обратите внимание: я сразу указал required для полей внутри items. Если бэкенд переименует price в unitPrice, в ответе пропадёт обязательное поле price, и валидация должна упасть. По крайней мере — в теории. На практике тут есть нюанс, до которого дойдём на шаге 5.

Что отсюда вынести: схема — это и есть формализованный контракт. Поле required превращает «ну, обычно цена приходит» в проверяемое правило.

Шаг 3. Подключаем схему к тесту

REST Assured умеет валидировать ответ против схемы из classpath одной строкой:

import org.junit.jupiter.api.Test;

import static io.restassured.RestAssured.given;
import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath;

class OrderSchemaTest {

    @Test
    void responseShouldMatchOrderSchema() {
        given()
            .baseUri("https://api.shop.internal")
        .when()
            .get("/api/v1/orders/42")
        .then()
            .statusCode(200)
            .body(matchesJsonSchemaInClasspath("schemas/order-schema.json"));
    }
}

Теперь одна проверка закрывает всю структуру разом: типы, обязательные поля, enum статуса. Это в разы дешевле, чем тянуть два десятка.body(«...»,...) руками. И, что важнее, такую проверку не забудешь обновить выборочно — она либо проходит для всего объекта, либо нет.

Как убедиться, что проверка действительно работает, а не просто зеленеет. Я всегда первым делом ломаю ответ намеренно — например, через мок отдаю заказ без totalPrice. Тест должен упасть, и fge при этом выдаёт вполне читаемое сообщение: какой именно инстанс не прошёл и почему. Выглядит это примерно так:

java.lang.AssertionError: 1 expectation failed.
Response body doesn't match expectation.
Expected: object has property "totalPrice"
  Actual: { "id": 42, "status": "PAID", "items": [ ... ] }

Если намеренно сломанный ответ валит тест с понятным указанием на поле — проверка живая. Если остаётся зелёным — это сигнал, что схема проверяет не то, что вы думаете (к этому вернёмся на шаге 5, там же объясню, почему так бывает).

Что отсюда вынести: одна строка matchesJsonSchemaInClasspath заменяет десятки ручных ассертов и даёт сплошную, а не точечную проверку структуры.

Шаг 4. Делаем схему строгой

А вот здесь начинается то, что отличает рабочую схему от схемы для галочки. По умолчанию — то есть если additionalProperties явно не указан — JSON Schema разрешает любые дополнительные поля, которых нет в описании. То есть если бэкенд добавит в ответ новое поле — схема это пропустит. Иногда это нормально, но для контракта чаще опасно: лишнее поле часто означает, что что‑то поехало (например, продублировалась цена в двух форматах, или утекло внутреннее поле, которого клиент видеть не должен).

Чтобы схема отвергала всё, что не описано явно, добавляем additionalProperties: false:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "additionalProperties": false,
  "required": ["id", "status", "items", "totalPrice"],
  "properties": {
    "id":         { "type": "integer" },
    "status":     { "type": "string", "enum": ["NEW", "PAID", "SHIPPED", "CANCELLED"] },
    "totalPrice": { "type": "number" },
    "items": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["sku", "price", "quantity"],
        "properties": {
          "sku":      { "type": "string" },
          "price":    { "type": "number" },
          "quantity": { "type": "integer", "minimum": 1 }
        }
      }
    }
  }
}

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

Важно и то, что именно ловит сильная схема: она ловит структурный дрейф — пропавшее поле, новый тип, лишний ключ. А смену значения она не увидит: если цена позиции была 100, а стала 95, контракт формально не нарушен — поле на месте, тип верный, схема зелёная. Структуру стерегут схемы, значения и бизнес‑правила — другой слой проверок.

Здесь я обычно делаю оговорку, потому что без неё совет вредный. additionalProperties: false хорош для внутренних API между вашими же сервисами, где вы контролируете обе стороны и контракт не предполагает эволюционного расширения. Оговорка про расширение существенная: если вы сознательно строите version‑tolerant readers или работаете по consumer‑driven contracts, то даже внутренние API часто специально делают расширяемыми, и тогда жёсткий запрет лишних полей будет мешать.

А для публичных API, которые расширяются по принципу «старые клиенты игнорируют новые поля», additionalProperties: false сломает тесты на каждом легальном расширении бэкенда. Мой вариант, который я обычно использую: строгая схема для стабильных внутренних контрактов, мягкая (с явным списком допустимых расширяемых блоков) — для расширяемых и внешних.

Проверяется строгий режим так же, как и обязательные поля: добавляете в мок‑ответ лишнее поле, которого нет в схеме (скажем, internalDebugFlag), и тест обязан упасть с сообщением вида «object instance has properties which are not allowed by the schema». Если не упал — значит additionalProperties: false где‑то не сработал (частая причина — забыли проставить его внутри вложенного объекта items, а не только на корне).

Что отсюда вынести: additionalProperties: false превращает схему из описания в контракт. Но применять её надо там, где вы владеете обеими сторонами интеграции, иначе утонете в ложных падениях.

Шаг 5. Капкан, в который я наступил: draft-04 под капотом

А теперь самое неприятное — и ради этого, по большому счёту, и затевалась статья.

Помню, как однажды я потратил полдня на схему, которая описывала условную логику: если status равен CANCELLED, то обязательно должно присутствовать поле cancelReason. В JSON Schema для этого есть конструкция if/then/else — она появилась в draft-07. Выглядела схема так:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["id", "status"],
  "properties": {
    "id":          { "type": "integer" },
    "status":      { "type": "string" },
    "cancelReason":{ "type": "string" }
  },
  "if": {
    "properties": { "status": { "const": "CANCELLED" } }
  },
  "then": {
    "required": ["cancelReason"]
  }
}

Написал, прогнал тест через matchesJsonSchemaInClasspath — зелёный. Обрадовался, закоммитил. А потом из любопытства намеренно сломал ответ: отдал CANCELLED без cancelReason. Тест всё равно зелёный — а по правилу из шага 3 сломанный ответ обязан его валить.

Как и опасался в тот момент — проверялось ровно ничего.

Причина оказалась вот в чём. Модуль json‑schema‑validator у REST Assured (включая версию 6.0.0) под капотом использует библиотеку Фрэнсиса Галиега — широко известную как fge. И эта библиотека застряла на draft-04. А if, then, const — это draft-07 и новее. Старый валидатор просто не понимает эти ключевые слова: для него это неизвестные свойства, которые он молча пропускает. Никакой ошибки, никакого предупреждения. Схема выглядит умной, а проверяет только type и required на верхнем уровне — то, что умел draft-04. Условие if/then не отрабатывает вообще, поэтому ответ без cancelReason и считается валидным.

Мне как‑то попалась мысль, что хуже непокрытого кода — только код, покрытый тестами, которые ничего не проверяют. Вот это ровно тот случай: зелёный тест создаёт ложное чувство безопасности, и это опаснее, чем честно отсутствующая проверка. У приёма «сначала намеренно сломай ответ и убедись, что тест покраснел» есть и формальные названия — negative testing, а шире — mutation testing mindset: вносишь контролируемую поломку и проверяешь, что тест её ловит. Для схем это обязательный шаг, а не перестраховка.

Что с этим делать. Если вам не нужны возможности после draft-04 — типы, required, enum, additionalProperties — fge полностью покрывает задачу, и схемы из шагов 2–4 работают корректно: эти ключевые слова есть и в draft-04, я их специально проверял на сломанных ответах. Проблемы начинаются ровно там, где вы используете draft-07 и новее: условную логику if/then/else, const, dependentRequired, более новые форматы. Всё это fge проигнорирует.

Если условная логика нужна — берём современный валидатор networknt/json-schema-validator, который поддерживает draft-04, 06, 07, 2019–09 и 2020–12, и подключаем его напрямую, в обход встроенного матчера REST Assured. Почему именно networknt: на сегодня это, пожалуй, самый активно развиваемый Java‑валидатор с полной поддержкой современных draft (в декабре 2025 он, как и REST Assured, переехал на Jackson 3 и JDK 17).

Популярный когда‑то everit держится только на draft-04/06/07 и фактически передал эстафету преемнику json‑sKema, так что советовать его в 2026 я бы не стал. Подключение и проверка выглядят так:

import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion.VersionFlag;
import com.networknt.schema.ValidationMessage;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.restassured.response.Response;

import java.util.Set;

import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertTrue;

class OrderStrictSchemaTest {

    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Test
    void cancelledOrderMustHaveReason() throws Exception {
        Response response = given()
                .baseUri("https://api.shop.internal")
            .when()
                .get("/api/v1/orders/42")
            .then()
                .statusCode(200)
                .extract().response();

        JsonNode body = MAPPER.readTree(response.asString());

        JsonSchema schema = JsonSchemaFactory
                .getInstance(VersionFlag.V202012)
                .getSchema(
                    getClass().getResourceAsStream("/schemas/order-schema-2020.json"));

        Set<ValidationMessage> errors = schema.validate(body);

        assertTrue(errors.isEmpty(),
            () -> "Ответ не соответствует схеме: " + errors);
    }
}

Тут мы сами читаем тело ответа, скармливаем его валидатору с явно указанной версией спецификации (V202012) и получаем понятный список ошибок. Кода чуть больше, чем в однострочнике REST Assured, зато проверка честная и поддерживает весь современный синтаксис JSON Schema.

Тот самый ответ CANCELLED без cancelReason, который раньше молча проходил, теперь валит тест с конкретным указанием на нарушенное условие then — то есть из зелёного на сломанных данных он наконец стал красным.

Что отсюда вынести: встроенный матчер REST Assured удобен, но завязан на draft-04. Как только вам нужны возможности draft-07 и новее — проверяйте, что под капотом, и при необходимости берите networknt напрямую. И главное правило, которое я вынес из этой истории: никогда не верьте зелёному тесту схемы, пока не увидели его красным на намеренно сломанном ответе.

Где в пайплайне это ловится

Один тест на схему — это хорошо, но чтобы дрейф контракта не доезжал до прода, проверку надо встроить в процесс. Ниже на рис. 2 — упрощённая схема того, где именно в пути от коммита бэкенда до продакшена срабатывает валидация и разворачивает поломку обратно к разработчику.

Рис. 2. Где в пайплайне ловится дрейф контракта
Рис. 2. Где в пайплайне ловится дрейф контракта

Главная мысль этой схемы простая: валидация схемы ценна не сама по себе, а как гейт. Она должна стоять в CI и блокировать сборку, а не просто существовать в виде теста, который кто‑то иногда запускает локально. Тогда переименованное priceunitPrice падает на этапе сборки с понятным сообщением, а не всплывает багом в корзине через неделю. Сдвиг проверки влево — это и есть весь смысл.

Сильная схема против слабой: что выбрать

Чтобы свести воедино разговор про строгость, посмотрите на рис. 3. Это матрица, которая помогает решить, насколько жёсткой должна быть схема в зависимости от того, чей это API и кто им управляет.

Рис. 3. Матрица выбора строгости схемы
Рис. 3. Матрица выбора строгости схемы

Главная мысль: универсального ответа «всегда делай строго» нет. Владеете обеими сторонами интеграции — делайте схему сильной, она окупается тем, что ловит дрейф. На другом конце внешний потребитель и API расширяется по обратной совместимости — сильная схема превратит каждое легальное новое поле в красную сборку, и команда быстро начнёт её игнорировать. А игнорируемая проверка не защищает ничего.

Что в итоге работает у сильных команд

Если обобщить, как выстроена работа с контрактами в зрелых командах на 2026 год, получается несколько слоёв. Сверху — OpenAPI как формальное описание API: из него и документация, и генерация клиентов, и провайдерская валидация (OpenAPI 3.1 полностью совместим с JSON Schema 2020–12, так что описание и схема перестали жить порознь).

Ниже — схема‑валидация ответов, которую мы и разбирали: дёшево, быстро, ловит структурные поломки. А поверх, для команд с множеством сервисов и независимыми релизами, — полноценное контрактное тестирование, чаще всего consumer-driven: потребитель сам декларирует нужные ему поля и типы, контракт хранится в общем брокере, и провайдер не может сломать потребителя, даже не зная о его существовании.

Чтобы сразу снять напрашивающиеся вопросы «а почему не вот это»: JSON Schema не заменяет Pact и consumer‑driven contracts, а закрывает другой уровень — структуру конкретного ответа, тогда как Pact стережёт согласованность между конкретными потребителем и провайдером. OpenAPI‑валидация решает близкую задачу на уровне всей спецификации и дополняет схему, а не конкурирует с ней. Spring Cloud Contract уместен, если вы целиком в экосистеме Spring. А WireMock — это про моки внешних зависимостей, а не про проверку контракта. Всё это слои одной картины, и схема‑валидация — её фундамент, а не альтернатива остальному.

Отдельная история, которая в 2026 году стала заметной — дрейф структурных ответов у LLM‑стадий в пайплайнах. Когда выход одной модели потребляется следующим компонентом, возникает тот же контракт «провайдер‑потребитель», только провайдер — языковая модель, и формат её ответа может поехать сам после смены версии модели или промпта: userId вдруг приходит как user_id, и парсинг тихо ломается. Лечится тем же приёмом — валидацией структуры на входе каждой стадии. Схема‑валидация, которую мы разобрали на простом заказе, переносится на этот класс задач один в один.

Но начинать всегда стоит с фундамента. Большинство команд, которые жалуются на «тесты есть, а инциденты продолжаются», обнаруживают, что валидируют ровно один слой — значения — и не валидируют структуру. С этого и надо закрывать дыру.

Чек‑лист: что проверить в своей валидации схемы

  • В тесте есть проверка всей структуры ответа, а не три точечных ассерта на отдельные поля.

  • В схеме проставлен required на действительно обязательные поля — включая вложенные объекты и элементы массивов.

  • Для внутренних контрактов стоит additionalProperties: false; для внешних — осознанно выбрана мягкая схема.

  • Вы знаете, какой draft использует ваш валидатор. Если в схеме есть if/then/else или const — проверьте, что под капотом не fge с draft-04, иначе эти правила молча игнорируются.

  • Валидация стоит гейтом в CI и блокирует сборку, а не запускается изредка руками.

  • При смене версии модели/контракта или схемы у вас есть тест, который намеренно подаёт «сломанный» ответ и убеждается, что валидация на него падает.

Зелёный тест ещё не значит, что контракт API не сломан. 2 июля в 20:00 на занятии «REST Assured & JSON Schema Validator: автоматизация тестирования API на практике» разберем, как с помощью REST Assured и JSON Schema Validator проверять структуру ответов, типы данных и обязательные поля.

Урок будет особенно полезен для тех, кто автоматизирует тестирование backend-а и хочет, чтобы тесты ловили реальные изменения API, а не просто проходили «для галочки». Записаться

Больше открытых уроков июля смотрите в дайджесте.

Комментарии (0)