Тестирование ПО Тестирование API: REST, GraphQL, gRPC, схемы и инструменты
0%

Тестирование API: REST, GraphQL, gRPC, схемы и инструменты

Тестирование API: REST, GraphQL, gRPC, схемы и инструменты

API-тесты — самый выгодный уровень автоматизации в типичном веб-проекте. Они на порядок быстрее и стабильнее E2E, потому что не зависят от вёрстки, анимаций и того, успел ли отрисоваться спиннер. И они гораздо ближе к бизнес-смыслу, чем юнит-тесты, потому что проверяют систему через тот же контракт, которым пользуется реальный клиент.

Именно поэтому в API-тестах чаще всего заводится специфическая болезнь: их пишут много, они зелёные, метрика «покрыто 87% эндпоинтов» растёт — а в проде регулярно всплывает то, что тесты в принципе не могли поймать. Эта статья про то, как этого избежать: что в API вообще есть предметом проверки, чем отличаются три доминирующих стиля интерфейсов и как из машинно-читаемого контракта извлечь половину тестов бесплатно.

Что вообще тестируем, когда «тестируем API»

Слово API в разговоре обозначает три разных вещи, и путаница между ними — источник бессмысленных споров.

  1. Контракт — описание того, что интерфейс обещает: OpenAPI-документ, .proto-файл, GraphQL SDL. Его можно проверять статически, без запущенной системы.
  2. Реализация за контрактом — сервис, который на запрос выдаёт ответ и меняет состояние мира. Её проверяем, отправляя запросы.
  3. Совместимость — свойство пары «контракт вчера / контракт сегодня» и пары «то, что ждёт клиент / то, что отдаёт сервер». Это отдельный класс проверок, про который забывают чаще всего.

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

Взгляд тестировщика: API — это дверь, через которую можно проверить бизнес-логику, не пробиваясь через UI. Главный вопрос: «что здесь может сломаться так, что пользователь заметит?»

Взгляд разработчика: API — это контракт, который я обязан не сломать при рефакторинге. Главный вопрос: «какие тесты дадут мне свободу менять внутренности?»

Оба взгляда нужны и дают разные наборы тестов. Разработчик почти никогда не пишет проверку «а что если прислать Content-Type: text/plain и тело в XML». Тестировщик почти никогда не пишет проверку «обратная совместимость версии v2 со старым клиентом на уровне схемы». Хорошая команда объединяет наборы, а не выбирает один.

Шесть слоёв проверки одного ответа

Ответ API — многослойная штука. Каждый слой можно проверить или не проверить, и большинство наборов тестов останавливаются на первых двух.

Шесть слоёв проверки ответа 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 /orders201 + Location возвращают 200 без Location
Чтение созданного GET /orders/{id} → те же данные ответ на создание богаче, чем на чтение
Невалидное тело лишнее поле, неверный тип → 400/422 молча игнорируют неизвестные поля
Нарушение бизнес-правила заказ на закрытый товар → 409/422 отдают 500
Отсутствие ресурса GET /orders/999999404 отдают 200 с null в теле
Чужой ресурс заказ другого пользователя → 404/403 отдают 200, это BOLA
Нет токена / протух 401 с WWW-Authenticate отдают 403 или 500
Метод не поддержан DELETE /orders405 + 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 партнёра. Три стратегии, все с ценой:

  1. Ходить в настоящую песочницу партнёра. Максимально честно, максимально нестабильно: чужая песочница падает, лимитирует запросы и меняет данные. Годится для отдельного медленного набора, запускаемого ночью, — не для PR-пайплайна.
  2. Поднимать заглушку (WireMock, Prism, MockServer, respx для Python). Быстро и стабильно. Риск: заглушка отвечает так, как вы думаете, что отвечает партнёр. Расхождение обнаружится в проде.
  3. Контрактные тесты (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-тестах.
  • «Покрыты все эндпоинты» ничего не значит; считайте наличие негативных проверок и утечки дефектов в прод.

Источники

Что дальше

Нагрузочное тестирование: k6, JMeter, профили нагрузки, чтение результатов — тот же API, но вопрос другой: не «правильно ли отвечает», а «сколько выдержит и что сломается первым». Разберём, как строить профиль нагрузки, чем открытая модель отличается от закрытой и почему средний时间 отклика — бесполезная метрика.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов