Пишем тесты для API как договор: матрица сценариев и проверка контрактов
Соберём минимально достаточный набор тестов: валидация схем, статусы, поля ошибок и регрессии на контракте без хрупких моков.
Содержание
Пишем тесты для API как договор: матрица сценариев и проверка контрактов
Тесты для API часто превращаются в набор хрупких проверок: мы мокируем половину внутренностей сервиса, сравниваем строки ошибок «как в примере», а потом удивляемся, что тесты ломаются от малейших изменений формулировок или порядка полей. В результате они начинают тормозить разработку вместо того, чтобы защищать качество.
В этой статье разберём подход, который ближе к идее «контракта»: тесты проверяют договор между клиентом и сервером — структуру данных, статусы, семантику ошибок и регрессии на стабильность интерфейса. Наша цель — минимально достаточный набор тестов: не «покрыть всё», а закрепить ключевые свойства, по которым API можно безопасно развивать.
Что значит тестировать API как договор
Договор API состоит из нескольких слоёв:
- Схема (schema) — типы, обязательность полей, форматы (email, UUID, даты), ограничения (минимум/максимум, regex), допустимые значения enum.
- Протокол (protocol) — HTTP-статусы, методы, редиректы, заголовки, а также соглашения об идемпотентности и пагинации (если применимо).
- Семантика ошибок (error contract) — структура тела ошибки, коды/типы ошибок, поля
message,code,details, правила, когда возвращается4xxи когда5xx. - Поведенческие инварианты (invariants) — условия истинности: «созданный ресурс доступен по GET», «обновление меняет только заявленные поля», «валидация не принимает некорректные данные».
- Регрессии контракта — чтобы изменения в бэкенде не ломали клиентов.
Когда тесты ориентированы на договор, они становятся устойчивее к внутренним рефакторингам. Мы тестируем не то, как устроен код, а то, что обязано быть стабильным.
Минимальный набор тестов: что реально нужно
Минимальный «адекватный» набор обычно включает:
- Позитивные сценарии: валидные входные данные → ожидаемый статус → ожидаемая форма ответа → ключевые инварианты.
- Негативные сценарии валидации: неверные входные данные →
400/422(как у вас принято) → корректная структура ошибки → корректное содержание полей ошибок. - Проверки статусов: ресурс не найден →
404, нет прав →403, конфликт →409, серверная ошибка →500(или ваша специфика). - Проверки контракта ошибок: единый формат тела ошибки независимо от типа проблемы.
- Регрессии схемы: тесты, которые ловят изменения в JSON-формате (например, удаление поля, смена типа с
stringнаnumber, переименование ключа). - Идемпотентность/повторяемость (опционально): если эндпоинт таков, добавляем тесты на повторные запросы и воспроизводимость ответа.
Что не обязательно в минимуме:
- Точные тексты сообщений, если они локализуются или могут меняться.
- Мокирование внутренних компонентов, если можно сделать интеграционные тесты на тестовой среде (хотя бы минимальные).
- Слишком детальные проверки «каждого поля» в каждом тесте — лучше проверять ключевые части схемы и инварианты.
Матрица сценариев: как не утонуть в комбинациях
Ключевой инструмент — матрица сценариев. Она помогает покрыть достаточно много вариантов, не превращаясь в комбинаторный взрыв.
Как строить матрицу
Для каждого эндпоинта определите:
- Варианты входа:
- валидный (happy path)
- невалидный — неправильный тип
- невалидный — нарушение формата (например, email/UUID)
- невалидный — пусто/пропущено обязательное
- невалидный — бизнес-ограничения (например, «password слишком короткий»)
- Варианты ресурса (если есть):
- ресурс существует
- ресурс не существует
- ресурс в состоянии, не позволяющем операцию (например, статус
archived)
- Варианты прав:
- аутентифицирован, но нет прав
- есть права
- Варианты инфраструктуры:
- конкурентные изменения (опционально)
- внутренние ошибки (обычно один тест на «не упали, отдали 500 и контракт ошибки соблюдён»)
Матрицу можно формализовать, но лучше начать с компактного перечня «канонических» случаев. Практика показывает: если стабилизировать контракт ошибок и схемы, 70% ценности достигается.
Принцип: тесты проверяют форму и правила, а не детали реализации
Почему хрупкие моки — зло
Моки ломаются от рефакторинга: поменяли реализацию — тест падает, хотя внешний контракт сохранился. Плюс моки часто не отражают реальную валидацию и форматирование ошибок.
В идеале вы делаете тесты на уровне API:
- поднятый приложение (иногда с тестовой БД),
- реальные HTTP-запросы,
- проверка ответа по контракту.
Если мокирование неизбежно (например, внешние платежи), мокайте внешние зависимости, но контракт API не мокаем. Клиентская часть должна видеть тот же формат ошибок и статусы, что и в продакшене.
Контракт ответов: единый формат ошибок и проверяемость
Большая часть «хрупкости» появляется из-за того, что разные ветки возвращают разные структуры ошибок. Даже если статус один и тот же, клиенты могут зависеть от полей тела ошибки.
Рекомендованный минимальный error contract
Например, договором может быть:
- HTTP-статус:
400/422для валидации,404для отсутствия ресурса,409для конфликтов. - Тело ошибки: объект со стабильными полями, которые присутствуют всегда.
Пример (условный):
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request is invalid",
"details": [
{
"field": "email",
"reason": "must be a valid email"
}
]
}
}
В тестах важны:
- наличие
error.code, - наличие
error.details(если вы их используете), - соответствие состава
detailsожидаемым полям, - отсутствие «лишней» структуры, которую вы потом будете поддерживать.
Не обязательно жестко сравнивать message, если он может меняться. Но важно, чтобы code и формат details были стабильными.
Пример эндпоинта и тест-матрицы
Допустим, у нас API для создания пользователя.
POST /users- успешный ответ:
201 Created - тело пользователя:
id,email,name - ошибки валидации:
422 Unprocessable Entity - ошибки: единый формат
Матрица для POST /users
Минимально достаточный набор:
-
Успех
emailвалиденnameвалиден- →
201, схема ответа соблюдена,idпоявляется
-
Валидация: отсутствует email
- →
422,error.code = VALIDATION_ERROR detailsсодержитfield = "email"
- →
-
Валидация: email невалидный
- →
422,detailsсодержитemail
- →
-
Валидация: name пустой/слишком короткий
- →
422,detailsсодержитname
- →
-
Ошибка контракта на 500
- Спровоцировать внутреннюю ошибку (например, принудительно выбросить исключение через специальный флаг в тестовой среде или дефектный сценарий)
- →
500, ошибка возвращается в том же формате (или в вашем «серверном» контракте)
-
Регрессия схемы
- Даже если «сценарий успеха» уже есть, отдельный тест (или часть) проверяет, что ответ не потерял обязательные поля и типы
Как проверять схемы: тесты против изменений JSON
Есть два подхода:
- JSON Schema проверка (валидируем структуру ответа целиком).
- Проверка выборочных полей (дешевле, но слабее защищает от частичных изменений).
Для минимального набора чаще достаточно комбинации:
- валидируем объект по схеме (или хотя бы ключевые поля),
- отдельно проверяем поля ошибки и их содержание.
Пример на TypeScript + Jest + AJV
Допустим, вы используете TypeScript и хотите проверять JSON-ответы по схеме.
Установим ajv:
npm i ajv
Схема успеха (условно, упрощённо):
// schemas/userCreateResponse.ts
export const userCreateResponseSchema = {
type: "object",
required: ["id", "email", "name"],
properties: {
id: { type: "string", minLength: 1 },
email: { type: "string" },
name: { type: "string" }
},
additionalProperties: false
};
Схема ошибки:
// schemas/errorResponse.ts
export const errorResponseSchema = {
type: "object",
required: ["error"],
properties: {
error: {
type: "object",
required: ["code", "message", "details"],
properties: {
code: { type: "string" },
message: { type: "string" },
details: {
type: "array",
items: {
type: "object",
required: ["field", "reason"],
properties: {
field: { type: "string" },
reason: { type: "string" }
},
additionalProperties: false
}
}
},
additionalProperties: false
}
},
additionalProperties: false
};
Тест на проверку схемы:
// tests/users.create.contract.test.ts
import Ajv from "ajv";
import { userCreateResponseSchema } from "../schemas/userCreateResponse";
import { errorResponseSchema } from "../schemas/errorResponse";
const ajv = new Ajv({ allErrors: true, strict: false });
describe("POST /users contract", () => {
it("success: returns user with expected schema", async () => {
const res = await fetch("http://localhost:3000/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "ava@example.com", name: "Ava" })
});
expect(res.status).toBe(201);
const json = await res.json();
const validate = ajv.compile(userCreateResponseSchema);
const ok = validate(json);
expect(ok).toBe(true);
if (!ok) {
// полезно, чтобы в лог шли конкретные проблемы схемы
// в реальном проекте — выводить validate.errors
console.error(validate.errors);
}
// Доп. инварианты (точечно, не дублируя schema):
expect(json.email).toBe("ava@example.com");
expect(typeof json.id).toBe("string");
});
it("validation error: missing email returns error contract", async () => {
const res = await fetch("http://localhost:3000/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Ava" })
});
expect(res.status).toBe(422);
const json = await res.json();
const validate = ajv.compile(errorResponseSchema);
const ok = validate(json);
expect(ok).toBe(true);
// Проверка семантики: ожидаем поле email в details
const details = json.error.details as Array<{ field: string; reason: string }>;
expect(details.some(d => d.field === "email")).toBe(true);
});
});
Важные нюансы:
additionalProperties: falseпомогает ловить «утечки» или неожиданные поля. Иногда это слишком строго; можно ослабить и проверять только required.- Для больших схем держите их в отдельном месте (или генерируйте из OpenAPI).
- Ошибки схемы — это именно то, что нужно, чтобы тесты не были хрупкими к локализации текста
message.
Проверка статусов и контрактов ошибок без привязки к текстам
Статусы — часть протокола. Их легко тестировать и редко меняют без причины. Ошибочный контракт — наиболее ценный слой для стабильности.
Практика: тестируем error.code, а не message
Текст ошибки часто меняется из-за правок UX или локализации. Поэтому:
error.codeи структураdetailsдолжны быть проверяемы,messageможно валидировать по схеме, но не сравнивать дословно.
Пример проверки «код ошибки и поле»:
expect(json.error.code).toBe("VALIDATION_ERROR");
expect(json.error.details.map((d: any) => d.field)).toContain("email");
Регрессия контракта: как тестировать «не исчезни»
Есть тип ошибок, которые сценарии ловят плохо: сервер начинает возвращать другое имя поля, отменяет обязательность или меняет формат id. В такой ситуации тест «happy path» может не упасть, если вы проверяете только часть полей.
Регрессия: отдельный слой проверки
Минимально достаточный способ:
- На успехе проверяйте схему ответа (или хотя бы список обязательных полей и их типы).
- На ошибках проверяйте схему ошибки (и что
error.code/detailsприсутствуют).
Если вы используете OpenAPI, ещё лучше:
- хранить schema как часть спецификации,
- генерировать валидатор,
- и тестировать, что фактический ответ соответствует контракту.
Избегаем хрупкости: что не делать в тестах
-
Не мокайте внутренние компоненты так, чтобы проверять их взаимодействия.
Тест должен подтверждать внешний эффект и формат. -
Не сравнивайте порядок элементов, если семантика не требует порядка.
Дляdetailsсортируйте или проверяйте множеством (в JS это удобно черезsome/includesпо полям). -
Не привязывайтесь к точному тексту
message.
Проверяйте коды, поля и типы. -
Не делайте один огромный «энд-ту-энд» тест на всё.
Лучше матрицей по ключевым веткам: валидация, доступ, не найдено, успех. -
Не позволяйте тестам «знать слишком много».
Если внутренний формат не важен — не проверяйте его. Проверяйте то, что клиенту нужно.
Стратегия для нескольких эндпоинтов: повторяемость подхода
Контракты лучше проверять единообразно. Для этого удобно ввести вспомогательные функции:
assertStatus(res, expected)assertJsonSchema(json, schema)assertErrorCode(json, code)assertValidationField(json, field)
Так вы снижаете стоимость поддержки тестов и минимизируете расхождения между командами.
Пример вспомогательных функций (условно, TypeScript):
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true, strict: false });
export function assertSchema(schema: any, json: any) {
const validate = ajv.compile(schema);
const ok = validate(json);
if (!ok) throw new Error(`Schema validation failed: ${JSON.stringify(validate.errors)}`);
}
export function assertErrorField(json: any, field: string) {
const details = json?.error?.details;
if (!Array.isArray(details)) throw new Error("Expected error.details to be an array");
if (!details.some((d: any) => d.field === field)) {
throw new Error(`Expected error.details to contain field=${field}`);
}
}
Как встроить тесты в процесс релизов
Тесты контрактов должны быть быстрыми и воспроизводимыми:
- Запускать в CI после сборки,
- Держать минимальный набор эндпоинтов «критичным»,
- Разделять:
- contract tests (быстро, схематично),
- integration tests (глубже, дольше),
- e2e (по необходимости).
Если тесты контрактов начинают длиться долго — чаще всего проблема в инфраструктуре (БД, миграции, поднятие окружения). Оптимизация должна идти туда: быстрее поднимать окружение, использовать тестовую БД с кэшированием, фиксировать начальные данные.
Типичные ошибки при тестировании контрактов
1) Слишком строгая схема и “вечные падения”
Если вы запретили additionalProperties: false, а сервер иногда добавляет метаданные — тесты начнут ломаться при каждом расширении ответа. В таком случае лучше:
- разрешить дополнительные поля,
- требовать только
required, - или сделать schema уровня «минимум» для контрактного слоя.
2) Разный формат ошибок в разных частях приложения
Одна из самых дорогих проблем — когда валидация летит через один middleware, а бизнес-ошибки формируются другим способом. Контракт ломается, тесты выстраиваются «по месту», и поддержка превращается в работу следователя. Нужен единый error formatter, а тест должен закреплять именно его.
3) “Тестируем только успех”
Если вы проверяете только happy path, вы ловите проблемы схемы, но не ловите деградацию валидации и ошибок — а клиентам именно ошибки важны. Матрица сценариев должна включать минимум
Комментарии
Пока нет комментариев