Раздел 29 · Тестирование

API и контрактное тестирование: схема как единый договор

middle-senior~50 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

API и контрактное тестирование: схема как единый договор

Сцена · договор между этажами

Фронт и бэк Kassa пишут разные люди (а часто разные агенты). Фронт ждёт, что GET /accounts/:id вернёт объект с полем balance числом. Бэк однажды решает отдавать balance строкой, “так удобнее форматировать”. Оба правы внутри себя, оба зелены в своих тестах, а на стыке всё ломается. И ловится это в худшем месте: в проде или в дорогом E2E, когда обе стороны уже уехали далеко.

Контракт это подписанный протокол обмена между этажами. Контрактный тест проверяет, что provider (сервер) отдаёт ровно то, что ждёт consumer (клиент). Расхождение ловится сразу, на дешёвом уровне, а не в продакшене. А в Effect-проекте у этого договора есть приятное свойство: схема ответа это одновременно и контракт, и валидатор, и тип.

Карта урока · что заберёшь

  • научишься тестировать HTTP-слой отдельно: статусы, коды ошибок, форма тела;
  • поймёшь, что такое contract testing и зачем он при параллельной разработке;
  • увидишь, как один Schema служит контрактом, валидатором и типом сразу;
  • напишешь consumer-driven contract и проверишь, что он краснеет раньше E2E.

Раздел 1 · HTTP-слой как предмет теста

Идея словами

API это отдельный слой со своим поведением, помимо логики домена: коды ответов (200, 201, 400, 401, 404, 422), форма тела, заголовки, авторизация. Это всё можно и нужно проверять прямо на HTTP-границе, не поднимая браузер. В уроке 17 мы уже дёргали web-handler как функцию, теперь смотрим на него под другим углом: не “записалось ли в БД”, а “правильный ли договор отдаёт сервер наружу”.

Ключевой приём: успешные тела не проверяем по полям руками, а декодируем общей схемой. Если форма ответа уедет, Schema.decodeUnknownSync упадёт сам.

Шаг 1 · где вообще живёт договор о статусах

Прежде чем проверять статусы, посмотри, откуда они берутся. В Kassa поверхность API описана декларативно через HttpApi, и код ответа там не настройка роутера, а аннотация на схеме:

// apps/kassa-api/src/api.ts
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from 'effect/http-api';

const accountsGroup = HttpApiGroup.make('accounts').add(
  HttpApiEndpoint.post('createAccount', '/accounts', {
    payload: CreateAccountRequest,
    success: AccountView.pipe(HttpApiSchema.status(201)),
  }),
  HttpApiEndpoint.get('getAccount', '/accounts/:id', {
    params: { id: AccountIdSchema },
    success: AccountView,
    error: AccountNotFound.pipe(HttpApiSchema.status(404)),
  }),
);

Читается как договор: путь, форма запроса, форма успеха, форма ошибки и код для каждой. Всё в одном объекте опций (params, payload, headers, success, error), а статус приезжает на схеме через HttpApiSchema.status(...). Когда ниже тест ждёт 201 на создание и 404 на неизвестный счёт, он проверяет ровно эти строки, а не догадки про поведение фреймворка.

Шаг 2 · статусы и коды ошибок

Покрываем матрицу ответов: успех, не найдено, бизнес-отказ, кривой ввод. Тело успеха декодируем схемой AccountView, ошибки проверяем по тегу и статусу, а не по тексту сообщения:

// apps/kassa-api/test/api-contract.test.ts
import { Schema } from 'effect';
import { AccountView } from 'kassa-contracts';

it('создание счёта это 201 и валидный по схеме AccountView', async () => {
  const { handler } = makeWebHandler(TestServicesLive);

  const res = await post(handler, '/accounts', { openingBalance: 250 });

  expect(res.status).toBe(201);
  const account = Schema.decodeUnknownSync(AccountView)(await res.json());
  expect(account.balance).toBe(250);
});

it('неизвестный счёт это 404 AccountNotFound', async () => {
  const { handler } = makeWebHandler(TestServicesLive);

  const res = await get(handler, '/accounts/acc_missing');

  expect(res.status).toBe(404);
  expect(await res.json()).toMatchObject({ _tag: 'AccountNotFound' });
});

it('платёж сверх остатка это 422 insufficient-funds', async () => {
  const { handler } = makeWebHandler(TestServicesLive);
  const created = await post(handler, '/accounts', { openingBalance: 100 });
  const account = Schema.decodeUnknownSync(AccountView)(await created.json());

  const res = await post(handler, `/accounts/${account.id}/payments`, { amount: 150 });

  expect(res.status).toBe(422);
  expect(await res.json()).toMatchObject({ _tag: 'PaymentRejected', reason: 'insufficient-funds' });
});

Ошибку проверяем по _tag, а не по строке сообщения, по той же причине, что в уроке 06: текст это копирайт, он меняется, тег это контракт. Бизнес-отказ (перерасход) это 422, а не 400: запрос валиден по форме, но отвергнут по правилу домена.

Шаг 3 · 400 ловит схема запроса

Кривой ввод (отрицательная сумма) не должен доходить до домена: его отбивает схема запроса на входе, это 400:

// apps/kassa-api/test/api-contract.test.ts
it('отрицательная сумма не проходит схему запроса (400)', async () => {
  const { handler } = makeWebHandler(TestServicesLive);
  const created = await post(handler, '/accounts', { openingBalance: 100 });
  const account = Schema.decodeUnknownSync(AccountView)(await created.json());

  const res = await post(handler, `/accounts/${account.id}/payments`, { amount: -5 });

  expect(res.status).toBe(400);
});

Разница важна: 400 это “ты прислал мусор, схема не пропустила”, 422 это “форма верна, но правило домена против”. Сервер обязан различать, и тест это фиксирует.

Что взять с собой

  • API-слой проверяется на HTTP-границе: статусы, коды, форма тела, авторизация;
  • успешное тело декодируй схемой, а не сверяй поля руками;
  • ошибку проверяй по тегу, а не по тексту; 400 (форма) и 422 (правило) это разное.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.