TechLead

Testing Unitario Profundo

Domina el testing unitario con Jest: mocking, spies, organización de tests y mejores prácticas

¿Qué Hace un Buen Test Unitario?

Un test unitario se enfoca en testear una sola "unidad" de código en aislamiento. Esto podría ser una función, un método o un componente. La clave es aislar el código bajo test de sus dependencias.

✅ Características de Buenos Tests Unitarios

  • • Rápidos (milisegundos)
  • • Aislados (sin dependencias externas)
  • • Repetibles (mismo resultado cada vez)
  • • Auto-validables (pasan o fallan claramente)
  • • Oportunos (escritos con o antes del código)

🎯 Principios FIRST

  • Fast - Se ejecutan rápidamente
  • Isolated - Independientes de otros
  • Repeatable - Resultados consistentes
  • Self-validating - Sin verificación manual
  • Timely - Escritos en el momento correcto

Conceptos Básicos de Jest

// math.js - Funciones simples a testear
export function add(a, b) {
  return a + b;
}

export function divide(a, b) {
  if (b === 0) throw new Error('División por cero');
  return a / b;
}

export function isEven(num) {
  return num % 2 === 0;
}

export function factorial(n) {
  if (n < 0) throw new Error('Número negativo');
  if (n === 0 || n === 1) return 1;
  return n * factorial(n - 1);
}
// math.test.js - Tests básicos
import { add, divide, isEven, factorial } from './math';

describe('Utilidades matemáticas', () => {
  describe('add', () => {
    test('suma números positivos', () => {
      expect(add(2, 3)).toBe(5);
    });

    test('suma números negativos', () => {
      expect(add(-2, -3)).toBe(-5);
    });

    test('suma números mixtos', () => {
      expect(add(-2, 3)).toBe(1);
    });
  });

  describe('divide', () => {
    test('divide números correctamente', () => {
      expect(divide(10, 2)).toBe(5);
    });

    test('lanza error en división por cero', () => {
      expect(() => divide(10, 0)).toThrow('División por cero');
    });
  });

  describe('isEven', () => {
    test.each([
      [2, true],
      [3, false],
      [0, true],
      [-2, true],
      [-3, false],
    ])('isEven(%i) devuelve %s', (num, expected) => {
      expect(isEven(num)).toBe(expected);
    });
  });

  describe('factorial', () => {
    test('calcula factorial correctamente', () => {
      expect(factorial(0)).toBe(1);
      expect(factorial(1)).toBe(1);
      expect(factorial(5)).toBe(120);
    });

    test('lanza error para números negativos', () => {
      expect(() => factorial(-1)).toThrow('Número negativo');
    });
  });
});

Referencia de Matchers de Jest

Matchers Comunes

// Igualdad
expect(value).toBe(5);                // Igualdad estricta (===)
expect(value).toEqual({ a: 1 });       // Igualdad profunda
expect(value).not.toBe(5);             // Negación

// Veracidad
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// Números
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3);
expect(value).toBeLessThan(5);
expect(value).toBeCloseTo(0.3);        // Punto flotante

// Strings
expect(str).toMatch(/pattern/);
expect(str).toContain('substring');

// Arrays
expect(arr).toContain(item);
expect(arr).toHaveLength(3);

// Objetos
expect(obj).toHaveProperty('key');
expect(obj).toMatchObject({ a: 1 });

// Funciones
expect(fn).toThrow();
expect(fn).toThrow('Error message');
expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledWith(arg1, arg2);

Matchers Asíncronos

// Promesas
test('test asíncrono', async () => {
  await expect(promise).resolves.toBe(value);
  await expect(promise).rejects.toThrow();
});

// Sintaxis alternativa
test('test asíncrono', () => {
  return expect(promise).resolves.toBe(value);
});

// Usando async/await
test('test asíncrono', async () => {
  const result = await asyncFunction();
  expect(result).toBe(expected);
});

// Testeando callbacks
test('test de callback', (done) => {
  function callback(data) {
    expect(data).toBe('result');
    done();
  }
  
  asyncFunction(callback);
});

Mocking con Jest

Los mocks reemplazan dependencias con implementaciones controladas para testing aislado.

Funciones Mock

// Creando funciones mock
const mockFn = jest.fn();

// Mock con valor de retorno
const mockFn = jest.fn(() => 'return value');
const mockFn = jest.fn().mockReturnValue('value');

// Mock con diferentes valores de retorno
const mockFn = jest.fn()
  .mockReturnValueOnce('first')
  .mockReturnValueOnce('second')
  .mockReturnValue('default');

console.log(mockFn()); // 'first'
console.log(mockFn()); // 'second'
console.log(mockFn()); // 'default'
console.log(mockFn()); // 'default'

// Mock de funciones asíncronas
const mockAsync = jest.fn().mockResolvedValue('success');
const mockAsyncError = jest.fn().mockRejectedValue(new Error('failed'));

// Verificando llamadas mock
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(4);
expect(mockFn).toHaveBeenCalledWith(arg1, arg2);
expect(mockFn).toHaveBeenLastCalledWith(arg);

// Accediendo datos de llamadas
console.log(mockFn.mock.calls);        // Todas las llamadas: [[args1], [args2]]
console.log(mockFn.mock.results);      // Todos los resultados
console.log(mockFn.mock.instances);    // Todas las instancias (para constructores)

Mocking de Módulos

// api.js - Módulo a simular
export async function fetchUser(id) {
  const response = await fetch('/api/users/' + id);
  return response.json();
}

export async function createUser(data) {
  const response = await fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify(data),
  });
  return response.json();
}
// userService.js - Código que usa la API
import { fetchUser, createUser } from './api';

export async function getUserName(id) {
  const user = await fetchUser(id);
  return user.name;
}

export async function registerUser(name, email) {
  const user = await createUser({ name, email });
  return user.id;
}
// userService.test.js - Test con API simulada
import { getUserName, registerUser } from './userService';
import * as api from './api';

// Simular el módulo completo
jest.mock('./api');

describe('User Service', () => {
  beforeEach(() => {
    // Limpiar todos los mocks antes de cada test
    jest.clearAllMocks();
  });

  test('getUserName devuelve el nombre del usuario', async () => {
    // Configurar valor de retorno mock
    api.fetchUser.mockResolvedValue({
      id: 1,
      name: 'Alice',
      email: 'alice@example.com',
    });

    const name = await getUserName(1);

    expect(name).toBe('Alice');
    expect(api.fetchUser).toHaveBeenCalledWith(1);
    expect(api.fetchUser).toHaveBeenCalledTimes(1);
  });

  test('registerUser devuelve id de nuevo usuario', async () => {
    api.createUser.mockResolvedValue({
      id: 123,
      name: 'Bob',
      email: 'bob@example.com',
    });

    const id = await registerUser('Bob', 'bob@example.com');

    expect(id).toBe(123);
    expect(api.createUser).toHaveBeenCalledWith({
      name: 'Bob',
      email: 'bob@example.com',
    });
  });

  test('maneja errores de API', async () => {
    api.fetchUser.mockRejectedValue(new Error('Error de red'));

    await expect(getUserName(1)).rejects.toThrow('Error de red');
  });
});

Spies y Mocks Parciales

// Espiar un método sin cambiar implementación
const obj = {
  method: () => 'original',
};

const spy = jest.spyOn(obj, 'method');

console.log(obj.method()); // 'original' - todavía funciona
expect(spy).toHaveBeenCalled();

// Espiar y cambiar implementación
const spy = jest.spyOn(obj, 'method').mockReturnValue('mocked');
console.log(obj.method()); // 'mocked'

// Restaurar original
spy.mockRestore();
console.log(obj.method()); // 'original'

// Mock parcial de módulo
jest.mock('./utils', () => ({
  ...jest.requireActual('./utils'), // Mantener otras exportaciones
  fetchData: jest.fn(),              // Simular solo esta
}));

Testeando Código Asíncrono

// Funciones asíncronas de ejemplo
async function fetchData() {
  const response = await fetch('/api/data');
  if (!response.ok) throw new Error('Fallo al obtener datos');
  return response.json();
}

function fetchDataCallback(callback) {
  setTimeout(() => {
    callback({ data: 'result' });
  }, 100);
}

// Testeando con async/await
test('obtiene datos exitosamente', async () => {
  global.fetch = jest.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ data: 'test' }),
  });

  const data = await fetchData();
  expect(data).toEqual({ data: 'test' });
});

// Testeando casos de error
test('lanza error en fetch fallido', async () => {
  global.fetch = jest.fn().mockResolvedValue({
    ok: false,
  });

  await expect(fetchData()).rejects.toThrow('Fallo al obtener datos');
});

// Testeando con callbacks
test('callback recibe datos', (done) => {
  function callback(data) {
    try {
      expect(data).toEqual({ data: 'result' });
      done();
    } catch (error) {
      done(error);
    }
  }

  fetchDataCallback(callback);
});

// Testeando con promesas
test('devuelve una promesa', () => {
  return expect(fetchData()).resolves.toEqual({ data: 'test' });
});

Setup y Teardown

describe('Tests de base de datos', () => {
  let db;

  // Ejecutar una vez antes de todos los tests en este bloque describe
  beforeAll(async () => {
    db = await connectDatabase();
  });

  // Ejecutar una vez después de todos los tests en este bloque describe
  afterAll(async () => {
    await db.disconnect();
  });

  // Ejecutar antes de cada test
  beforeEach(async () => {
    await db.clear();
    await db.seed();
  });

  // Ejecutar después de cada test
  afterEach(async () => {
    await db.clearLogs();
  });

  test('puede insertar usuario', async () => {
    const user = { name: 'Alice' };
    await db.users.insert(user);
    
    const found = await db.users.findOne({ name: 'Alice' });
    expect(found.name).toBe('Alice');
  });

  test('puede eliminar usuario', async () => {
    await db.users.insert({ name: 'Bob' });
    await db.users.delete({ name: 'Bob' });
    
    const found = await db.users.findOne({ name: 'Bob' });
    expect(found).toBeNull();
  });
});

Mejores Prácticas de Organización de Tests

📁 Estructura de Archivos

src/
  components/
    Button/
      Button.jsx
      Button.test.jsx
      Button.styles.css
  utils/
    math.js
    math.test.js
  __tests__/
    integration/
      userFlow.test.js

Mantén los tests cerca del código que testean, o en una carpeta __tests__ dedicada.

✍️ Nomenclatura de Tests

// ❌ Mal: No está claro qué se está testeando
test('it works', () => { ... });
test('test1', () => { ... });

// ✅ Bien: Describe el comportamiento claramente
test('devuelve nombre de usuario cuando el usuario existe', () => { ... });
test('lanza error cuando usuario no encontrado', () => { ... });
test('calcula total con descuento aplicado', () => { ... });

// ✅ Bien: Usando bloques describe para organización
describe('ShoppingCart', () => {
  describe('addItem', () => {
    test('agrega item a carrito vacío', () => { ... });
    test('incrementa cantidad cuando item ya existe', () => { ... });
    test('lanza error cuando item es inválido', () => { ... });
  });

  describe('removeItem', () => {
    test('elimina item completamente', () => { ... });
    test('decrementa cantidad cuando count > 1', () => { ... });
  });
});

Cobertura de Código

// Ejecutar tests con cobertura
npm test -- --coverage

// Ejemplo de salida:
// -------------------|---------|----------|---------|---------|
// File               | % Stmts | % Branch | % Funcs | % Lines |
// -------------------|---------|----------|---------|---------|
// All files          |   85.5  |   78.2   |   90.1  |   84.8  |
//  math.js           |   100   |   100    |   100   |   100   |
//  userService.js    |   80.5  |   66.7   |   85.0  |   79.2  |
// -------------------|---------|----------|---------|---------|

⚠️ La Cobertura No Lo Es Todo

  • • 100% de cobertura no significa código libre de bugs
  • • Enfócate en testear comportamiento importante, no en alcanzar un número
  • • Apunta a 70-80% de cobertura como objetivo razonable
  • • Las rutas críticas deberían tener alta cobertura

💡 Mejores Prácticas de Testing Unitario

  • ✓ Testea una cosa por test
  • ✓ Usa nombres de test descriptivos que expliquen el comportamiento
  • ✓ Patrón Arrange-Act-Assert para claridad
  • ✓ Simula dependencias externas para mantener tests rápidos y aislados
  • ✓ Testea casos extremos y condiciones de error
  • ✓ Mantén tests simples y legibles
  • ✓ No testees detalles de implementación, testea comportamiento
  • ✓ Ejecuta tests frecuentemente durante el desarrollo

📚 Más Temas de Testing

Explora los 6 temas de testing para construir una comprensión completa del testing de software.

Ver Todos los Temas