Introducción
Mocha y Chai forman una de las combinaciones de testing más populares en el ecosistema JavaScript. Mocha proporciona un framework de testing flexible con excelente soporte asíncrono, mientras que Chai ofrece estilos de aserción expresivos que hacen que los tests sean legibles y mantenibles. Juntos, crean una solución de testing poderosa para aplicaciones Node.js y basadas en navegador.
¿Por Qué Mocha y Chai?
Ventajas de Mocha
- Flexibilidad: Funciona con cualquier librería de aserciones (Chai, Should.js, Expect.js)
- Soporte asíncrono: Soporte de primera clase para Promesas, async/await y callbacks
- Reportes ricos: Múltiples reporters integrados y sistema de reporters extensible
- Soporte de navegador: Se ejecuta tanto en Node.js como en navegadores
- Ejecución serial: Los tests se ejecutan serialmente, permitiendo control preciso del flujo asíncrono
Ventajas de Chai
- Múltiples estilos de aserción: BDD (expect/should) y TDD (assert)
- Sintaxis legible: Aserciones en lenguaje natural
- Extensible: Rico ecosistema de plugins
- Mensajes de error: Mensajes de error claros y útiles
Instalación y Configuración
Instalación Básica
npm install --save-dev mocha chai
Estructura del Proyecto
project/
├── src/
│ └── calculator.js
├── test/
│ ├── unit/
│ │ └── calculator.test.js
│ └── integration/
│ └── api.test.js
├── package.json
└── .mocharc.json
Configuración (.mocharc.json)
{
"require": ["test/setup.js"],
"spec": ["test/**/*.test.js"],
"timeout": 5000,
"reporter": "spec",
"recursive": true,
"exit": true
}
Scripts de 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"
}
}
Estilos de Aserción de Chai
1. Expect (Estilo BDD)
const { expect } = require('chai');
describe('Estilo Expect', () => {
it('debería demostrar aserciones expect', () => {
const name = 'John';
const age = 30;
const hobbies = ['reading', 'coding'];
const user = { name: 'John', active: true };
// Igualdad
expect(name).to.equal('John');
expect(age).to.be.a('number');
// Igualdad profunda para objetos/arrays
expect(user).to.deep.equal({ name: 'John', active: true });
// Longitud e inclusión
expect(hobbies).to.have.lengthOf(2);
expect(hobbies).to.include('coding');
// Existencia de propiedad
expect(user).to.have.property('name');
expect(user).to.have.property('name', 'John');
// Verificaciones booleanas
expect(user.active).to.be.true;
expect(undefined).to.be.undefined;
expect(null).to.be.null;
// Negación
expect(name).to.not.equal('Jane');
});
});
2. Should (Estilo BDD)
const chai = require('chai');
chai.should();
describe('Estilo Should', () => {
it('debería demostrar aserciones 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 (Estilo TDD)
const { assert } = require('chai');
describe('Estilo Assert', () => {
it('debería demostrar aserciones 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 });
});
});
Ejemplos de Testing del Mundo Real
Probando un Módulo Calculadora
src/calculator.js:
class Calculator {
add(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError('Los argumentos deben ser números');
}
return a + b;
}
divide(a, b) {
if (b === 0) {
throw new Error('División por cero');
}
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('debería sumar dos números positivos', () => {
const result = calculator.add(5, 3);
expect(result).to.equal(8);
});
it('debería sumar números negativos', () => {
expect(calculator.add(-5, -3)).to.equal(-8);
});
it('debería lanzar TypeError para argumentos no numéricos', () => {
expect(() => calculator.add('5', 3)).to.throw(TypeError, 'Los argumentos deben ser números');
});
});
describe('#divide()', () => {
it('debería dividir dos números', () => {
expect(calculator.divide(10, 2)).to.equal(5);
});
it('debería lanzar error al dividir por cero', () => {
expect(() => calculator.divide(10, 0)).to.throw(Error, 'División por cero');
});
});
describe('#asyncMultiply()', () => {
it('debería multiplicar dos números asíncronamente', async () => {
const result = await calculator.asyncMultiply(4, 5);
expect(result).to.equal(20);
});
});
});
Hooks de Mocha
Tipos de Hooks
describe('Ejemplo de Hooks', () => {
before(() => {
// Se ejecuta una vez antes de todos los tests en este bloque
console.log('Configuración: antes de todos los tests');
});
after(() => {
// Se ejecuta una vez después de todos los tests en este bloque
console.log('Limpieza: después de todos los tests');
});
beforeEach(() => {
// Se ejecuta antes de cada test en este bloque
console.log('Configuración: antes de cada test');
});
afterEach(() => {
// Se ejecuta después de cada test en este bloque
console.log('Limpieza: después de cada test');
});
it('test 1', () => {
expect(true).to.be.true;
});
it('test 2', () => {
expect(false).to.be.false;
});
});
Hooks Asíncronos
describe('Hooks Asíncronos', () => {
before(async () => {
// Configurar conexión a base de datos
await database.connect();
});
after(async () => {
// Cerrar conexión a base de datos
await database.disconnect();
});
beforeEach(async () => {
// Limpiar base de datos antes de cada test
await database.clear();
});
});
Patrones de Testing Asíncrono
1. Async/Await (Recomendado)
describe('Patrón Async/Await', () => {
it('debería obtener datos de usuario', async () => {
const user = await fetchUser(123);
expect(user.name).to.equal('John');
});
it('debería manejar errores', async () => {
try {
await fetchUser(999);
expect.fail('Debería haber lanzado error');
} catch (error) {
expect(error.message).to.include('no encontrado');
}
});
});
2. Promesas
describe('Patrón Promise', () => {
it('debería obtener datos de usuario', () => {
return fetchUser(123).then(user => {
expect(user.name).to.equal('John');
});
});
it('debería manejar errores', () => {
return fetchUser(999).catch(error => {
expect(error.message).to.include('no encontrado');
});
});
});
Plugins de Chai
chai-http (Testing 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('Endpoints de API', () => {
it('GET /api/users debería retornar usuarios', (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 debería crear usuario', 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 (Aserciones de Promesas)
npm install --save-dev chai-as-promised
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
chai.use(chaiAsPromised);
const { expect } = chai;
describe('Testing de Promesas', () => {
it('debería resolver con datos de usuario', () => {
return expect(fetchUser(123)).to.eventually.have.property('name', 'John');
});
it('debería rechazar con error', () => {
return expect(fetchUser(999)).to.be.rejectedWith('Usuario no encontrado');
});
it('debería cumplirse', () => {
return expect(Promise.resolve('éxito')).to.be.fulfilled;
});
});
Reporters de Mocha
Reporters Integrados
# Spec (predeterminado) - vista jerárquica
mocha --reporter spec
# Matriz de puntos - salida mínima
mocha --reporter dot
# JSON - salida legible por máquina
mocha --reporter json > results.json
# HTML - salida amigable para navegador
mocha --reporter html > results.html
# TAP - Test Anything Protocol
mocha --reporter tap
Cobertura de Código con NYC
Instalación
npm install --save-dev nyc
Configuración (.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
}
Ejecutar Cobertura
nyc mocha
# Con aplicación de umbral
nyc --check-coverage --lines 90 mocha
Mejores Prácticas
1. Nombres Descriptivos de Tests
// Mal
it('test 1', () => {});
// Bien
it('debería retornar 404 cuando el usuario no existe', () => {});
2. Patrón Arrange-Act-Assert
it('debería calcular precio total con descuento', () => {
// Arrange (Preparar)
const cart = new ShoppingCart();
cart.addItem({ price: 100 });
const discount = 0.1;
// Act (Actuar)
const total = cart.calculateTotal(discount);
// Assert (Afirmar)
expect(total).to.equal(90);
});
3. Una Aserción Por Test
// Evitar múltiples aserciones no relacionadas
it('debería validar usuario', () => {
expect(user.name).to.equal('John');
expect(user.age).to.equal(30);
expect(user.email).to.include('@');
});
// Mejor: tests separados
describe('Validación de Usuario', () => {
it('debería tener nombre correcto', () => {
expect(user.name).to.equal('John');
});
it('debería tener edad correcta', () => {
expect(user.age).to.equal(30);
});
it('debería tener email válido', () => {
expect(user.email).to.include('@');
});
});
4. Evitar Interdependencia de Tests
// Mal: tests dependen del orden de ejecución
describe('Mal Ejemplo', () => {
let userId;
it('debería crear usuario', async () => {
const user = await createUser({ name: 'John' });
userId = user.id; // Estado compartido
});
it('debería obtener usuario', async () => {
const user = await fetchUser(userId); // Depende del test anterior
expect(user.name).to.equal('John');
});
});
// Bien: tests independientes
describe('Buen Ejemplo', () => {
it('debería crear usuario', async () => {
const user = await createUser({ name: 'John' });
expect(user).to.have.property('id');
});
it('debería obtener usuario', async () => {
const created = await createUser({ name: 'John' });
const fetched = await fetchUser(created.id);
expect(fetched.name).to.equal('John');
});
});
Integración CI/CD
GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configurar Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Instalar dependencias
run: npm ci
- name: Ejecutar tests
run: npm test
- name: Generar cobertura
run: npm run test:coverage
- name: Subir cobertura a Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
Conclusión
Mocha y Chai proporcionan una base de testing poderosa y flexible para proyectos JavaScript. El diseño async-first de Mocha y su arquitectura flexible se combinan perfectamente con los estilos de aserción expresivos de Chai para crear tests legibles y mantenibles. Siguiendo las mejores prácticas—nombres descriptivos de tests, aislamiento apropiado de tests y cobertura comprehensiva—puedes construir una suite de tests robusta que detecta bugs temprano y sirve como documentación viva para tu código base.
Comienza con tests unitarios simples, agrega gradualmente tests de integración, aprovecha plugins para necesidades especializadas de testing, e integra reportes de cobertura para asegurar que tu aplicación permanezca confiable y mantenible.