Backend проверяют на двух уровнях. Unit-тесты гоняют бизнес-логику сервиса в памяти: базу и внешние API заменяют подделками, и такие тесты идут за доли секунды. Интеграционные тесты отправляют настоящие HTTP-запросы в приложение через supertest и работают с настоящим PostgreSQL в Docker — только так проверяются SQL, ограничения базы, коды ответов и права доступа. Внешние сервисы в обоих случаях подменяют, а каждый тест начинают с чистой базы.
После материала вы сможете
- написать 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 г.
Содержание
Что тестировать в 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 и разрезанное на слои так, чтобы каждый было удобно тестировать.
Подготовка проекта
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/nodeNode.js 24 исполняет .ts напрямую, удаляя аннотации типов, поэтому сборка не нужна; tsconfig.json такой же, как в материале о фоновых задачах (в include добавьте tests и vitest.config.ts), и нужен только для npx tsc --noEmit. Встроенного node:test из урока по Express хватило бы и здесь, а supertest работает с обоими; Vitest мы берём ради проектов, globalSetup с передачей данных в тесты и встроенных моков. Unit- и интеграционные тесты разведены по двум проектам Vitest: первые запускаются без Docker и за доли секунды, вторые поднимают базу.
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
},
},
],
},
});Сервис зависит не от PostgreSQL и HTTP, а от двух интерфейсов: LeadsRepo (хранилище) и SpamCheck (проверка на спам). Ошибки предметной области — отдельные классы, чтобы HTTP-слой превратил их в правильные статусы.
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(), которая возвращает заданное значение и запоминает вызовы: по ней проверяют, вызывался ли внешний сервис и с какими аргументами.
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);
});
});it.eachпревращает таблицу «вход → ожидание» в отдельные тесты — удобно для функций вродеnormalizePhone.expect(isSpam).not.toHaveBeenCalled()проверяет порядок действий: при неверном телефоне сервис не должен тратить запрос к платному API.vi.fn().mockRejectedValue(...)имитирует упавший сервис без сети — так проверяется ветка «принять заявку, но пометить для ручной проверки».
$ 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%)Тестовая база: PostgreSQL в Docker через Testcontainers
Интеграционным тестам нужна настоящая база той же версии, что в продакшене, но своя: тесты очищают таблицы, и общая база разработки для этого не годится. Testcontainers запускает контейнер из кода теста, ждёт готовности и отдаёт строку подключения; после прогона контейнер останавливается. Нужен только работающий Docker.
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()
);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;
}
}Документация Vitest предупреждает: globalSetup выполняется в отдельной области видимости, переменные оттуда тестам не видны, данные передаются через project.provide и читаются через inject. Объявление ProvidedContext даёт inject('databaseUrl') правильный тип.
Если Testcontainers по какой-то причине недоступен (например, в CI нет доступа к Docker API, зато есть сервисный контейнер), ту же настройку можно направить на базу, запущенную вручную:
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- Изоляция между тестами:
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-тест проверить не может.
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);
},
};
}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;
},
};
}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;
}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 уходит в обработчик, а не в интернет.
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 };
}Фикстуры — данные, которые тест готовит заранее. Фабрика buildLead даёт валидную заявку с уникальным телефоном, тест переопределяет только важное ему поле. insertLead пишет фикстуру прямым SQL, а не через проверяемый код: если репозиторий сломается, упадут тесты репозитория, а не подготовка данных во всех тестах подряд.
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);
});
});Обратите внимание: первый тест проверяет не только ответ, но и строку в базе — запрос к pool после вызова API. Тест на 409 готовит фикстуру с тем же телефоном в другом формате и проверяет, что нормализация и ограничение UNIQUE работают вместе.
Ошибки и права доступа; тест, который должен упасть
Для каждого защищённого эндпоинта нужны как минимум три теста: без токена — 401, с токеном без нужной роли — 403, с правильной ролью — успех. Это дёшево и ловит самую неприятную ошибку: эндпоинт, который случайно стал публичным.
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);
});
});$ 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%)Зелёный прогон ещё ничего не доказывает: нужно убедиться, что тесты ловят поломку. Внесём правдоподобную опечатку — перепутаем код ошибки PostgreSQL:
--- 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;$ 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=1Unit-тесты эту ошибку не видят: в них нет 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.