Тестирование API: REST, GraphQL, gRPC, схемы и инструменты
API-тесты — самый выгодный уровень автоматизации в типичном веб-проекте. Они на порядок быстрее и стабильнее E2E, потому что не зависят от вёрстки, анимаций и того, успел ли отрисоваться спиннер. И они гораздо ближе к бизнес-смыслу, чем юнит-тесты, потому что проверяют систему через тот же контракт, которым пользуется реальный клиент.
Именно поэтому в API-тестах чаще всего заводится специфическая болезнь: их пишут много, они зелёные, метрика «покрыто 87% эндпоинтов» растёт — а в проде регулярно всплывает то, что тесты в принципе не могли поймать. Эта статья про то, как этого избежать: что в API вообще есть предметом проверки, чем отличаются три доминирующих стиля интерфейсов и как из машинно-читаемого контракта извлечь половину тестов бесплатно.
Что вообще тестируем, когда «тестируем API»
Слово API в разговоре обозначает три разных вещи, и путаница между ними — источник бессмысленных споров.
- Контракт — описание того, что интерфейс обещает: OpenAPI-документ,
.proto-файл, GraphQL SDL. Его можно проверять статически, без запущенной системы. - Реализация за контрактом — сервис, который на запрос выдаёт ответ и меняет состояние мира. Её проверяем, отправляя запросы.
- Совместимость — свойство пары «контракт вчера / контракт сегодня» и пары «то, что ждёт клиент / то, что отдаёт сервер». Это отдельный класс проверок, про который забывают чаще всего.
API-тест по смыслу — интеграционный: он поднимает сервис вместе с его БД, очередью и, возможно, заглушками внешних систем, и дёргает публичную границу. Разница с материалом статьи об интеграционном тестировании в точке зрения: там мы смотрели изнутри, «работают ли мои компоненты вместе», здесь — снаружи, «выполняю ли я обещание перед клиентом».
Взгляд тестировщика: API — это дверь, через которую можно проверить бизнес-логику, не пробиваясь через UI. Главный вопрос: «что здесь может сломаться так, что пользователь заметит?»
Взгляд разработчика: API — это контракт, который я обязан не сломать при рефакторинге. Главный вопрос: «какие тесты дадут мне свободу менять внутренности?»
Оба взгляда нужны и дают разные наборы тестов. Разработчик почти никогда не пишет проверку «а что если прислать
Content-Type: text/plainи тело в XML». Тестировщик почти никогда не пишет проверку «обратная совместимость версии v2 со старым клиентом на уровне схемы». Хорошая команда объединяет наборы, а не выбирает один.
Шесть слоёв проверки одного ответа
Ответ API — многослойная штука. Каждый слой можно проверить или не проверить, и большинство наборов тестов останавливаются на первых двух.
Практический приём на код-ревью тестов: возьмите любой существующий API-тест и спросите, какие слои он трогает. Если только 1–2 — это тест «сервер жив», он полезен как смоук, но не заменяет проверку логики. Если 4 и 5 — это настоящий тест поведения.
Самый недооценённый слой — пятый. Классический прод-инцидент выглядит так:
POST /orders вернул 201 Created с корректным телом, но запись в БД не
закоммитилась из-за проглоченного исключения в асинхронном хвосте обработки.
Тест, который проверяет только код ответа и схему, зелёный. Тест, который после
создания делает GET по Location и ждёт 200, — красный.
REST: что проверять, кроме «200 OK»
REST — не стандарт, а архитектурный стиль поверх HTTP. Поэтому «правильность» REST-ответа определяется семантикой HTTP из RFC 9110 плюс договорённостями команды. Хороший ориентир для этих договорённостей — Zalando RESTful API Guidelines и Google API Design Guide.
Базовый набор проверок для одного ресурса — не «happy path», а сетка:
| Что проверяем | Пример | Частая ошибка реализации |
|---|---|---|
| Успешное создание | POST /orders → 201 + Location |
возвращают 200 без Location |
| Чтение созданного | GET /orders/{id} → те же данные |
ответ на создание богаче, чем на чтение |
| Невалидное тело | лишнее поле, неверный тип → 400/422 |
молча игнорируют неизвестные поля |
| Нарушение бизнес-правила | заказ на закрытый товар → 409/422 |
отдают 500 |
| Отсутствие ресурса | GET /orders/999999 → 404 |
отдают 200 с null в теле |
| Чужой ресурс | заказ другого пользователя → 404/403 |
отдают 200, это BOLA |
| Нет токена / протух | → 401 с WWW-Authenticate |
отдают 403 или 500 |
| Метод не поддержан | DELETE /orders → 405 + Allow |
отдают 404 |
Неверный Content-Type |
XML вместо JSON → 415 |
пытаются распарсить и падают |
| Повтор запроса | второй POST с тем же Idempotency-Key |
создаётся дубль заказа |
| Пагинация на границе | ?limit=0, ?limit=10000, курсор с конца |
утекает вся таблица |
| Превышение лимита | → 429 + Retry-After |
лимитов нет вовсе |
Отдельно про идемпотентность. По HTTP GET, PUT, DELETE идемпотентны,
POST — нет. Но реальный клиент с ретраями превращает любой POST в потенциальный
дубль: сеть оборвалась после того, как сервер обработал запрос, клиент повторил.
Отсюда паттерн Idempotency-Key — см. как это описывает Stripe.
Проверка на это — обязательная строчка в чек-листе для любой операции, которая
списывает деньги или создаёт сущность.
Тест на Python: httpx + pytest
import uuid
import httpx
import pytest
BASE_URL = "http://localhost:8000"
@pytest.fixture(scope="session")
def api() -> httpx.Client:
"""Один клиент на сессию: переиспользуем соединения, задаём общий таймаут.
Таймаут обязателен — без него зависший сервис вешает весь прогон CI."""
with httpx.Client(base_url=BASE_URL, timeout=5.0) as client:
yield client
@pytest.fixture
def auth_headers(api) -> dict[str, str]:
"""Свежий пользователь на каждый тест: тесты не должны делить состояние,
иначе параллельный прогон начнёт мигать."""
email = f"user-{uuid.uuid4()}@example.test"
r = api.post("/auth/register", json={"email": email, "password": "S3cret!pass"})
assert r.status_code == 201, r.text
return {"Authorization": f"Bearer {r.json()['access_token']}"}
def test_создание_заказа_возвращает_location_и_читается_по_нему(api, auth_headers):
payload = {"sku": "BOOK-001", "quantity": 2}
created = api.post("/orders", json=payload, headers=auth_headers)
# Слой 2: статус и заголовки
assert created.status_code == 201
assert "Location" in created.headers
assert created.headers["Content-Type"].startswith("application/json")
# Слой 4: значения — цена посчитана, статус начальный
body = created.json()
assert body["quantity"] == 2
assert body["status"] == "created"
assert body["total"] == "1198.00" # точная строка, а не float: деньги
# Слой 5: побочный эффект — ресурс действительно существует
fetched = api.get(created.headers["Location"], headers=auth_headers)
assert fetched.status_code == 200
assert fetched.json()["id"] == body["id"]
def test_повтор_с_тем_же_idempotency_key_не_создаёт_дубль(api, auth_headers):
key = str(uuid.uuid4())
headers = {**auth_headers, "Idempotency-Key": key}
payload = {"sku": "BOOK-001", "quantity": 1}
first = api.post("/orders", json=payload, headers=headers)
second = api.post("/orders", json=payload, headers=headers)
assert first.status_code == 201
# Второй ответ должен вернуть тот же ресурс, а не создать новый
assert second.json()["id"] == first.json()["id"]
assert api.get("/orders", headers=auth_headers).json()["total_count"] == 1
@pytest.mark.parametrize(
"payload, expected_status, expected_field",
[
({"sku": "BOOK-001", "quantity": 0}, 422, "quantity"), # граница снизу
({"sku": "BOOK-001", "quantity": -1}, 422, "quantity"), # за границей
({"sku": "BOOK-001", "quantity": 1001}, 422, "quantity"), # граница сверху
({"sku": "", "quantity": 1}, 422, "sku"), # пустая строка
({"quantity": 1}, 422, "sku"), # нет поля
({"sku": "NO-SUCH-SKU", "quantity": 1}, 404, "sku"), # ссылка в никуда
],
)
def test_невалидный_запрос_отклоняется_с_указанием_поля(
api, auth_headers, payload, expected_status, expected_field
):
r = api.post("/orders", json=payload, headers=auth_headers)
assert r.status_code == expected_status
problem = r.json() # ожидаем RFC 9457 problem+json
assert expected_field in str(problem["errors"])
Три вещи здесь не случайны. Во-первых, параметризация вместо шести копипаст-тестов — это прямое применение классов эквивалентности и границ из тест-дизайна. Во-вторых, уникальный пользователь на каждый тест: общие тестовые данные — источник номер один флаки-тестов при параллельном прогоне. В-третьих, проверка тела ошибки, а не только кода: клиент должен уметь показать пользователю, какое поле неверно.
Тот же подход на TypeScript: vitest + zod
Схему ответа удобно описывать типизированно и переиспользовать как валидатор,
а не писать десяток expect(body.x).toBeDefined().
import { describe, expect, it, beforeAll } from "vitest";
import { z } from "zod";
const BASE = process.env.API_URL ?? "http://localhost:8000";
// Контракт ответа как единый объект: строгий режим ловит лишние поля,
// а .parse() падает с внятным сообщением о том, что именно не совпало.
const OrderSchema = z
.object({
id: z.string().uuid(),
sku: z.string().min(1),
quantity: z.number().int().positive(),
total: z.string().regex(/^\d+\.\d{2}$/),
status: z.enum(["created", "paid", "shipped", "cancelled"]),
createdAt: z.string().datetime({ offset: true }),
})
.strict();
let auth: Record<string, string>;
beforeAll(async () => {
const res = await fetch(`${BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: `user-${crypto.randomUUID()}@example.test`,
password: "S3cret!pass",
}),
});
auth = { Authorization: `Bearer ${(await res.json()).access_token}` };
});
describe("POST /orders", () => {
it("возвращает заказ, полностью соответствующий контракту", async () => {
const res = await fetch(`${BASE}/orders`, {
method: "POST",
headers: { "Content-Type": "application/json", ...auth },
body: JSON.stringify({ sku: "BOOK-001", quantity: 2 }),
});
expect(res.status).toBe(201);
// Схема проверяет форму, отдельные expect — бизнес-значения
const order = OrderSchema.parse(await res.json());
expect(order.status).toBe("created");
expect(order.total).toBe("1198.00");
});
});
.strict() здесь важнее, чем кажется. Без него тест пропустит новое поле в ответе —
а новое поле может быть утечкой персональных данных (passwordHash, internalNotes),
которую никто не заметит. С ним — тест упадёт и заставит осознанно обновить контракт.
Схема как источник истины: OpenAPI и генерация тестов
Ручное описание ожидаемой формы ответа в каждом тесте — работа, которую можно не делать. Если у сервиса есть OpenAPI-документ, он и есть спецификация формы.
import json
from pathlib import Path
import httpx
import pytest
from jsonschema import Draft202012Validator
from referencing import Registry, Resource
SPEC = json.loads(Path("openapi.json").read_text(encoding="utf-8"))
def validator_for(component: str) -> Draft202012Validator:
"""Строим валидатор для схемы из components/schemas, сохраняя $ref-ссылки
на другие компоненты того же документа."""
registry = Registry().with_resource("", Resource.from_contents(SPEC))
schema = {"$ref": f"#/components/schemas/{component}"}
return Draft202012Validator(schema, registry=registry)
def test_ответ_соответствует_схеме_из_openapi(api, auth_headers):
r = api.get("/orders", headers=auth_headers)
assert r.status_code == 200
errors = sorted(
validator_for("OrderList").iter_errors(r.json()),
key=lambda e: list(e.path),
)
assert not errors, "\n".join(f"{list(e.path)}: {e.message}" for e in errors)
Дальше идёт приём, который экономит недели: property-based тестирование по схеме.
Schemathesis читает OpenAPI, генерирует запросы,
которые схема формально допускает, и проверяет ответ на набор встроенных свойств —
соответствие схеме, отсутствие 500, корректность заголовков.
# Прогон против запущенного сервиса; --checks all включает все встроенные проверки.
# Синтаксис CLI менялся между мажорными версиями — сверяйтесь с докой своей.
schemathesis run http://localhost:8000/openapi.json \
--checks all \
--hypothesis-max-examples 200 \
--header "Authorization: Bearer $TOKEN" \
--junit-xml reports/schemathesis.xml
Что этот прогон находит в реальных проектах: 500 на строке в 10 000 символов там,
где схема не задала maxLength; падение на unicode-суррогатах; ответ 200 с телом,
не соответствующим объявленной схеме, когда сработала редкая ветка кода. Что он
не находит: ни одного бизнес-дефекта. Скидка посчитана неверно — Schemathesis
это устраивает, схема-то соблюдена.
Отсюда правило разделения труда: форму проверяем машинно, смысл — руками. Генератор по схеме снимает с вас скучный класс дефектов и освобождает время на проверки, которые требуют знания предметной области.
Обратная сторона той же медали — дрейф спецификации. Документ в репозитории
описывает status: string, сервис отдаёт status: null. Если OpenAPI пишется
руками отдельно от кода, он протухает за пару спринтов. Лечится двумя способами:
генерировать спеку из кода (FastAPI, NestJS + Swagger, springdoc) либо, наоборот,
генерировать серверные модели из спеки. Тест, который каждый прогон сверяет
опубликованный документ с фактическими ответами, — дешёвая страховка в обоих случаях.
Как решать, что тестировать: риск, а не список эндпоинтов
«Покрыть все эндпоинты» — плохая цель. У сервиса из 60 эндпоинтов три обрабатывают 95% трафика и все деньги, а сорок — административные ручки, которыми пользуются раз в месяц два человека. Равномерное покрытие означает, что вы потратили одинаково на критичное и на неважное.
Работающий приём — разложить эндпоинты по двум осям: частота использования и ущерб от отказа.
Правый верхний квадрант получает всё: негативные кейсы, идемпотентность, гонки, нагрузочный профиль. Левый нижний — одну строчку в смоуке или вообще ничего, кроме генератора по схеме. Разница в затратах между «покрыть всё одинаково» и «покрыть по риску» — обычно в разы, при том же уровне защиты от дорогих инцидентов. Подробнее про экономику этого выбора — в статье о стратегии автоматизации.
Второй приём — тестировать переходы состояний, а не отдельные вызовы. Ресурс с жизненным циклом даёт целый пласт дефектов на невалидных переходах, которые никогда не всплывут при поштучной проверке ручек.
Формально: если в жизненном цикле N состояний и M операций, то валидных переходов
единицы, а невалидных пар — порядка N × M. Проверять все не нужно, но пройти по
самым опасным (деньги, отгрузка) — обязательно. Именно здесь чаще всего
обнаруживается, что сервис отдаёт 500 вместо 409, потому что разработчик
не рассматривал такую последовательность.
GraphQL: другой контракт, другие дефекты
GraphQL ломает половину привычек REST-тестировщика, и если этого не осознать, тесты будут проверять не то.
Что меняется принципиально:
- Один эндпоинт
POST /graphql. Проверка «а что наDELETE /orders» теряет смысл. - HTTP
200почти всегда, даже когда запрос провалился. Ошибка лежит в полеerrorsтела ответа. Тест, который ассертит толькоres.status === 200, проходит на полностью сломанном запросе. Это ошибка номер один. - Частичный успех: ответ может содержать и
dataс частью полей, иerrorsпо остальным. Нужно проверять оба поля. - Клиент сам определяет форму ответа. Значит, тестировать надо не «ответ ручки», а поведение полей и резолверов в разных комбинациях.
import { expect, it } from "vitest";
async function gql<T>(query: string, variables: Record<string, unknown> = {}) {
const res = await fetch(`${BASE}/graphql`, {
method: "POST",
headers: { "Content-Type": "application/json", ...auth },
body: JSON.stringify({ query, variables }),
});
// Статус проверяем, но он ничего не доказывает — важен разбор тела
expect(res.status).toBe(200);
return (await res.json()) as { data: T | null; errors?: Array<{
message: string;
path?: (string | number)[];
extensions?: { code?: string };
}> };
}
it("несуществующий заказ даёт null в data и код NOT_FOUND в errors", async () => {
const body = await gql<{ order: unknown }>(
`query ($id: ID!) { order(id: $id) { id status } }`,
{ id: "00000000-0000-0000-0000-000000000000" },
);
expect(body.data?.order).toBeNull();
expect(body.errors?.[0].extensions?.code).toBe("NOT_FOUND");
expect(body.errors?.[0].path).toEqual(["order"]);
});
it("чужой заказ не отдаёт поля, но не роняет весь ответ", async () => {
const body = await gql<{ order: { id: string; internalCost: number | null } }>(
`query ($id: ID!) { order(id: $id) { id internalCost } }`,
{ id: FOREIGN_ORDER_ID },
);
// Частичный ответ: id виден, приватное поле — нет
expect(body.data?.order?.id).toBe(FOREIGN_ORDER_ID);
expect(body.data?.order?.internalCost).toBeNull();
expect(body.errors?.[0].extensions?.code).toBe("FORBIDDEN");
});
Специфические для GraphQL классы дефектов, которые стоит проверять явно:
- Авторизация на уровне поля. В REST доступ проверяется на ручке, в GraphQL —
на каждом резолвере. Обход обычно выглядит так: пользователь запрашивает
безобидный объект и вытягивает через связь
order { customer { email phone } }чужие данные. Проверка обязана быть по каждому чувствительному полю, а не по корневому запросу. - N+1 запросов. Запрос
orders(first: 50) { customer { name } }без DataLoader порождает 51 обращение к БД. Тест: выполнить запрос, посчитать количество SQL-запросов (счётчик в тестовом окружении,pg_stat_statementsили хук ORM) и утверждать, что их не больше константы. - Отказ в обслуживании через глубину и сложность. Рекурсивный запрос
order { customer { orders { customer { ... } } } }кладёт сервер. Тест должен подтверждать, что лимит глубины/сложности включён и срабатывает. - Интроспекция в проде. Открытый
__schemaвыдаёт всю модель данных. Проверяется одним запросом на боевом окружении. - Совместимость схемы. Удаление поля, сужение типа, добавление обязательного
аргумента — ломающие изменения. Автоматизируется через
GraphQL Inspector:
graphql-inspector diff old.graphql new.graphqlв CI, падение на breaking change.
Разработчику GraphQL удобнее покрыть резолверы юнит-тестами и держать контрактный слой тонким. Тестировщику это не заменяет запросов «как настоящий клиент»: комбинация полей — это то, чего в юнит-тестах резолвера не существует, а в проде она приходит с фронтенда каждую секунду.
gRPC: контракт компилируется, ошибки типизированы
gRPC на первый взгляд проще: .proto — строгая схема, клиент генерируется,
опечатки ловит компилятор. Целый класс REST-дефектов (неверный Content-Type,
кривой JSON, поле не того типа) исчезает. Взамен появляются свои.
прекратить работу, а не дописывать в БД
Что проверять специфично для gRPC:
- Коды статусов. Их 17, и они часть контракта:
INVALID_ARGUMENTпротивFAILED_PRECONDITION,NOT_FOUNDпротивPERMISSION_DENIED,ALREADY_EXISTS,RESOURCE_EXHAUSTED,UNAVAILABLE. Типовой дефект — всё заворачивается вUNKNOWNилиINTERNAL, и клиент не может отличить «повторить можно» от «повторять бессмысленно». Список и семантика — в документации gRPC. - Deadline. В gRPC дедлайн передаётся с запросом и должен распространяться по
цепочке вызовов. Тест: выставить заведомо маленький дедлайн и убедиться, что
клиент получил
DEADLINE_EXCEEDED, а сервер не оставил после себя половину записанных данных. - Стриминг. Четыре режима — unary, server-streaming, client-streaming, bidirectional. Для стримов проверяются вещи, которых нет в REST: порядок сообщений, поведение при разрыве посреди потока, корректное завершение, обратное давление.
- Обратная совместимость
.proto. Нельзя переиспользовать номер удалённого поля (нуженreserved), нельзя менять тип поля, нельзя переименовывать enum-значения, если они уходят наружу как строки. Проверяется статически:buf breaking.
import grpc
import pytest
import orders_pb2 as pb
import orders_pb2_grpc as pb_grpc
@pytest.fixture(scope="session")
def stub():
with grpc.insecure_channel("localhost:50051") as channel:
# Ждём готовности канала, иначе первый тест упадёт на гонке старта
grpc.channel_ready_future(channel).result(timeout=10)
yield pb_grpc.OrdersStub(channel)
def test_несуществующий_заказ_даёт_not_found(stub, auth_metadata):
with pytest.raises(grpc.RpcError) as exc:
stub.GetOrder(pb.GetOrderRequest(id="missing"), timeout=2, metadata=auth_metadata)
assert exc.value.code() == grpc.StatusCode.NOT_FOUND
# Сообщение — тоже контракт: клиент показывает его в логах поддержки
assert "order" in exc.value.details().lower()
def test_отрицательное_количество_даёт_invalid_argument(stub, auth_metadata):
with pytest.raises(grpc.RpcError) as exc:
stub.CreateOrder(
pb.CreateOrderRequest(sku="BOOK-001", quantity=-1),
timeout=2, metadata=auth_metadata,
)
assert exc.value.code() == grpc.StatusCode.INVALID_ARGUMENT
def test_серверный_стрим_отдаёт_заказы_по_возрастанию_даты(stub, auth_metadata):
stream = stub.ListOrders(pb.ListOrdersRequest(page_size=100),
timeout=10, metadata=auth_metadata)
dates = [item.created_at.ToDatetime() for item in stream]
assert dates == sorted(dates), "порядок в стриме — часть контракта"
assert len(dates) > 0
Для ручной разведки без написания кода незаменим grpcurl — при включённой
server reflection он позволяет вызывать методы, не имея .proto под рукой:
grpcurl -plaintext localhost:50051 list # какие сервисы есть
grpcurl -plaintext localhost:50051 describe orders.Orders # какие методы и типы
grpcurl -plaintext -d '{"sku":"BOOK-001","quantity":2}' \
-H "authorization: Bearer $TOKEN" \
localhost:50051 orders.Orders/CreateOrder
А проверку совместимости схемы стоит поставить в CI отдельным быстрым шагом (buf breaking):
# buf.yaml
version: v2
breaking:
use:
- FILE # запрещает всё, что ломает существующих клиентов
except:
- FIELD_SAME_DEFAULT
lint:
use:
- STANDARD
Сравнение трёх стилей с точки зрения тестировщика
| Аспект | REST | GraphQL | gRPC |
|---|---|---|---|
| Контракт | OpenAPI (часто дрейфует) | SDL (генерируется из кода) | .proto (компилируется) |
| Признак ошибки | HTTP-код | поле errors, статус всегда 200 |
StatusCode + details |
| Форма ответа | фиксирована сервером | задаёт клиент | фиксирована схемой |
| Разведка вручную | curl, Postman, Bruno | GraphiQL, Altair | grpcurl + reflection |
| Генерация тестов | Schemathesis по OpenAPI | по SDL, ограниченно | fuzzing по proto |
| Главный риск | молчаливый дрейф схемы | field-level авторизация, N+1 | несовместимость proto |
| Слабое место тестов | «проверили только 200» | «проверили только status 200» | «проверили только happy path» |
Заглушки внешних систем и контрактные тесты
Реальный сервис почти никогда не живёт один: он ходит в платёжный шлюз, в сервис уведомлений, в чужой API партнёра. Три стратегии, все с ценой:
- Ходить в настоящую песочницу партнёра. Максимально честно, максимально нестабильно: чужая песочница падает, лимитирует запросы и меняет данные. Годится для отдельного медленного набора, запускаемого ночью, — не для PR-пайплайна.
- Поднимать заглушку (WireMock, Prism, MockServer,
respxдля Python). Быстро и стабильно. Риск: заглушка отвечает так, как вы думаете, что отвечает партнёр. Расхождение обнаружится в проде. - Контрактные тесты (Pact). Потребитель описывает свои ожидания, они превращаются в заглушку для его тестов и в набор проверок, который прогоняется против настоящего провайдера. Пункт 2 без его главного риска.
пока чей-то контракт не верифицирован
Практический нюанс, о который спотыкаются: контрактный тест не проверяет
бизнес-логику. Он гарантирует, что формы запросов и ответов совпадают.
Если провайдер вернёт корректный по форме, но неверный по смыслу total,
Pact будет доволен. Подробнее — в статье об
интеграционном тестировании.
Инструменты: чем и когда
- curl / HTTPie — разведка, воспроизведение бага из баг-репорта. Команда curl в баг-репорте ценнее трёх абзацев описания.
- Postman / Insomnia / Bruno — исследовательская работа и коллекции для ручных проверок. Bruno и Hurl хранят запросы в текстовых файлах, значит их можно ревьюить в Git — большой плюс перед бинарным экспортом коллекций.
- Hurl — декларативные HTTP-тесты простым текстом, отлично подходит для смоука в CI:
POST http://localhost:8000/orders
Authorization: Bearer {{token}}
{ "sku": "BOOK-001", "quantity": 2 }
HTTP 201
[Asserts]
header "Location" matches "^/orders/[0-9a-f-]{36}$"
jsonpath "$.status" == "created"
jsonpath "$.total" == "1198.00"
duration < 1000
- Newman — прогон Postman-коллекций в CI. Работает, но коллекции плохо ревьюятся и склонны превращаться в свалку; для растущего проекта код на pytest или vitest выигрывает по поддерживаемости.
- REST Assured (Java), requests/httpx (Python), supertest (Node) — когда тесты живут в репозитории вместе с сервисом. Обычно это правильный выбор для тестов, которые пишет команда разработки.
- Schemathesis, Dredd — генерация по спецификации.
- k6, ghz — нагрузка по тому же API, см. нагрузочное тестирование.
- OWASP ZAP — автоматическое сканирование API, см. тестирование безопасности.
API-тесты в CI
Ключевое требование — окружение должно подниматься самим пайплайном, а не быть общим стендом. Общий стенд — гарантированный источник флаки: два пайплайна затирают данные друг друга.
name: api-tests
on: [pull_request]
jobs:
api:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Поднять сервис и зависимости
run: docker compose -f docker-compose.test.yml up -d --wait
# --wait ждёт healthcheck'и: без них тесты стартуют раньше сервиса
# и первый прогон падает по connection refused
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements-test.txt
- name: Смоук — сервис отвечает и схема доступна
run: |
curl --fail --retry 5 --retry-delay 2 http://localhost:8000/health
curl --fail http://localhost:8000/openapi.json -o openapi.json
- name: Функциональные API-тесты
run: pytest tests/api -n 8 --junitxml=reports/api.xml
# -n 8 (pytest-xdist) даёт параллельность; она честно работает,
# только если каждый тест создаёт свои данные
- name: Проверка соответствия спецификации
run: schemathesis run openapi.json --url http://localhost:8000 --checks all
continue-on-error: true # первый месяц — как отчёт, потом делаем блокирующим
- name: Логи сервиса при падении
if: failure()
run: docker compose -f docker-compose.test.yml logs --no-color
- uses: actions/upload-artifact@v4
if: always()
with:
name: api-reports
path: reports/
Два неочевидных, но важных шага здесь — выгрузка логов сервиса при падении
(без них разбор красного теста в CI превращается в гадание) и continue-on-error
для нового вида проверки: включать генератор сразу как блокирующий — верный
способ, чтобы команда его отключила на второй день. Подробнее про quality gates
и работу с нестабильными тестами — в статье о
тестах в CI.
Честно про боль
Флаки из-за общих данных. Тест создаёт пользователя test@example.com,
второй прогон падает на 409 Conflict. Лечение — генерировать уникальные
идентификаторы (uuid4 в email, в SKU, в имени) и не полагаться на очистку
базы между прогонами. «Почистить БД перед тестами» ломается ровно в тот день,
когда тесты запустят параллельно.
Асинхронность. POST вернул 202 Accepted, обработка идёт в фоне, тест
сразу читает результат и не находит. Лечение — polling с таймаутом и внятным
сообщением, а не sleep(3):
def wait_until(fn, timeout=10.0, interval=0.2, message="условие не выполнилось"):
"""Ждём выполнения условия, а не фиксированное время.
sleep() либо делает тест медленным, либо флаки — обычно и то и другое."""
deadline = time.monotonic() + timeout
last = None
while time.monotonic() < deadline:
last = fn()
if last:
return last
time.sleep(interval)
raise AssertionError(f"{message}; последнее значение: {last!r}")
Снапшот-тесты всего JSON-ответа. Соблазнительно: одна строка assert response == snapshot.
Через месяц любое добавление поля красит десятки тестов, ревьюер механически
запускает обновление снапшотов — и вместе с новым полем в снапшот тихо въезжает
реальный дефект. Снапшоты работают только вместе с нормализацией волатильных
полей (id, timestamps) и дисциплиной «обновление снапшота читается как изменение
контракта».
Записанные ответы внешних систем протухают. VCR-кассеты и статические заглушки — быстро и стабильно, но они замораживают представление о партнёре на дату записи. Партнёр меняет формат — ваши тесты по-прежнему зелёные, прод падает. Минимальное лечение: отдельный редкий прогон против настоящей песочницы, результат которого читают.
Ретраи в клиенте маскируют дефект. Если HTTP-клиент в тестах настроен на три
повтора, сервис, отдающий 500 в 30% случаев, выглядит здоровым. В тестовом
клиенте ретраи должны быть выключены или явно проверяться отдельным тестом.
«Покрыто 100% эндпоинтов» — самая опасная метрика этой статьи. Она считает факт вызова, а не проверенное поведение. Эндпоинт с шестью ветвями бизнес-логики, покрытый одним happy-path запросом, идёт в отчёт как «покрыт». Более честные показатели: доля эндпоинтов, у которых есть хотя бы один негативный тест; доля критичных переходов состояний, покрытых сценарием; количество дефектов, дошедших до прода через API-слой. Ни один из них не выражается одним процентом — и это нормально.
Мини-итог
- API-тест проверяет обещание перед клиентом; шесть слоёв — транспорт, статус, схема, значения, побочные эффекты, свойства во времени — покрываются осознанно, а не «докуда дотянулись».
- Форму ответа проверяйте машинно (OpenAPI + jsonschema, Schemathesis, zod, proto), смысл — руками; генератор по схеме не находит ни одного бизнес-дефекта.
- В REST главные проверки — негативные:
404на чужое,409на невалидный переход, идемпотентность повтора, границы пагинации. - В GraphQL статус всегда
200: тест обязан разбиратьerrors, а авторизация проверяется по каждому полю, а не по эндпоинту. - В gRPC контракт компилируется, поэтому тесты смещаются к кодам статусов,
дедлайнам, стримам и статической проверке совместимости
.proto. - Приоритет эндпоинтов = частота × ущерб; равномерное покрытие — равномерная трата денег.
- Общий тестовый стенд и
sleep()— два главных источника флаки в API-тестах. - «Покрыты все эндпоинты» ничего не значит; считайте наличие негативных проверок и утечки дефектов в прод.
Источники
- RFC 9110 «HTTP Semantics» — rfc-editor.org
- RFC 9457 «Problem Details for HTTP APIs» — rfc-editor.org
- OpenAPI Specification — spec.openapis.org
- Zalando RESTful API Guidelines — opensource.zalando.com
- Google API Design Guide — cloud.google.com/apis/design
- Stripe: идемпотентные запросы — docs.stripe.com
- GraphQL Specification — spec.graphql.org
- GraphQL Inspector (диффы схем в CI) — the-guild.dev
- gRPC status codes — grpc.io
- Buf: breaking change detection — buf.build
- Schemathesis — schemathesis.readthedocs.io
- Pact: consumer-driven contracts — docs.pact.io
- WireMock — wiremock.org
- Hurl — hurl.dev
- OWASP API Security Top 10 — owasp.org
Что дальше
Нагрузочное тестирование: k6, JMeter, профили нагрузки, чтение результатов — тот же API, но вопрос другой: не «правильно ли отвечает», а «сколько выдержит и что сломается первым». Разберём, как строить профиль нагрузки, чем открытая модель отличается от закрытой и почему средний时间 отклика — бесполезная метрика.