Введение

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