Юнит-тесты на сервере пишут не ради галочки в пайплайне. Каждая правка расчёта скидок, валидации заказа или статуса платежа без автопроверки - это ставка на внимательность человека в конце дня, а внимательность к вечеру заканчивается быстрее, чем кажется. Связка 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 при этом остаётся выбором команд, которым нужны вложенные сценарии, богатые отчёты и два десятилетия экосистемы вокруг. Для репозитория приживается короткий свод правил:

  1. зафиксировать порог таймаута в общем конфиге, а не в голове отдельных проверок;
  2. включить forbid-only в серверном скрипте, чтобы одинокий it.only не отключил половину набора;
  3. перейти на parallel только после того, как исчезло общее состояние между тестами;
  4. считать покрытие через c8 и держать пороги в отдельном скрипте запуска;
  5. возвращать код выхода раннера из npm-скрипта без обёрток, чтобы пайплайн падал честно.

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