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
Aprende Más
📚 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