Как делать11 октября 2026 г.6 мин чтения
Backend на Node.jsШаг 7 из 8

Тестирование backend: unit- и интеграционные тесты API

Коротко

Backend проверяют на двух уровнях. Unit-тесты гоняют бизнес-логику сервиса в памяти: базу и внешние API заменяют подделками, и такие тесты идут за доли секунды. Интеграционные тесты отправляют настоящие HTTP-запросы в приложение через supertest и работают с настоящим PostgreSQL в Docker — только так проверяются SQL, ограничения базы, коды ответов и права доступа. Внешние сервисы в обоих случаях подменяют, а каждый тест начинают с чистой базы.

Пошаговый урокУровень: СреднийТестирование backendПрактика: ~1,5–3 ч

После материала вы сможете

  • написать unit-тест сервиса, подменив репозиторий подделкой, а внешний API — vi.fn()
  • написать интеграционный тест эндпоинта через supertest с настоящим PostgreSQL 17 в Docker через Testcontainers
  • изолировать тесты друг от друга: очистка таблиц, фабрики фикстур, последовательный запуск файлов с общей базой
  • подменить внешний HTTP-сервис через MSW и проверить ответы 400, 401, 403, 404, 409 и 422

Проверено на

Node.js 24 LTS (24.18)Vitest 5.0supertest 7.3MSW 3.0Testcontainers 12.2pg 8.23Express 5.3PostgreSQL 17 (postgres:17-alpine)TypeScript 7.0

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Что тестировать в backend
  2. 02Подготовка проекта
  3. 03Unit-тест сервиса: подмена зависимостей
  4. 04Тестовая база: PostgreSQL в Docker через Testcontainers
  5. 05Интеграционный тест эндпоинта: supertest, фикстуры и мок внешнего API
  6. 06Ошибки и права доступа; тест, который должен упасть
  7. 07То же на Java: JUnit 5 и MockMvc
  8. 08Упражнения

Что тестировать в backend

Общая идея уровней тестирования разобрана в материале о пирамиде тестирования, а Vitest как инструмент — в уроке по тестированию фронтенда. В уроке по REST API на Express вы уже написали первые тесты через supertest и node:test на хранилище в памяти. Здесь следующий шаг: разделение на unit и интеграционные тесты, настоящий PostgreSQL и подмена внешних API. Что специфично для backend: у него нет экрана, его поведение — это контракт (какой запрос даёт какой ответ), данные в базе и реакция на ошибки.

Бизнес-правила: нормализация телефона, решение «спам или нет»

Уровень
Unit
Пример из урока
createLeadsService с подделкой репозитория

Контракт HTTP: статусы, тело ответа, заголовки

Уровень
Интеграционный
Пример из урока
POST /api/leads → 201 и JSON заявки

SQL и ограничения базы: UNIQUE, NOT NULL, типы

Уровень
Интеграционный с реальной БД
Пример из урока
повторный телефон → 409, а не 500

Ошибки и права доступа

Уровень
Интеграционный
Пример из урока
401 без токена, 403 для роли viewer, 404

Поведение при сбое чужого API

Уровень
Оба, с моком сети
Пример из урока
антиспам-сервис ответил 503 — заявка всё равно принята

Практика урока — API заявок: POST /api/leads принимает заявку, проверяет телефон во внешнем антиспам-сервисе и сохраняет её в PostgreSQL; GET /api/leads доступен менеджеру, GET /api/leads/:id — ещё и наблюдателю. Это API заявок из упражнения к уроку по Express, только с PostgreSQL и разрезанное на слои так, чтобы каждый было удобно тестировать.

Подготовка проекта

bash
npm init -y && npm pkg set type=module
npm install express pg
npm install -D vitest supertest msw @testcontainers/postgresql typescript \
  @types/express @types/pg @types/supertest @types/node
Зависимости: приложение — express и pg, тесты — Vitest, supertest, MSW и Testcontainers

Node.js 24 исполняет .ts напрямую, удаляя аннотации типов, поэтому сборка не нужна; tsconfig.json такой же, как в материале о фоновых задачах (в include добавьте tests и vitest.config.ts), и нужен только для npx tsc --noEmit. Встроенного node:test из урока по Express хватило бы и здесь, а supertest работает с обоими; Vitest мы берём ради проектов, globalSetup с передачей данных в тесты и встроенных моков. Unit- и интеграционные тесты разведены по двум проектам Vitest: первые запускаются без Docker и за доли секунды, вторые поднимают базу.

vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    projects: [
      {
        test: {
          name: 'unit',
          include: ['tests/unit/**/*.test.ts'],
        },
      },
      {
        test: {
          name: 'integration',
          include: ['tests/integration/**/*.test.ts'],
          globalSetup: ['tests/integration/global-setup.ts'],
          fileParallelism: false, // the files share one database: run them one by one
          hookTimeout: 60_000, // pulling the image on the first run takes time
        },
      },
    ],
  },
});
Два проекта Vitest: unit — без внешних зависимостей, integration — с базой и последовательным запуском файлов

Сервис зависит не от PostgreSQL и HTTP, а от двух интерфейсов: LeadsRepo (хранилище) и SpamCheck (проверка на спам). Ошибки предметной области — отдельные классы, чтобы HTTP-слой превратил их в правильные статусы.

src/leads.ts
export type Lead = {
  id: number;
  name: string;
  phone: string;
  spamChecked: boolean;
  createdAt: Date;
};

export type NewLead = Pick<Lead, 'name' | 'phone' | 'spamChecked'>;

// Dependencies are interfaces: the service doesn't know about PostgreSQL or HTTP.
export interface LeadsRepo {
  insert(lead: NewLead): Promise<Lead>;
  findById(id: number): Promise<Lead | null>;
  list(): Promise<Lead[]>;
}

export interface SpamCheck {
  isSpam(phone: string): Promise<boolean>;
}

export class ValidationError extends Error {}
export class SpamError extends Error {}
export class ConflictError extends Error {}

// "+7 701 123 45 67", "8 (701) 123-45-67" -> "+77011234567"; anything else -> null
export function normalizePhone(input: string): string | null {
  const digits = input.replace(/\D/g, '');
  if (digits.length !== 11 || !/^[78]/.test(digits)) return null;
  return `+7${digits.slice(1)}`;
}

export function createLeadsService(deps: { repo: LeadsRepo; spam: SpamCheck }) {
  return {
    async create(input: { name?: unknown; phone?: unknown }): Promise<Lead> {
      const name = typeof input.name === 'string' ? input.name.trim() : '';
      const phone = typeof input.phone === 'string' ? normalizePhone(input.phone) : null;
      if (!name) throw new ValidationError('name is required');
      if (!phone) throw new ValidationError('phone must be a Kazakhstan number');

      let spamChecked = true;
      try {
        if (await deps.spam.isSpam(phone)) throw new SpamError('lead rejected as spam');
      } catch (err) {
        if (err instanceof SpamError) throw err;
        spamChecked = false; // the checker is down: accept the lead, flag it for review
      }
      return deps.repo.insert({ name, phone, spamChecked });
    },
    get: (id: number) => deps.repo.findById(id),
    list: () => deps.repo.list(),
  };
}

export type LeadsService = ReturnType<typeof createLeadsService>;
Сервис заявок: валидация, нормализация телефона, решение по спаму; зависимости приходят параметром

Unit-тест сервиса: подмена зависимостей

Подменять зависимость можно двумя способами. Подделка (fake) — упрощённая рабочая реализация: репозиторий на массиве ведёт себя как настоящий, только без базы. Мок (mock) — функция-шпион vi.fn(), которая возвращает заданное значение и запоминает вызовы: по ней проверяют, вызывался ли внешний сервис и с какими аргументами.

tests/unit/leads-service.test.ts
import { describe, expect, it, vi } from 'vitest';
import {
  createLeadsService,
  normalizePhone,
  SpamError,
  ValidationError,
  type Lead,
  type LeadsRepo,
} from '../../src/leads.ts';

// A fake repository: real behavior, but in memory instead of PostgreSQL
function memoryRepo(): LeadsRepo & { rows: Lead[] } {
  const rows: Lead[] = [];
  return {
    rows,
    async insert(lead) {
      const saved = { ...lead, id: rows.length + 1, createdAt: new Date('2026-10-11T09:00:00Z') };
      rows.push(saved);
      return saved;
    },
    async findById(id) {
      return rows.find((r) => r.id === id) ?? null;
    },
    async list() {
      return rows;
    },
  };
}

function setup(isSpam = vi.fn(async (_phone: string) => false)) {
  const repo = memoryRepo();
  const service = createLeadsService({ repo, spam: { isSpam } });
  return { repo, service, isSpam };
}

describe('normalizePhone', () => {
  it.each([
    ['+7 701 123 45 67', '+77011234567'],
    ['8 (701) 123-45-67', '+77011234567'],
    ['77011234567', '+77011234567'],
    ['12345', null],
    ['+1 202 555 0143', null],
  ])('%s -> %s', (input, expected) => {
    expect(normalizePhone(input)).toBe(expected);
  });
});

describe('createLeadsService.create', () => {
  it('saves a lead with a normalized phone', async () => {
    const { service, repo, isSpam } = setup();

    const lead = await service.create({ name: ' Aigerim ', phone: '8 (701) 123-45-67' });

    expect(lead).toMatchObject({ name: 'Aigerim', phone: '+77011234567', spamChecked: true });
    expect(isSpam).toHaveBeenCalledWith('+77011234567');
    expect(repo.rows).toHaveLength(1);
  });

  it('rejects an invalid phone before calling anything external', async () => {
    const { service, repo, isSpam } = setup();

    await expect(service.create({ name: 'Aigerim', phone: '123' })).rejects.toThrow(ValidationError);
    expect(isSpam).not.toHaveBeenCalled();
    expect(repo.rows).toHaveLength(0);
  });

  it('rejects spam and saves nothing', async () => {
    const { service, repo } = setup(vi.fn(async () => true));

    await expect(service.create({ name: 'Bot', phone: '+77011234567' })).rejects.toThrow(SpamError);
    expect(repo.rows).toHaveLength(0);
  });

  it('accepts the lead when the spam checker is down, but flags it', async () => {
    const { service } = setup(vi.fn().mockRejectedValue(new Error('HTTP 503')));

    const lead = await service.create({ name: 'Aigerim', phone: '+77011234567' });

    expect(lead.spamChecked).toBe(false);
  });
});
Unit-тесты: таблица случаев для normalizePhone, подделка репозитория и vi.fn вместо антиспам-сервиса
  • it.each превращает таблицу «вход → ожидание» в отдельные тесты — удобно для функций вроде normalizePhone.
  • expect(isSpam).not.toHaveBeenCalled() проверяет порядок действий: при неверном телефоне сервис не должен тратить запрос к платному API.
  • vi.fn().mockRejectedValue(...) имитирует упавший сервис без сети — так проверяется ветка «принять заявку, но пометить для ручной проверки».
terminal
$ npx vitest run --project unit

 RUN  v5.0.3 …/leads-api


 Test Files  1 passed (1)
      Tests  9 passed (9)
   Start at  17:36:28
   Duration  202ms (transform 52%, import 29%, tests 12%, worker 8%)
Реальный вывод: 9 unit-тестов за 202 мс — без Docker и сети

Тестовая база: PostgreSQL в Docker через Testcontainers

Интеграционным тестам нужна настоящая база той же версии, что в продакшене, но своя: тесты очищают таблицы, и общая база разработки для этого не годится. Testcontainers запускает контейнер из кода теста, ждёт готовности и отдаёт строку подключения; после прогона контейнер останавливается. Нужен только работающий Docker.

db/schema.sql
CREATE TABLE leads (
  id           integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name         text        NOT NULL,
  phone        text        NOT NULL UNIQUE,
  spam_checked boolean     NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now()
);
Схема таблицы: телефон уникален — это правило проверит только реальная база
tests/integration/global-setup.ts
import { readFile } from 'node:fs/promises';
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import pg from 'pg';
import type { TestProject } from 'vitest/node';

let container: StartedPostgreSqlContainer | undefined;

export async function setup(project: TestProject) {
  // Either a database you started yourself, or a fresh container for this run
  let url = process.env.TEST_DATABASE_URL;
  if (!url) {
    container = await new PostgreSqlContainer('postgres:17-alpine').start();
    url = container.getConnectionUri();
  }
  // The schema is recreated from scratch: never point this at a real database
  if (!new URL(url).pathname.includes('test')) {
    throw new Error(`refusing to reset a non-test database: ${new URL(url).pathname}`);
  }

  const client = new pg.Client({ connectionString: url });
  await client.connect();
  await client.query('DROP TABLE IF EXISTS leads');
  await client.query(await readFile('db/schema.sql', 'utf8'));
  await client.end();

  project.provide('databaseUrl', url);
}

export async function teardown() {
  await container?.stop();
}

declare module 'vitest' {
  export interface ProvidedContext {
    databaseUrl: string;
  }
}
globalSetup запускается один раз до всех тестов: контейнер, схема и передача адреса базы через provide

Документация Vitest предупреждает: globalSetup выполняется в отдельной области видимости, переменные оттуда тестам не видны, данные передаются через project.provide и читаются через inject. Объявление ProvidedContext даёт inject('databaseUrl') правильный тип.

Если Testcontainers по какой-то причине недоступен (например, в CI нет доступа к Docker API, зато есть сервисный контейнер), ту же настройку можно направить на базу, запущенную вручную:

bash
docker run -d --rm --name kb-pg-leads-test -e POSTGRES_PASSWORD=test \
  -e POSTGRES_DB=leads_test -p 55437:5432 postgres:17-alpine
TEST_DATABASE_URL=postgres://postgres:test@localhost:55437/leads_test npx vitest run --project integration
Альтернатива: база из docker run и адрес через TEST_DATABASE_URL
  • Изоляция между тестами: TRUNCATE leads RESTART IDENTITY перед каждым тестом — таблица пуста, счётчик id снова с 1. Тест не должен зависеть от того, что оставил предыдущий.
  • Изоляция между файлами: файлы Vitest по умолчанию идут параллельно, а база одна. Мы проверили: с fileParallelism: true в четырёх прогонах из четырёх один тест падал — соседний файл очищал таблицу посреди чужого теста. Поэтому в проекте integration файлы идут по очереди; альтернатива — своя база на каждый воркер.
  • Защита от ошибки: setup пересоздаёт таблицу и отказывается работать, если в имени базы нет test. Случайно направленный на рабочую базу TEST_DATABASE_URL даст ошибку «refusing to reset a non-test database», а не потерю данных.

Интеграционный тест эндпоинта: supertest, фикстуры и мок внешнего API

Репозиторий на pg превращает нарушение уникальности (код PostgreSQL 23505) в ConflictError, а HTTP-слой — в статус 409. Именно эту цепочку unit-тест проверить не может.

src/pg-leads-repo.ts
import type { Pool } from 'pg';
import { ConflictError, type Lead, type LeadsRepo } from './leads.ts';

type Row = { id: number; name: string; phone: string; spam_checked: boolean; created_at: Date };

const toLead = (r: Row): Lead => ({
  id: r.id,
  name: r.name,
  phone: r.phone,
  spamChecked: r.spam_checked,
  createdAt: r.created_at,
});

export function pgLeadsRepo(pool: Pool): LeadsRepo {
  return {
    async insert(lead) {
      try {
        const { rows } = await pool.query<Row>(
          'INSERT INTO leads (name, phone, spam_checked) VALUES ($1, $2, $3) RETURNING *',
          [lead.name, lead.phone, lead.spamChecked],
        );
        return toLead(rows[0]);
      } catch (err) {
        // 23505 = unique_violation: a lead with this phone already exists
        if ((err as { code?: string }).code === '23505') {
          throw new ConflictError('lead with this phone already exists');
        }
        throw err;
      }
    },
    async findById(id) {
      const { rows } = await pool.query<Row>('SELECT * FROM leads WHERE id = $1', [id]);
      return rows[0] ? toLead(rows[0]) : null;
    },
    async list() {
      const { rows } = await pool.query<Row>('SELECT * FROM leads ORDER BY id');
      return rows.map(toLead);
    },
  };
}
Репозиторий на node-postgres: параметризованные запросы и перевод кода 23505 в ConflictError
src/http-spam-check.ts
import type { SpamCheck } from './leads.ts';

// A client for an external anti-spam service (a third-party HTTP API).
export function httpSpamCheck(baseUrl: string, apiKey: string): SpamCheck {
  return {
    async isSpam(phone) {
      const res = await fetch(`${baseUrl}/v1/check`, {
        method: 'POST',
        headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
        body: JSON.stringify({ phone }),
        signal: AbortSignal.timeout(3000),
      });
      if (!res.ok) throw new Error(`spam check failed: HTTP ${res.status}`);
      const body = (await res.json()) as { spam: boolean };
      return body.spam;
    },
  };
}
Клиент внешнего антиспам-API — его в тестах подменит MSW
src/app.ts
import express, { type NextFunction, type Request, type Response } from 'express';
import { ConflictError, SpamError, ValidationError, type LeadsService } from './leads.ts';

export type Role = 'manager' | 'viewer';

export function createApp(deps: { leads: LeadsService; tokens: Record<string, Role> }) {
  const app = express();
  app.use(express.json());

  // Bearer token -> role. No token: 401, wrong role: 403.
  const allow = (...roles: Role[]) => (req: Request, res: Response, next: NextFunction) => {
    const token = req.get('authorization')?.replace(/^Bearer /, '');
    const role = token ? deps.tokens[token] : undefined;
    if (!role) return void res.status(401).json({ error: 'unauthorized' });
    if (!roles.includes(role)) return void res.status(403).json({ error: 'forbidden' });
    next();
  };

  app.post('/api/leads', async (req, res) => {
    const lead = await deps.leads.create(req.body ?? {});
    res.status(201).json(lead);
  });

  app.get('/api/leads', allow('manager'), async (_req, res) => {
    res.json(await deps.leads.list());
  });

  app.get('/api/leads/:id', allow('manager', 'viewer'), async (req, res) => {
    const id = Number(req.params.id);
    const lead = Number.isInteger(id) ? await deps.leads.get(id) : null;
    if (!lead) return void res.status(404).json({ error: 'not found' });
    res.json(lead);
  });

  // Express 5 forwards rejected promises from async handlers here.
  app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
    if (err instanceof ValidationError) return void res.status(400).json({ error: err.message });
    if (err instanceof SpamError) return void res.status(422).json({ error: err.message });
    if (err instanceof ConflictError) return void res.status(409).json({ error: err.message });
    console.error(err);
    res.status(500).json({ error: 'internal error' });
  });

  return app;
}
createApp собирает Express-приложение из зависимостей и не вызывает listen
src/server.ts
import pg from 'pg';
import { createApp } from './app.ts';
import { httpSpamCheck } from './http-spam-check.ts';
import { createLeadsService } from './leads.ts';
import { pgLeadsRepo } from './pg-leads-repo.ts';

const env = (name: string) => {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is not set`);
  return value;
};

const pool = new pg.Pool({ connectionString: env('DATABASE_URL') });
const leads = createLeadsService({
  repo: pgLeadsRepo(pool),
  spam: httpSpamCheck(env('SPAM_API_URL'), env('SPAM_API_KEY')),
});
const app = createApp({ leads, tokens: { [env('MANAGER_TOKEN')]: 'manager' } });

app.listen(3000, () => console.log('http://localhost:3000'));
Боевая сборка: те же функции с настоящими зависимостями из переменных окружения

supertest принимает приложение и, как сказано в его документации, сам поднимает его на свободном временном порту — следить за портами не нужно. Внешний антиспам-сервис подменяем на уровне сети через MSW: настоящий fetch из http-spam-check.ts уходит в обработчик, а не в интернет.

tests/integration/test-app.ts
import { http, HttpResponse } from 'msw/http';
import { setupServer } from 'msw/node';
import pg from 'pg';
import { afterAll, afterEach, beforeAll, beforeEach, inject } from 'vitest';
import { createApp, type Role } from '../../src/app.ts';
import { httpSpamCheck } from '../../src/http-spam-check.ts';
import { createLeadsService, type NewLead } from '../../src/leads.ts';
import { pgLeadsRepo } from '../../src/pg-leads-repo.ts';

export const SPAM_API = 'https://spam-check.example';

// The external service is mocked at the network level; by default it says "not spam"
export const spamApi = setupServer(
  http.post(`${SPAM_API}/v1/check`, () => HttpResponse.json({ spam: false })),
);

export function useTestApp() {
  const pool = new pg.Pool({ connectionString: inject('databaseUrl') });
  const tokens: Record<string, Role> = { 'manager-token': 'manager', 'viewer-token': 'viewer' };
  const leads = createLeadsService({
    repo: pgLeadsRepo(pool),
    spam: httpSpamCheck(SPAM_API, 'test-key'),
  });
  const app = createApp({ leads, tokens });

  beforeAll(() =>
    spamApi.listen({
      onUnhandledFrame({ frame, defaults }) {
        // MSW also sees supertest's calls to our own app on 127.0.0.1: let them through
        if (frame.protocol === 'http') {
          const { request } = frame.data as { request: Request };
          if (new URL(request.url).hostname === '127.0.0.1') return;
        }
        defaults.error(); // any other unexpected outgoing request is an error
      },
    }),
  );
  beforeEach(() => pool.query('TRUNCATE leads RESTART IDENTITY')); // every test starts empty
  afterEach(() => spamApi.resetHandlers());
  afterAll(async () => {
    spamApi.close();
    await pool.end();
  });

  return { app, pool };
}

// Factory: valid data by default; a test overrides only what it cares about
let seq = 0;
export function buildLead(overrides: Partial<NewLead> = {}): NewLead {
  seq += 1;
  return {
    name: `Lead ${seq}`,
    phone: `+770100${String(seq).padStart(5, '0')}`,
    spamChecked: true,
    ...overrides,
  };
}

// Fixtures go straight to SQL so they don't depend on the code under test
export async function insertLead(pool: pg.Pool, overrides: Partial<NewLead> = {}) {
  const lead = buildLead(overrides);
  const { rows } = await pool.query<{ id: number }>(
    'INSERT INTO leads (name, phone, spam_checked) VALUES ($1, $2, $3) RETURNING id',
    [lead.name, lead.phone, lead.spamChecked],
  );
  return { ...lead, id: rows[0].id };
}
Общая обвязка интеграционных тестов: пул к тестовой базе, приложение, мок внешнего API, очистка и фабрика данных

Фикстуры — данные, которые тест готовит заранее. Фабрика buildLead даёт валидную заявку с уникальным телефоном, тест переопределяет только важное ему поле. insertLead пишет фикстуру прямым SQL, а не через проверяемый код: если репозиторий сломается, упадут тесты репозитория, а не подготовка данных во всех тестах подряд.

tests/integration/leads.api.test.ts
import { http, HttpResponse } from 'msw/http';
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { insertLead, SPAM_API, spamApi, useTestApp } from './test-app.ts';

const { app, pool } = useTestApp();

describe('POST /api/leads', () => {
  it('creates a lead and stores it in PostgreSQL', async () => {
    const res = await request(app)
      .post('/api/leads')
      .send({ name: 'Aigerim', phone: '8 (701) 123-45-67' })
      .expect(201);

    expect(res.body).toMatchObject({ id: 1, phone: '+77011234567', spamChecked: true });
    const { rows } = await pool.query('SELECT name, phone FROM leads');
    expect(rows).toEqual([{ name: 'Aigerim', phone: '+77011234567' }]);
  });

  it('returns 400 for an invalid phone and writes nothing', async () => {
    const res = await request(app).post('/api/leads').send({ name: 'Aigerim', phone: '123' }).expect(400);

    expect(res.body.error).toMatch(/phone/);
    const { rows } = await pool.query('SELECT count(*)::int AS n FROM leads');
    expect(rows[0].n).toBe(0);
  });

  it('returns 409 when the phone is already taken (UNIQUE in the database)', async () => {
    await insertLead(pool, { phone: '+77011234567' });

    await request(app)
      .post('/api/leads')
      .send({ name: 'Aigerim', phone: '+7 701 123 45 67' })
      .expect(409);
  });

  it('returns 422 when the spam service flags the lead', async () => {
    spamApi.use(http.post(`${SPAM_API}/v1/check`, () => HttpResponse.json({ spam: true })));

    await request(app).post('/api/leads').send({ name: 'Bot', phone: '+77011234567' }).expect(422);
  });

  it('accepts the lead when the spam service is down', async () => {
    spamApi.use(http.post(`${SPAM_API}/v1/check`, () => new HttpResponse(null, { status: 503 })));

    const res = await request(app)
      .post('/api/leads')
      .send({ name: 'Aigerim', phone: '+77011234567' })
      .expect(201);

    expect(res.body.spamChecked).toBe(false);
  });
});
Тесты POST /api/leads: запись в БД, 400, 409 от UNIQUE, 422 и 503 от внешнего сервиса

Обратите внимание: первый тест проверяет не только ответ, но и строку в базе — запрос к pool после вызова API. Тест на 409 готовит фикстуру с тем же телефоном в другом формате и проверяет, что нормализация и ограничение UNIQUE работают вместе.

Ошибки и права доступа; тест, который должен упасть

Для каждого защищённого эндпоинта нужны как минимум три теста: без токена — 401, с токеном без нужной роли — 403, с правильной ролью — успех. Это дёшево и ловит самую неприятную ошибку: эндпоинт, который случайно стал публичным.

tests/integration/access.api.test.ts
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { insertLead, useTestApp } from './test-app.ts';

const { app, pool } = useTestApp();

describe('GET /api/leads (access rules)', () => {
  it('returns 401 without a token', async () => {
    await request(app).get('/api/leads').expect(401);
  });

  it('returns 403 for a viewer', async () => {
    await request(app).get('/api/leads').set('Authorization', 'Bearer viewer-token').expect(403);
  });

  it('returns all leads to a manager, oldest first', async () => {
    await insertLead(pool, { name: 'First' });
    await insertLead(pool, { name: 'Second' });

    const res = await request(app)
      .get('/api/leads')
      .set('Authorization', 'Bearer manager-token')
      .expect(200);

    expect(res.body.map((l: { name: string }) => l.name)).toEqual(['First', 'Second']);
  });
});

describe('GET /api/leads/:id', () => {
  it('lets a viewer read one lead', async () => {
    const lead = await insertLead(pool);

    const res = await request(app)
      .get(`/api/leads/${lead.id}`)
      .set('Authorization', 'Bearer viewer-token')
      .expect(200);

    expect(res.body.phone).toBe(lead.phone);
  });

  it('returns 404 for a missing lead', async () => {
    await request(app).get('/api/leads/999').set('Authorization', 'Bearer manager-token').expect(404);
  });
});
Права доступа: 401, 403, 200 для менеджера, чтение одной заявки наблюдателем и 404
terminal
$ npx vitest run --reporter=verbose

 RUN  v5.0.3 …/leads-api

 ✓ |unit| tests/unit/leads-service.test.ts > normalizePhone > +7 701 123 45 67 -> +77011234567 1ms
 ✓ |unit| tests/unit/leads-service.test.ts > normalizePhone > 8 (701) 123-45-67 -> +77011234567 0ms
 ✓ |unit| tests/unit/leads-service.test.ts > normalizePhone > 77011234567 -> +77011234567 0ms
 ✓ |unit| tests/unit/leads-service.test.ts > normalizePhone > 12345 -> null 0ms
 ✓ |unit| tests/unit/leads-service.test.ts > normalizePhone > +1 202 555 0143 -> null 0ms
 ✓ |unit| tests/unit/leads-service.test.ts > createLeadsService.create > saves a lead with a normalized phone 2ms
 ✓ |unit| tests/unit/leads-service.test.ts > createLeadsService.create > rejects an invalid phone before calling anything external 1ms
 ✓ |unit| tests/unit/leads-service.test.ts > createLeadsService.create > rejects spam and saves nothing 0ms
 ✓ |unit| tests/unit/leads-service.test.ts > createLeadsService.create > accepts the lead when the spam checker is down, but flags it 0ms
 ✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > creates a lead and stores it in PostgreSQL 78ms
 ✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > returns 400 for an invalid phone and writes nothing 10ms
 ✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > returns 409 when the phone is already taken (UNIQUE in the database) 15ms
 ✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > returns 422 when the spam service flags the lead 20ms
 ✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > accepts the lead when the spam service is down 12ms
 ✓ |integration| tests/integration/access.api.test.ts > GET /api/leads (access rules) > returns 401 without a token 50ms
 ✓ |integration| tests/integration/access.api.test.ts > GET /api/leads (access rules) > returns 403 for a viewer 9ms
 ✓ |integration| tests/integration/access.api.test.ts > GET /api/leads (access rules) > returns all leads to a manager, oldest first 16ms
 ✓ |integration| tests/integration/access.api.test.ts > GET /api/leads/:id > lets a viewer read one lead 11ms
 ✓ |integration| tests/integration/access.api.test.ts > GET /api/leads/:id > returns 404 for a missing lead 8ms

 Test Files  3 passed (3)
      Tests  19 passed (19)
   Start at  17:34:54
   Duration  4.47s (import 55%, tests 26%, transform 17%, worker 2%)
Реальный вывод: 19 тестов в двух проектах; общий прогон с запуском контейнера занял около 6 секунд

Зелёный прогон ещё ничего не доказывает: нужно убедиться, что тесты ловят поломку. Внесём правдоподобную опечатку — перепутаем код ошибки PostgreSQL:

src/pg-leads-repo.ts
--- a/src/pg-leads-repo.ts
+++ b/src/pg-leads-repo.ts
@@ -22,7 +22,7 @@ export function pgLeadsRepo(pool: Pool): LeadsRepo {
         return toLead(rows[0]);
       } catch (err) {
         // 23505 = unique_violation: a lead with this phone already exists
-        if ((err as { code?: string }).code === '23505') {
+        if ((err as { code?: string }).code === '23503') {
           throw new ConflictError('lead with this phone already exists');
         }
         throw err;
Намеренная ошибка: 23503 — это foreign_key_violation, а не нарушение уникальности
terminal
$ npx vitest run --project integration; echo "exit=$?"

 RUN  v5.0.3 …/leads-api

stderr | tests/integration/leads.api.test.ts > POST /api/leads > returns 409 when the phone is already taken (UNIQUE in the database)
error: duplicate key value violates unique constraint "leads_phone_key"
  …
  code: '23505',
  detail: 'Key (phone)=(+77011234567) already exists.',
  …

 ❯ |integration| tests/integration/leads.api.test.ts (5 tests | 1 failed) 155ms
   ❯ POST /api/leads (5)
     × returns 409 when the phone is already taken (UNIQUE in the database) 16ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  |integration| tests/integration/leads.api.test.ts > POST /api/leads > returns 409 when the phone is already taken (UNIQUE in the database)
Error: expected 409 "Conflict", got 500 "Internal Server Error"
 ❯ tests/integration/leads.api.test.ts:34:8
     32|       .post('/api/leads')
     33|       .send({ name: 'Aigerim', phone: '+7 701 123 45 67' })
     34|       .expect(409);
       |        ^
     35|   });
     36|
 …

 Test Files  1 failed | 1 passed (2)
      Tests  1 failed | 9 passed (10)
exit=1
Реальный вывод (сокращён на месте …): упал ровно один тест, код выхода 1 — CI остановит сборку

Unit-тесты эту ошибку не видят: в них нет PostgreSQL и нет кода 23505. Интеграционный тест показывает и причину (в stderr — исходная ошибка базы с code: '23505'), и последствие для клиента: 500 вместо 409. После отката опечатки все 19 тестов снова зелёные.

То же на Java: JUnit 5 и MockMvc

Подход не зависит от языка. В Spring Boot (документация сверена для версии 4.1) те же уровни выглядят так:

Unit-тест сервиса

Node.js (этот урок)
Vitest, подделка или vi.fn()
Spring Boot
JUnit 5 + Mockito

HTTP-слой без поднятия сервера

Node.js (этот урок)
supertest + createApp
Spring Boot
@WebMvcTest + MockMvc, зависимости контроллера — @MockitoBean

Полное приложение

Node.js (этот урок)
supertest + настоящие зависимости
Spring Boot
@SpringBootTest + @AutoConfigureMockMvc

Реальная база в контейнере

Node.js (этот урок)
Testcontainers в globalSetup
Spring Boot
Testcontainers + @ServiceConnection — Spring сам берёт параметры подключения из контейнера

Срез @WebMvcTest по документации поднимает только MVC-инфраструктуру и контроллеры — обычные @Component-бины не сканируются, поэтому сервисы подменяют через @MockitoBean. Это аналог нашей передачи зависимостей в createApp.

Упражнения

Как проверено4 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    src/leads.ts, src/pg-leads-repo.ts, src/http-spam-check.ts, src/app.ts, src/server.ts, db/schema.sql, vitest.config.ts, tests/unit/leads-service.test.ts, tests/integration/{global-setup,test-app,leads.api.test,access.api.test}.ts

    временный проект: express 5.3.0, pg 8.23.1, vitest 5.0.3, supertest 7.3.1, msw 3.0.3, @testcontainers/postgresql 12.2.0, typescript 7.0.2, Node 24.18; tsc -p . без ошибок; vitest run — 19/19 (unit 9 за 202 мс, integration 10); PostgreSQL 17.11 из postgres:17-alpine через Testcontainers, полный прогон ~6,3 с

  • запущено, работает

    намеренно упавший тест (diff 23505 → 23503)

    vitest run --project integration: 1 failed | 9 passed, 'expected 409 "Conflict", got 500', exit=1; после отката снова 19/19

  • запущено, работает

    альтернатива TEST_DATABASE_URL + docker run

    docker run --rm kb-pg-testirovanie-backenda (postgres:17-alpine, POSTGRES_DB=leads_test, порт 55437) → 10/10 integration за 1,5 с; с базой /postgres setup бросает 'refusing to reset a non-test database'. В статье имя контейнера kb-pg-leads-test — косметическое отличие

  • запущено, работает

    утверждения о поведении (проверены экспериментом)

    onUnhandledFrame: 'error' без пропуска 127.0.0.1 — все 5 тестов leads.api упали (MSW перехватывает запросы supertest); fileParallelism: true — 4 прогона из 4 с одним упавшим тестом; упражнение repeat (без .trim()) — падает ровно 1 unit-тест

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

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