Введение
Mocha и Chai формируют одну из самых популярных комбинаций тестирования в экосистеме JavaScript. Mocha предоставляет гибкий фреймворк тестирования с отличной поддержкой асинхронности, в то время как Chai предлагает выразительные стили утверждений, которые делают тесты читаемыми и поддерживаемыми. Вместе они создают мощное решение для тестирования Node.js и браузерных приложений.
Почему Mocha и Chai?
Преимущества Mocha
- Гибкость: Работает с любой библиотекой утверждений (Chai, Should.js, Expect.js)
- Поддержка асинхронности: Первоклассная поддержка Promises, async/await и callbacks
- Богатые отчеты: Множество встроенных репортеров и расширяемая система репортеров
- Поддержка браузера: Работает как в Node.js, так и в браузерах
- Последовательное выполнение: Тесты выполняются последовательно, позволяя точный контроль асинхронного потока
Преимущества Chai
- Множественные стили утверждений: BDD (expect/should) и TDD (assert)
- Читаемый синтаксис: Утверждения на естественном языке
- Расширяемый: Богатая экосистема плагинов
- Сообщения об ошибках: Четкие, полезные сообщения об ошибках
Установка и Настройка
Базовая Установка
npm install --save-dev mocha chai
Структура Проекта
project/
├── src/
│ └── calculator.js
├── test/
│ ├── unit/
│ │ └── calculator.test.js
│ └── integration/
│ └── api.test.js
├── package.json
└── .mocharc.json
Конфигурация (.mocharc.json)
{
"require": ["test/setup.js"],
"spec": ["test/**/*.test.js"],
"timeout": 5000,
"reporter": "spec",
"recursive": true,
"exit": true
}
Скрипты Package.json
{
"scripts": {
"test": "mocha",
"test:watch": "mocha --watch",
"test:coverage": "nyc mocha",
"test:unit": "mocha test/unit/**/*.test.js",
"test:integration": "mocha test/integration/**/*.test.js"
}
}
Стили Утверждений Chai
1. Expect (Стиль BDD)
const { expect } = require('chai');
describe('Стиль Expect', () => {
it('должен демонстрировать утверждения expect', () => {
const name = 'John';
const age = 30;
const hobbies = ['reading', 'coding'];
const user = { name: 'John', active: true };
// Равенство
expect(name).to.equal('John');
expect(age).to.be.a('number');
// Глубокое равенство для объектов/массивов
expect(user).to.deep.equal({ name: 'John', active: true });
// Длина и включение
expect(hobbies).to.have.lengthOf(2);
expect(hobbies).to.include('coding');
// Существование свойства
expect(user).to.have.property('name');
expect(user).to.have.property('name', 'John');
// Булевы проверки
expect(user.active).to.be.true;
expect(undefined).to.be.undefined;
expect(null).to.be.null;
// Отрицание
expect(name).to.not.equal('Jane');
});
});
2. Should (Стиль BDD)
const chai = require('chai');
chai.should();
describe('Стиль Should', () => {
it('должен демонстрировать утверждения should', () => {
const name = 'John';
const hobbies = ['reading', 'coding'];
name.should.equal('John');
name.should.be.a('string');
hobbies.should.have.lengthOf(2);
hobbies.should.include('coding');
});
});
3. Assert (Стиль TDD)
const { assert } = require('chai');
describe('Стиль Assert', () => {
it('должен демонстрировать утверждения assert', () => {
const name = 'John';
const user = { name: 'John', age: 30 };
assert.equal(name, 'John');
assert.typeOf(name, 'string');
assert.lengthOf(name, 4);
assert.property(user, 'name');
assert.propertyVal(user, 'name', 'John');
assert.deepEqual(user, { name: 'John', age: 30 });
});
});
Примеры Тестирования из Реального Мира
Тестирование Модуля Калькулятора
src/calculator.js:
class Calculator {
add(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError('Аргументы должны быть числами');
}
return a + b;
}
divide(a, b) {
if (b === 0) {
throw new Error('Деление на ноль');
}
return a / b;
}
async asyncMultiply(a, b) {
return new Promise((resolve) => {
setTimeout(() => resolve(a * b), 100);
});
}
}
module.exports = Calculator;
test/unit/calculator.test.js:
const { expect } = require('chai');
const Calculator = require('../../src/calculator');
describe('Calculator', () => {
let calculator;
beforeEach(() => {
calculator = new Calculator();
});
describe('#add()', () => {
it('должен складывать два положительных числа', () => {
const result = calculator.add(5, 3);
expect(result).to.equal(8);
});
it('должен складывать отрицательные числа', () => {
expect(calculator.add(-5, -3)).to.equal(-8);
});
it('должен бросать TypeError для нечисловых аргументов', () => {
expect(() => calculator.add('5', 3)).to.throw(TypeError, 'Аргументы должны быть числами');
});
});
describe('#divide()', () => {
it('должен делить два числа', () => {
expect(calculator.divide(10, 2)).to.equal(5);
});
it('должен бросать ошибку при делении на ноль', () => {
expect(() => calculator.divide(10, 0)).to.throw(Error, 'Деление на ноль');
});
});
describe('#asyncMultiply()', () => {
it('должен умножать два числа асинхронно', async () => {
const result = await calculator.asyncMultiply(4, 5);
expect(result).to.equal(20);
});
});
});
Хуки Mocha
Типы Хуков
describe('Пример Хуков', () => {
before(() => {
// Выполняется один раз перед всеми тестами в этом блоке
console.log('Настройка: перед всеми тестами');
});
after(() => {
// Выполняется один раз после всех тестов в этом блоке
console.log('Очистка: после всех тестов');
});
beforeEach(() => {
// Выполняется перед каждым тестом в этом блоке
console.log('Настройка: перед каждым тестом');
});
afterEach(() => {
// Выполняется после каждого теста в этом блоке
console.log('Очистка: после каждого теста');
});
it('тест 1', () => {
expect(true).to.be.true;
});
it('тест 2', () => {
expect(false).to.be.false;
});
});
Асинхронные Хуки
describe('Асинхронные Хуки', () => {
before(async () => {
// Настроить подключение к базе данных
await database.connect();
});
after(async () => {
// Закрыть подключение к базе данных
await database.disconnect();
});
beforeEach(async () => {
// Очистить базу данных перед каждым тестом
await database.clear();
});
});
Паттерны Асинхронного Тестирования
1. Async/Await (Рекомендуется)
describe('Паттерн Async/Await', () => {
it('должен получить данные пользователя', async () => {
const user = await fetchUser(123);
expect(user.name).to.equal('John');
});
it('должен обработать ошибки', async () => {
try {
await fetchUser(999);
expect.fail('Должен был бросить ошибку');
} catch (error) {
expect(error.message).to.include('не найден');
}
});
});
2. Промисы
describe('Паттерн Promise', () => {
it('должен получить данные пользователя', () => {
return fetchUser(123).then(user => {
expect(user.name).to.equal('John');
});
});
it('должен обработать ошибки', () => {
return fetchUser(999).catch(error => {
expect(error.message).to.include('не найден');
});
});
});
Плагины Chai
chai-http (HTTP Тестирование)
npm install --save-dev chai-http
const chai = require('chai');
const chaiHttp = require('chai-http');
const app = require('../src/app');
chai.use(chaiHttp);
const { expect } = chai;
describe('Эндпоинты API', () => {
it('GET /api/users должен вернуть пользователей', (done) => {
chai.request(app)
.get('/api/users')
.end((err, res) => {
expect(res).to.have.status(200);
expect(res.body).to.be.an('array');
expect(res.body).to.have.lengthOf.at.least(1);
done();
});
});
it('POST /api/users должен создать пользователя', async () => {
const res = await chai.request(app)
.post('/api/users')
.send({ name: 'John', email: 'john@example.com' });
expect(res).to.have.status(201);
expect(res.body).to.have.property('id');
expect(res.body.name).to.equal('John');
});
});
chai-as-promised (Утверждения для Промисов)
npm install --save-dev chai-as-promised
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
chai.use(chaiAsPromised);
const { expect } = chai;
describe('Тестирование Промисов', () => {
it('должен разрешиться с данными пользователя', () => {
return expect(fetchUser(123)).to.eventually.have.property('name', 'John');
});
it('должен отклониться с ошибкой', () => {
return expect(fetchUser(999)).to.be.rejectedWith('Пользователь не найден');
});
it('должен выполниться', () => {
return expect(Promise.resolve('успех')).to.be.fulfilled;
});
});
Репортеры Mocha
Встроенные Репортеры
# Spec (по умолчанию) - иерархический вид
mocha --reporter spec
# Точечная матрица - минимальный вывод
mocha --reporter dot
# JSON - машиночитаемый вывод
mocha --reporter json > results.json
# HTML - дружественный для браузера вывод
mocha --reporter html > results.html
# TAP - Test Anything Protocol
mocha --reporter tap
Покрытие Кода с NYC
Установка
npm install --save-dev nyc
Конфигурация (.nycrc.json)
{
"all": true,
"include": ["src/**/*.js"],
"exclude": ["test/**", "**/*.test.js"],
"reporter": ["html", "text", "lcov"],
"check-coverage": true,
"lines": 80,
"functions": 80,
"branches": 80,
"statements": 80
}
Запуск Покрытия
nyc mocha
# С проверкой порога
nyc --check-coverage --lines 90 mocha
Лучшие Практики
1. Описательные Названия Тестов
// Плохо
it('тест 1', () => {});
// Хорошо
it('должен вернуть 404 когда пользователь не существует', () => {});
2. Паттерн Arrange-Act-Assert
it('должен рассчитать общую цену со скидкой', () => {
// Arrange (Подготовка)
const cart = new ShoppingCart();
cart.addItem({ price: 100 });
const discount = 0.1;
// Act (Действие)
const total = cart.calculateTotal(discount);
// Assert (Утверждение)
expect(total).to.equal(90);
});
3. Одно Утверждение на Тест
// Избегать множественных несвязанных утверждений
it('должен валидировать пользователя', () => {
expect(user.name).to.equal('John');
expect(user.age).to.equal(30);
expect(user.email).to.include('@');
});
// Лучше: отдельные тесты
describe('Валидация Пользователя', () => {
it('должен иметь правильное имя', () => {
expect(user.name).to.equal('John');
});
it('должен иметь правильный возраст', () => {
expect(user.age).to.equal(30);
});
it('должен иметь валидный email', () => {
expect(user.email).to.include('@');
});
});
4. Избегать Взаимозависимости Тестов
// Плохо: тесты зависят от порядка выполнения
describe('Плохой Пример', () => {
let userId;
it('должен создать пользователя', async () => {
const user = await createUser({ name: 'John' });
userId = user.id; // Общее состояние
});
it('должен получить пользователя', async () => {
const user = await fetchUser(userId); // Зависит от предыдущего теста
expect(user.name).to.equal('John');
});
});
// Хорошо: независимые тесты
describe('Хороший Пример', () => {
it('должен создать пользователя', async () => {
const user = await createUser({ name: 'John' });
expect(user).to.have.property('id');
});
it('должен получить пользователя', async () => {
const created = await createUser({ name: 'John' });
const fetched = await fetchUser(created.id);
expect(fetched.name).to.equal('John');
});
});
Интеграция с CI/CD
GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Настроить Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Установить зависимости
run: npm ci
- name: Запустить тесты
run: npm test
- name: Сгенерировать покрытие
run: npm run test:coverage
- name: Загрузить покрытие в Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
Заключение
Mocha и Chai предоставляют мощную, гибкую основу для тестирования JavaScript проектов. Дизайн Mocha с приоритетом асинхронности и гибкая архитектура прекрасно сочетаются с выразительными стилями утверждений Chai для создания читаемых, поддерживаемых тестов. Следуя лучшим практикам—описательные названия тестов, правильная изоляция тестов и комплексное покрытие—вы можете построить надежный набор тестов, который ловит баги рано и служит живой документацией для вашей кодовой базы.
Начните с простых unit-тестов, постепенно добавляйте интеграционные тесты, используйте плагины для специализированных потребностей тестирования и интегрируйте отчеты о покрытии, чтобы обеспечить надежность и поддерживаемость вашего приложения.