Интеграционные тесты на JavaScript
Интеграционные тесты проверяют совместную работу нескольких частей приложения: HTTP-маршрутов, middleware, контроллеров, сервисов, репозиториев и базы данных.
Типичная проверяемая цепочка:
HTTP-запрос → Express → контроллер → сервис → репозиторий → БДВ примерах используются JavaScript с ES Modules, Node.js, Express, Vitest, Supertest, PostgreSQL и Testcontainers. TypeScript не требуется.
Содержание
- Интеграционные тесты и unit-тесты
- Установка
- Подготовка Express-приложения
- Supertest — тестирование HTTP API
- Репозиторий PostgreSQL
- Testcontainers — настоящая БД в тестах
- Полный тест: Supertest и Testcontainers
- Настройка тестового окружения
- Сиды, фикстуры и фабрики
- Очистка и изоляция БД
- Реальная БД или моки
- Рекомендуемая структура
- Что проверять в API
- Частые ошибки
- Итог
Интеграционные тесты и unit-тесты
Unit-тест изолированно проверяет небольшую единицу поведения: функцию, метод или модуль. Внешние зависимости обычно заменяются моками или стабами.
export function calculateDiscount(total) {
return total >= 10_000 ? total * 0.1 : 0;
}import { expect, it } from 'vitest';
import { calculateDiscount } from './calculateDiscount.js';
it('рассчитывает скидку 10%', () => {
expect(calculateDiscount(12_000)).toBe(1_200);
});Интеграционный тест проверяет взаимодействие реальных компонентов. Например, запрос на создание пользователя может пройти через Express, валидацию и репозиторий, а затем записать строку в PostgreSQL.
| Характеристика | Unit-тест | Интеграционный тест |
|---|---|---|
| Объём | Один модуль | Несколько компонентов |
| Зависимости | Часто заменены | Часто реальные |
| Скорость | Высокая | Ниже |
| База данных | Обычно мок | Реальная или тестовая |
| Реалистичность | Ограниченная | Выше |
Оба вида тестов нужны одновременно: unit-тесты быстро проверяют бизнес-логику, а интеграционные обнаруживают ошибки на границах модулей.
Установка
npm install express pg dotenv
npm install --save-dev vitest supertest testcontainers @testcontainers/postgresqlpackage.json:
{
"type": "module",
"scripts": {
"start": "node src/server.js",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run tests/integration"
}
}Поле "type": "module" включает синтаксис import и export в .js-файлах.
vitest.config.js:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['tests/**/*.test.js'],
testTimeout: 30_000,
hookTimeout: 60_000,
clearMocks: true,
restoreMocks: true,
},
});Увеличенный hookTimeout полезен, потому что первый запуск контейнера может потребовать загрузки Docker-образа.
Подготовка Express-приложения
Создание приложения следует отделять от запуска HTTP-сервера. Иначе импорт модуля в тесте сразу займёт сетевой порт.
src/app.js:
import express from 'express';
export function createApp({ userRepository }) {
const app = express();
app.use(express.json());
app.post('/api/users', async (req, res, next) => {
try {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({
error: 'Поля name и email обязательны',
});
}
const existingUser = await userRepository.findByEmail(email);
if (existingUser) {
return res.status(409).json({
error: 'Пользователь уже существует',
});
}
const user = await userRepository.create({ name, email });
return res.status(201).json(user);
} catch (error) {
next(error);
}
});
app.get('/api/users/:id', async (req, res, next) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'Некорректный id' });
}
const user = await userRepository.findById(id);
if (!user) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
return res.json(user);
} catch (error) {
next(error);
}
});
app.use((error, req, res, next) => {
console.error(error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
});
return app;
}src/server.js:
import 'dotenv/config';
import { Pool } from 'pg';
import { createApp } from './app.js';
import { createUserRepository } from './repositories/userRepository.js';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const app = createApp({
userRepository: createUserRepository(pool),
});
const port = Number(process.env.PORT) || 3000;
const server = app.listen(port, () => {
console.log(`Сервер запущен на порту ${port}`);
});
async function shutdown() {
server.close(async () => {
await pool.end();
process.exit(0);
});
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);В тесте используется createApp(), а listen() не вызывается. Supertest сам передаёт запрос Express-приложению.
Supertest — тестирование HTTP API
Supertest позволяет отправлять тестовые HTTP-запросы и проверять статус, заголовки и тело ответа.
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { createApp } from '../../src/app.js';
const repository = {
findByEmail: async () => null,
create: async (data) => ({ id: 1, ...data }),
findById: async () => null,
};
const app = createApp({ userRepository: repository });
describe('POST /api/users', () => {
it('создаёт пользователя', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Анна', email: 'anna@example.com' });
expect(response.status).toBe(201);
expect(response.headers['content-type']).toMatch(/json/);
expect(response.body).toEqual({
id: 1,
name: 'Анна',
email: 'anna@example.com',
});
});
});В этом примере проверяется интеграция Express, JSON middleware и маршрута, но БД ещё заменена тестовым репозиторием.
Основные операции
Проверка статуса:
await request(app)
.get('/api/users/999')
.expect(404);Query-параметры:
const response = await request(app)
.get('/api/users')
.query({ page: 2, limit: 20 });Заголовок авторизации:
const response = await request(app)
.get('/api/profile')
.set('Authorization', `Bearer ${accessToken}`);Сохранение cookies между запросами:
const agent = request.agent(app);
await agent
.post('/api/login')
.send({ email: 'admin@example.com', password: 'secret' })
.expect(200);
await agent.get('/api/profile').expect(200);Динамические поля лучше проверять матчерами:
expect(response.body).toMatchObject({
name: 'Анна',
email: 'anna@example.com',
});
expect(response.body.id).toEqual(expect.any(Number));
expect(response.body.createdAt).toEqual(expect.any(String));Репозиторий PostgreSQL
src/repositories/userRepository.js:
export function createUserRepository(pool) {
return {
async create({ name, email }) {
const result = await pool.query(
`INSERT INTO users (name, email)
VALUES ($1, $2)
RETURNING id, name, email, created_at AS "createdAt"`,
[name, email],
);
return result.rows[0];
},
async findByEmail(email) {
const result = await pool.query(
`SELECT id, name, email, created_at AS "createdAt"
FROM users WHERE email = $1`,
[email],
);
return result.rows[0] ?? null;
},
async findById(id) {
const result = await pool.query(
`SELECT id, name, email, created_at AS "createdAt"
FROM users WHERE id = $1`,
[id],
);
return result.rows[0] ?? null;
},
};
}Параметры $1 и $2 передают значения отдельно от SQL и защищают запрос от SQL-инъекций.
Testcontainers — настоящая БД в тестах
Testcontainers запускает временные Docker-контейнеры из тестового кода. Это позволяет тестировать приложение с настоящей PostgreSQL без подключения к рабочей базе.
import { PostgreSqlContainer } from '@testcontainers/postgresql';
const container = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('app_test')
.withUsername('test')
.withPassword('test')
.start();
console.log(container.getConnectionUri());
await container.stop();Для работы нужен доступ к Docker или совместимому контейнерному runtime. Обычно контейнер запускают один раз на файл или набор тестов, а не перед каждым it().
Миграции
Тестовая база должна создаваться теми же миграциями, что и рабочая.
migrations/001-create-users.sql:
CREATE TABLE IF NOT EXISTS users (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);src/database/runMigrations.js:
import { readFile } from 'node:fs/promises';
export async function runMigrations(pool) {
const sql = await readFile(
new URL('../../migrations/001-create-users.sql', import.meta.url),
'utf8',
);
await pool.query(sql);
}В большом проекте вместо ручного чтения SQL следует вызывать используемый проектом миграционный инструмент.
Полный тест: Supertest и Testcontainers
tests/integration/users.test.js:
import request from 'supertest';
import { Pool } from 'pg';
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
} from 'vitest';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { createApp } from '../../src/app.js';
import { runMigrations } from '../../src/database/runMigrations.js';
import { createUserRepository } from '../../src/repositories/userRepository.js';
let container;
let pool;
let app;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('app_test')
.withUsername('test')
.withPassword('test')
.start();
pool = new Pool({
connectionString: container.getConnectionUri(),
});
await runMigrations(pool);
app = createApp({
userRepository: createUserRepository(pool),
});
});
beforeEach(async () => {
await pool.query('TRUNCATE TABLE users RESTART IDENTITY CASCADE');
});
afterAll(async () => {
await pool?.end();
await container?.stop();
});
describe('Users API', () => {
it('создаёт пользователя и сохраняет его в БД', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Анна', email: 'anna@example.com' });
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: 1,
name: 'Анна',
email: 'anna@example.com',
});
const result = await pool.query(
'SELECT id, name, email FROM users WHERE email = $1',
['anna@example.com'],
);
expect(result.rows).toEqual([{
id: 1,
name: 'Анна',
email: 'anna@example.com',
}]);
});
it('возвращает 400 для неполных данных', async () => {
const response = await request(app)
.post('/api/users')
.send({ name: 'Анна' });
expect(response.status).toBe(400);
const result = await pool.query('SELECT COUNT(*) FROM users');
expect(Number(result.rows[0].count)).toBe(0);
});
it('возвращает 409 для повторяющегося email', async () => {
await pool.query(
'INSERT INTO users (name, email) VALUES ($1, $2)',
['Первый', 'same@example.com'],
);
const response = await request(app)
.post('/api/users')
.send({ name: 'Второй', email: 'same@example.com' });
expect(response.status).toBe(409);
expect(response.body).toEqual({
error: 'Пользователь уже существует',
});
});
it('возвращает существующего пользователя', async () => {
const inserted = await pool.query(
`INSERT INTO users (name, email)
VALUES ($1, $2) RETURNING id`,
['Иван', 'ivan@example.com'],
);
const response = await request(app)
.get(`/api/users/${inserted.rows[0].id}`);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
name: 'Иван',
email: 'ivan@example.com',
});
});
});Проверка HTTP-ответа подтверждает внешний контракт API, а прямой запрос к БД — фактический побочный эффект. Двойная проверка особенно полезна для ключевых операций записи.
Настройка тестового окружения
Тесты не должны обращаться к production-базе или реальным внешним сервисам.
.env.test:
NODE_ENV=test
LOG_LEVEL=silent
JWT_SECRET=test-secret
EMAIL_PROVIDER=fakeАдрес PostgreSQL при использовании Testcontainers берётся у контейнера:
const databaseUrl = container.getConnectionUri();Полезна дополнительная защита:
export function assertTestEnvironment() {
if (process.env.NODE_ENV !== 'test') {
throw new Error('Тестовая БД доступна только в NODE_ENV=test');
}
}После тестов необходимо закрывать пул и контейнер:
afterAll(async () => {
await pool.end();
await container.stop();
});Незакрытые подключения оставляют активные дескрипторы и могут помешать Vitest завершить процесс.
Сиды, фикстуры и фабрики
Фикстура — заранее подготовленный объект:
export const validUser = {
name: 'Анна',
email: 'anna@example.com',
};Фабрика создаёт объект и позволяет переопределить отдельные поля:
let sequence = 0;
export function buildUser(overrides = {}) {
sequence += 1;
return {
name: `Пользователь ${sequence}`,
email: `user-${sequence}@example.com`,
...overrides,
};
}Фабрика с записью в БД:
export async function insertUser(pool, overrides = {}) {
const user = buildUser(overrides);
const result = await pool.query(
`INSERT INTO users (name, email)
VALUES ($1, $2)
RETURNING id, name, email`,
[user.name, user.email],
);
return result.rows[0];
}Сиды создают общие системные данные, например роли или справочники:
export async function seedRoles(pool) {
await pool.query(`
INSERT INTO roles (name)
VALUES ('user'), ('admin')
ON CONFLICT (name) DO NOTHING
`);
}Не стоит загружать большой общий сид, если тесту нужны две записи. Минимальные данные упрощают чтение и отладку сценария.
Очистка и изоляция БД
Простой способ — очищать таблицы перед каждым тестом:
beforeEach(async () => {
await pool.query(
'TRUNCATE TABLE orders, users RESTART IDENTITY CASCADE',
);
});TRUNCATE создаёт предсказуемое состояние и сбрасывает счётчики идентификаторов.
Другой подход — транзакция с откатом:
beforeEach(async () => {
await client.query('BEGIN');
});
afterEach(async () => {
await client.query('ROLLBACK');
});Он работает только тогда, когда приложение использует то же подключение. Если приложение берёт соединения из пула, транзакция теста не изолирует их автоматически.
Для параллельных тестов можно выдавать каждому worker отдельную схему, базу или контейнер. Если используется общая БД и TRUNCATE, безопаснее запускать интеграционный набор последовательно.
Реальная БД или моки
Реальная БД проверяет:
- SQL-запросы;
- миграции;
- ограничения
UNIQUE,NOT NULLиFOREIGN KEY; - транзакции;
- преобразование типов;
- поведение конкретной СУБД.
Моки полезны, когда нужно:
- быстро проверить бизнес-логику;
- смоелировать редкую ошибку;
- проверить сервис отдельно от инфраструктуры;
- избежать тяжёлого окружения в unit-тесте.
const repository = {
findByEmail: async () => null,
create: async (data) => ({ id: 1, ...data }),
};Практичная стратегия:
- чистые функции — unit-тесты;
- сервисы — unit-тесты с моками репозиториев;
- репозитории — интеграционные тесты с настоящей БД;
- HTTP API — Supertest с настоящей БД;
- критические пользовательские сценарии — E2E-тесты.
Мок не способен подтвердить правильность SQL и миграций. Реальная БД, в свою очередь, не обязательна для каждой ветви чистой бизнес-логики.
Рекомендуемая структура
project/
├── migrations/
│ └── 001-create-users.sql
├── src/
│ ├── app.js
│ ├── server.js
│ ├── database/
│ │ └── runMigrations.js
│ ├── repositories/
│ │ └── userRepository.js
│ └── services/
│ └── usersService.js
├── tests/
│ ├── factories/
│ │ └── userFactory.js
│ ├── fixtures/
│ │ └── users.js
│ ├── unit/
│ │ └── usersService.test.js
│ └── integration/
│ ├── userRepository.test.js
│ └── usersApi.test.js
├── package.json
└── vitest.config.jsЧто проверять в API
Для важного маршрута обычно проверяют:
- успешный ответ;
- ошибку валидации
400; - отсутствие аутентификации
401; - недостаток прав
403; - отсутствие ресурса
404; - конфликт данных
409; - внутреннюю ошибку
500; - изменение данных в БД;
- отсутствие секретных полей в ответе.
expect(response.body).not.toHaveProperty('password');
expect(response.body).not.toHaveProperty('passwordHash');Тестировать следует наблюдаемое поведение API, а не внутреннее количество вызовов функций.
Частые ошибки
Запуск сервера при импорте
Не вызывайте listen() в модуле, который импортируется тестом. Экспортируйте createApp() отдельно.
Подключение к production
Не запускайте миграции, TRUNCATE или тестовые сиды против рабочей БД.
Зависимость от порядка тестов
Каждый тест должен самостоятельно создавать нужное состояние и не рассчитывать на результат предыдущего теста.
Мокирование всей цепочки
Если замокированы роутер, контроллер, сервис и репозиторий, тест почти не проверяет интеграцию.
Проверка только успешного пути
Проверяйте валидацию, конфликты, отсутствие данных, права и ошибки инфраструктуры.
Произвольные задержки
Не используйте setTimeout() для ожидания результата. Ожидайте HTTP-ответ, Promise или конкретное изменение состояния.
Незакрытые ресурсы
Закрывайте пул, сервер, Redis-клиент, очереди и контейнеры в afterAll().
Итог
Supertest проверяет HTTP-контракт Express-приложения без ручного запуска сервера. Testcontainers поднимает временную настоящую БД, благодаря чему тесты обнаруживают ошибки в SQL, миграциях и ограничениях схемы.
Надёжный набор тестов сочетает быстрые unit-тесты с моками и более реалистичные интеграционные тесты с настоящей инфраструктурой. Для ключевых API-сценариев полезно проверять одновременно HTTP-ответ и фактическое состояние базы данных.