Юнит-тесты на сервере пишут не ради галочки в пайплайне. Каждая правка расчёта скидок, валидации заказа или статуса платежа без автопроверки - это ставка на внимательность человека в конце дня, а внимательность к вечеру заканчивается быстрее, чем кажется. Связка Mocha и Chai закрывает базовую потребность бэкенда: прогнать за секунды сотни проверок и назвать виновника по имени. Ниже - разбор связки от установки до непрерывной интеграции, с рабочими фрагментами под Node.
Mocha ведёт расписание тестов а Chai выносит приговор по каждому шагу
У пары чёткое разделение труда. Mocha отвечает за расписание: находит файлы тестов, строит из них дерево блоков describe и проверок it, выполняет по порядку, ловит отклонения по времени и печатает отчёт. Chai отвечает за приговор: сравнивает ожидание с фактом и выбрасывает исключение, когда факт не совпал. Раннер и библиотека утверждений живут раздельно, поэтому Mocha всё равно, что именно ругает тест: expect из Chai, нативный assert из Node или самописный помощник.
Версии на октябрь 2026 года стоят такие. Mocha двенадцатой мажорной линии, вышедшей в конце августа 2026-го и добравшейся до 12.0.3, требует Node 20.19 или 22.12 и новее; одиннадцатая мажорная версия, появившаяся в конце 2024 года, держала планку Node 18.18. Chai идёт шестой мажорной версией и поставляется чистым ES-модулем, перелом случился в пятой версии. Установка одной командой:
npm install --save-dev mocha chai
Первый тест читается как проза:
import { expect } from 'chai';
import { priceWithDiscount } from '../src/pricing.js';
describe('расчёт цены со скидкой', () => {
it('считает процент от суммы', () => {
expect(priceWithDiscount(1000, 15)).to.equal(850);
});
it('отклоняет скидку больше ста процентов', () => {
expect(() => priceWithDiscount(1000, 150)).to.throw();
});
});
Утверждения Chai цепляются словами: to, equal, deep, above, have.property. Цепочка читается слева направо как английское предложение, и провал печатает разницу между ожиданием и фактом, а не голое слово assert.
Chai поставляется тремя интерфейсами: assert в духе классических библиотек, expect в стиле разработки через поведение и should, который навешивает свойство на каждый объект через прототип. Последний красиво смотрится в коде, но трогать прототипы ради читаемости команды перестали, и expect стал стандартом по умолчанию.
Границу unit каждая команда проводит сама: кто-то проверяет чистую функцию расчёта, кто-то модуль целиком с заглушками зависимостей. Важна не буква определения, а изоляция: проверяемая единица не тянет за собой весь продакшн.
Каркас из describe блоков и вложенность без хаоса в именовании
Имя теста читается как фраза из трёх частей: субъект, условие, ожидание. Внешний describe('хранилище заказов') называет субъект, вложенный describe('когда корзина пуста') - условие, it('возвращает ошибку валидации') - ожидание. В отчёте строки склеиваются в законченные предложения, и по одному логу ясно, какой сценарий погиб. Плоская груда из двухсот проверок без группировки даёт обратный эффект: страница красного текста, из которой непонятно, что именно сломалось.
describe('хранилище заказов', () => {
describe('когда корзина пуста', () => {
it('отклоняет оформление с кодом EMPTY', () => {
expect(validate([]).code).to.equal('EMPTY');
});
});
describe('когда корзина заполнена', () => {
it('сохраняет заказ и возвращает id', () => {
expect(save([{ sku: 'a1', qty: 2 }])).to.be.a('number');
});
});
});
Для точечного запуска служит фильтр --grep: mocha --grep корзина прогоняет только сценарии с этим словом в имени, что экономит минуты при отладке одного модуля. С ростом набора имена начинают работать документацией: новый инженер читает сценарии и понимает контракт модуля раньше, чем откроет реализацию. Папку тестов держат отдельной, например test/, а файлы называют по маске вида *.spec.js, чтобы конфигурация раннера собирала их одним шаблоном. Один файл - одна подсистема, один тест - одно поведение; проверка, которая пробует сразу пять сценариев, при падении не скажет, какой из пяти умер.
Хуки before и after для состояния которое нельзя пересоздавать
Жизненный цикл закрывают четыре хука: before выполняется один раз перед блоком, beforeEach - перед каждым тестом, afterEach и after - зеркально после. Классическая схема для базы: before открывает соединение с тестовым хранилищем, beforeEach сеет свежие данные в чистой транзакции, afterEach откатывает транзакцию, after закрывает соединение. Хуки вешают на ближайший уровень вложенности, где состояние действительно общее: соединение открывают на внешнем describe, чистые данные готовят на внутреннем. Быстрые наборы строят на транзакциях: beforeEach открывает транзакцию, afterEach откатывает её, и восстановление длится миллисекунды при полной идентичности данных от теста к тесту.
describe('база заказов', () => {
let store;
before(async () => {
store = await createTestStore();
});
beforeEach(async () => {
await store.reset();
await store.seed({ orders: 3 });
});
after(async () => {
await store.close();
});
it('находит заказ по id', async () => {
const order = await store.findById(1);
expect(order.items).to.equal(3);
});
});
Анти-паттерн выглядит тихо: один тест создаёт запись, следующий рассчитывает, что она существует, и набор зелёный ровно до запуска одиночного теста из середины. Правило одно: проверка обязана проходить и в одиночку, и в толпе. Для приготовлений, общих всему набору, существуют корневые хуки, их подключают флагом --require и хранят отдельным файлом.
Асинхронные проверки и лимит времени в две секунды по умолчанию
Асинхронность в Mocha решена без церемоний. Тест, объявленный как async-функция, ждёт промис автоматически: отклонённый промис красит тест, а не подвешивает набор. Второй путь - колбэк done, доставшийся от старых времён и живущий в легаси; смешивать оба в одной проверке нельзя, раннер честно выбросит ошибку о двойном завершении.
Лимит времени по умолчанию - две тысячи миллисекунд на тест. Для тяжёлых операций его поднимают локально через this.timeout или глобально флагом --timeout. Число подбирают по реальному времени отклика, а не по ощущениям: проверка, которая иногда укладывается в 1,9 секунды, будет мигать красным раз в неделю и обесценит доверие ко всему набору. Рядом с жёстким лимитом живёт this.slow: порог, после которого отчёт подсвечивает проверку как медленную. Заметить, что экспорт дорос до трёх секунд из пяти, полезнее, чем слушать жалобы на медленный пайплайн.
describe('экспорт отчёта', () => {
it('укладывается в расширенный лимит', async function () {
this.timeout(8000);
const file = await exportReport({ format: 'csv' });
expect(file.rows).to.be.above(0);
});
});
Обычная функция здесь не случайность: стрелочная функция не имеет своего this, и this.timeout в ней недоступен. Там, где лимит поднимают, пишут function, остальной набор остаётся на стрелках.
Проверка HTTP эндпоинтов через supertest без открытых портов
Проверять эндпоинты можно и поднятым сервером на порту, но это уже интеграционный тест с лишними движениями: занять порт, убить процесс, гонять трафик через петлевой интерфейс. Библиотека supertest версии 7.3 работает иначе: она берёт объект приложения из Express или Fastify и вызывает обработчик напрямую в памяти. Ни порта, ни сети, ни утечки процессов.
import request from 'supertest';
import { app } from '../src/app.js';
describe('GET /api/health', () => {
it('отвечает статусом 200', async () => {
const res = await request(app).get('/api/health');
expect(res.status).to.equal(200);
expect(res.body).to.have.property('status', 'ok');
});
});
POST-сценарии устроены так же прямолинейно:
const res = await request(app)
.post('/api/orders')
.set('Authorization', 'Bearer test-token')
.send({ sku: 'a1', qty: 2 });
expect(res.status).to.equal(201);
Ожидания через запятую читаются как список: статус, тело, заголовок. Успешное создание проверяют статусом 201, ошибку валидации - статусом 400 и текстом причины в ответе. Приложение при таком подходе получает тестовую конфигурацию через переменные окружения: лёгкая база в памяти, отключённые внешние клиенты, детерминированное время. Близкий по духу пакет chai-http 5.1 выполняет ту же работу методами поверх Chai, и выбор между ними - вопрос вкуса команды, а не производительности.
Заглушки sinon для внешних сервисов и честная изоляция проверки
Unit-тест по определению проверяет одну единицу. Если модуль оформления заказа ходит в платёжный шлюз, проверка не обязана ходить туда же: шлюз заменяют заглушкой. Sinon версии 22 подменяет выбранную функцию заранее подготовленным ответом, и тест сфокусирован на логике модуля, а не на настроении внешней системы.
import sinon from 'sinon';
import { checkout } from '../src/checkout.js';
import * as gateway from '../src/gateway.js';
describe('оформление заказа', () => {
beforeEach(() => {
sinon.stub(gateway, 'charge').resolves({ ok: true });
});
afterEach(() => {
sinon.restore();
});
it('проставляет статус оплаты', async () => {
const result = await checkout({ total: 4200 });
expect(result.paid).to.equal(true);
});
});
Восстановление в afterEach обязательно: без него заглушка протечёт в соседние проверки и превратит набор в лотерею. Шпион spy не подменяет поведение, а запоминает вызовы, им проверяют, что кэш действительно обратился к функции один раз, а не на каждом запросе. Отдельный талант Синона - поддельные таймеры: sinon.useFakeTimers замораживает часы, проверка дедлайна не спит реальных тридцать секунд, а переводит стрелки одной командой clock.tick(30000), после чего таймеры возвращает тот же sinon.restore.
Конфигурация запуск и отчёты для непрерывной интеграции
Конфигурация живёт в файле .mocharc.yml и читается раннером без флагов:
spec: 'test/**/*.spec.js'
timeout: 4000
reporter: spec
Скрипты в package.json запускают локальный и серверный режимы:
{
"scripts": {
"test": "mocha",
"test:ci": "mocha --forbid-only --reporter dot"
}
}
Репортёры меняются одним флагом: spec рисует дерево сценариев, dot экономит экран сотнями точек, json отдаёт машиночитаемый результат для разбора сервером сборки, mochawesome собирает html-страницу для тех, кто отчёты читает глазами. Флаг --forbid-only страхует от классической аварии: разработчик оставил it.only в одном тесте, пайплайн позеленел, а половина набора не выполнилась вовсе. Режим --parallel с настраиваемым числом воркеров ускоряет большие наборы, но требует, чтобы общее состояние между проверками исчезло. Флаг --check-leaks ловит случайные глобальные переменные, --bail останавливает прогон на первом падении, что удобно локально и вредно в пайплайне. Код выхода раннер возвращает честный: ноль при зелёном наборе, единица при любом падении, и этого достаточно серверу сборки. Покрытие считают утилитой c8, которая понимает ES-модули; старшая ветка nyc осталась в эпохе CommonJS.
Альтернатива встроенная: у Node есть собственный раннер node:test, и для нового маленького сервиса он снижает число зависимостей до нуля. Mocha при этом остаётся выбором команд, которым нужны вложенные сценарии, богатые отчёты и два десятилетия экосистемы вокруг. Для репозитория приживается короткий свод правил:
- зафиксировать порог таймаута в общем конфиге, а не в голове отдельных проверок;
- включить forbid-only в серверном скрипте, чтобы одинокий it.only не отключил половину набора;
- перейти на parallel только после того, как исчезло общее состояние между тестами;
- считать покрытие через c8 и держать пороги в отдельном скрипте запуска;
- возвращать код выхода раннера из npm-скрипта без обёрток, чтобы пайплайн падал честно.
Набор тестов, собранный по этим правилам, живёт годами: новая проверка пишется за минуты, падение называется по имени, а ночные сюрпризы превращаются в короткие красные строки утреннего отчёта. Ценность не в количестве проверок, а в предсказуемости: когда зелёная сборка действительно что-то гарантирует, релизы перестают быть лотереей, и бэкенд спокойно переживает любые пятничные правки.