Интеграционные тесты на JavaScript

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

Типичная проверяемая цепочка:

HTTP-запрос → Express → контроллер → сервис → репозиторий → БД

В примерах используются JavaScript с ES Modules, Node.js, Express, Vitest, Supertest, PostgreSQL и Testcontainers. TypeScript не требуется.

Содержание


Интеграционные тесты и 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/postgresql

package.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, безопаснее запускать интеграционный набор последовательно.


Реальная БД или моки

Реальная БД проверяет:

Моки полезны, когда нужно:

const repository = {
  findByEmail: async () => null,
  create: async (data) => ({ id: 1, ...data }),
};

Практичная стратегия:

Мок не способен подтвердить правильность 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

Для важного маршрута обычно проверяют:

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-ответ и фактическое состояние базы данных.