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

Интеграционные тесты: сколько настоящего в проверке

middle~50 мин

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

Интеграционные тесты: сколько настоящего в проверке

Сцена · сколько настоящего в тесте

В прошлом уроке E2E проверял всё целиком через браузер. Это честно, но медленно: поднять браузер, отрисовать страницу, кликнуть, дождаться. А ведь большая часть вопроса “работает ли сервер” вообще не про браузер. Платёж пишется в базу? Баланс перечитывается верно? Чужой токен отбивается? Это про сервер и БД, и браузер тут лишний посредник.

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

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

  • поймёшь, что отличает уровни по числу настоящих зависимостей в игре;
  • научишься выбирать уровень деревом решений: браузер, БД или чистая логика;
  • увидишь, что интеграция часто даёт ту же уверенность дешевле E2E;
  • напишешь интеграционный тест POST /payments против реального SQL.

Раздел 1 · граница unit, integration, E2E

Идея словами

Уровни различаются не размером, а тем, сколько настоящих зависимостей участвует в проверке.

  • юнит: всё подменено или его нет вовсе. Чистая функция, никакой сети, БД, времени. Микросекунды.
  • интеграционный: одна или несколько настоящих зависимостей (обычно БД), но без браузера. Проверяет шов между твоим кодом и внешней системой.
  • E2E: настоящее всё, включая браузер и сеть до UI.

Граница плавающая, и спор “это юнит или интеграция” обычно бесплоден. Полезнее спрашивать иначе: что в этом тесте настоящее, а что подменено. Тест applyPayment настоящий только в домене, БД нет вообще, это юнит. Тест POST /payments поднимает настоящий SQL, это интеграция. Тест “кликнул в браузере” поднимает всё, это E2E.

Картинка · дерево выбора уровня

Для каждой проверки спрашивай сверху вниз:

нужен ли реальный браузер и UI?
   ├─ да  ────────────────────────────▶ E2E (урок 16)
   └─ нет
       нужна ли реальная БД / HTTP / внешняя система?
          ├─ да  ─────────────────────▶ интеграционный
          └─ нет (чистая логика) ──────▶ юнит (урок 12)

Правило простое: бери самый дешёвый уровень, на котором проверка ещё осмысленна. Если для проверки баланса браузер не нужен, не плати за браузер. Если для проверки applyPayment не нужна БД, не поднимай БД.

Шаг 1 · одна проверка, три уровня, три цены

Возьми проверку “после платежа 30 при остатке 100 баланс равен 70”. Её можно поставить на любом уровне, но цена разная:

юнит        applyPayment(makeAccount({balance:100}), makePayment(30))   ~0.01 мс
интеграция  POST /accounts/:id/payments {amount:30}, читаем из БД        ~5 мс
E2E         клик "оплатить 30" в браузере, читаем DOM                    ~2000 мс

Все три зелёные проверяют разное: юнит, что верна арифметика домена; интеграция, что платёж дошёл до БД и перечитался; E2E, что это видно клиенту. Для арифметики хватит юнита. Для “дошло до БД” нужна интеграция. Гонять арифметику через E2E это платить в сто тысяч раз дороже за тот же факт.

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

  • уровень это про число настоящих зависимостей, а не про размер;
  • спрашивай: что тут настоящее, а что подменено;
  • бери самый дешёвый уровень, на котором проверка ещё осмысленна.

Раздел 2 · интеграция дешевле E2E

Идея словами

Многое из того, что хочется проверить E2E, на деле про сервер и БД, а не про браузер. “Платёж пишется в базу и баланс пересчитан”, “две оплаты подряд дают верный накопленный баланс”, “чужой токен отбивается” это серверные факты. Проверять их кликами в браузере дорого и хрупко: упасть может из-за вёрстки, анимации, тайминга, хотя сервер цел.

Интеграционный тест бьёт прямо в HTTP-слой сервера. В Kassa сервер собран на HTTP-модулях самого effect (effect/http и effect/http-api) как web-handler: функция из Request в Response. Её можно дёргать прямо, без порта и сети.

Шаг 1 · тонкий HTTP-клиент без сети

Не нужен ни supertest, ни поднятый сервер: собираем Request, читаем Response. Помощник на пару строк:

// apps/kassa-api/test/support/client.ts
type Handler = (request: Request) => Promise<Response>;

const url = (path: string): string => `http://kassa${path}`;

export const post = (handler: Handler, path: string, body: unknown): Promise<Response> =>
  handler(
    new Request(url(path), {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(body),
    }),
  );

export const get = (handler: Handler, path: string): Promise<Response> =>
  handler(new Request(url(path)));

supertest делает то же самое поверх Express/Fastify, поднимая эфемерный порт. Здесь порт не нужен: web-handler это просто функция. Меньше движущихся частей, быстрее прогон.

Шаг 2 · платёж против реального SQL

Теперь интеграционный тест: создаём счёт, проводим платёж, перечитываем счёт из БД и проверяем, что баланс и история сохранены. SQL настоящий (SQLite в памяти), браузера нет:

// apps/kassa-api/test/payments.integration.test.ts
import { Schema } from 'effect';
import { AccountView, PaymentResult } from 'kassa-contracts';
import { describe, expect, it } from 'vitest';

import { makeWebHandler } from '~/web-handler.ts';
import { SqlTestServicesLive } from '~/layers/live.ts';

import { get, post } from './support/client.ts';

describe('POST /accounts/:id/payments (реальный SQL)', () => {
  it('проводит платёж, пишет в БД и пересчитывает баланс', async () => {
    const { handler } = makeWebHandler(SqlTestServicesLive());

    const created = await post(handler, '/accounts', { openingBalance: 100 });
    const account = Schema.decodeUnknownSync(AccountView)(await created.json());

    const paid = await post(handler, `/accounts/${account.id}/payments`, { amount: 30 });
    const result = Schema.decodeUnknownSync(PaymentResult)(await paid.json());
    expect(result.balance).toBe(70);

    // перечитываем из БД: баланс и история сохранены
    const fetched = await get(handler, `/accounts/${account.id}`);
    const reread = Schema.decodeUnknownSync(AccountView)(await fetched.json());
    expect(reread.balance).toBe(70);
    expect(reread.history).toHaveLength(1);
  });
});

Ключевой момент проверки в том, что счёт перечитывается отдельным запросом. Юнит проверил бы арифметику в памяти. Интеграция проверяет, что платёж и правда долетел до базы и достаётся обратно тем же. Это шов “код и БД”, который юнит не видит, а E2E проверяет в десятки раз дороже. Ответы декодируем общей схемой из contracts, мост на контрактные тесты урока 20.

Шаг 3 · где Effect удобен: изоляция по построению

Помнишь гонку на shared-данных из урока 08? Здесь её нет, и не потому, что мы аккуратны. Каждый makeWebHandler(SqlTestServicesLive()) строит свежую in-memory БД:

const { handler } = makeWebHandler(SqlTestServicesLive());

Состояние это сервис в слое, у каждого теста свой слой, поэтому тесты изолированы по построению, а не дисциплиной чистки таблиц. Это та самая строка из §3a: параллельные тесты не делят глобальное состояние, потому что у каждого свой Layer. Честный Postgres из testcontainers подключается тем же приёмом, только ресурс живёт в Scope, а it.effect закрывает его сам.

Шаг 4 · почему база переживает три запроса подряд

Тут прячется тонкость, на которой легко обжечься. Тест выше делает три запроса подряд к одному handler и ждёт, что счёт, созданный первым запросом, найдётся третьим. Значит in-memory база обязана быть одна на весь тест, а не новая на каждый запрос. За это отвечает ровно одна строка в сборке:

// apps/kassa-api/src/web-handler.ts
export const makeWebHandler = (services: Layer.Layer<AccountRepo | Sessions | Rates | Ids>) =>
  HttpRouter.toWebHandler(
    ApiLive.pipe(HttpRouter.provideRequest(services), Layer.provide(HttpServer.layerServices)),
  );

Требования хендлеров в роутере запросные: каждый обработчик просит сервисы у контекста запроса, а не у контекста сборки. HttpRouter.provideRequest строит слой один раз при сборке роутера и подставляет его контекст в каждый входящий запрос. Поэтому :memory: создаётся однажды на handler и живёт весь сценарий. Отдай те же сервисы обычным Layer.provide, и типы разъедутся: хендлеры останутся неудовлетворёнными, а toWebHandler начнёт требовать контекст вторым аргументом. Правило простое: сервисы приложения идут через provideRequest, платформенные зависимости роутера (файловая система, пути, Etag) через HttpServer.layerServices.

Когда код пишет агент

Агент, которого просят “протестируй эндпоинт”, часто мокает репозиторий и проверяет, что хендлер его вызвал. Получается тест, который проверяет мок, а не поведение: SQL не выполнялся, и сломанный запрос пройдёт зелёным. Интеграционный тест с настоящей БД ловит как раз то, что мок прячет: кривой SQL, забытую миграцию, рассинхрон схемы и кода.

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

  • серверные факты (запись в БД, накопление, авторизация) дешевле интеграцией, чем E2E;
  • web-handler дёргается как функция, без порта и браузера;
  • свежий слой на тест даёт изоляцию по построению, без чистки таблиц;
  • сервисы хендлерам отдаёт HttpRouter.provideRequest, поэтому база одна на весь сценарий из нескольких запросов;
  • настоящая БД ловит то, что замоканный репозиторий прячет.

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

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

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