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.