Mejores Prácticas de Testing
Estrategias de testing del mundo real, patrones comunes, técnicas de depuración y construcción de suites de tests mantenibles
Escribiendo Código Testeable
Buenos tests comienzan con buen código. El código que es fácil de testear suele estar mejor diseñado, más modular y más fácil de mantener.
❌ Difícil de Testear
// Acoplado, dependencias duras
function processOrder() {
const db = new Database();
const payment = new PaymentGateway();
const email = new EmailService();
const order = db.getOrder(123);
const result = payment.charge(order.total);
email.send(order.email, 'Recibo');
return result;
}
// ¿Cómo testeas esto sin:
// - Base de datos real
// - Pasarela de pago real
// - Enviar emails de verdad
✅ Fácil de Testear
// Inyección de dependencias, función pura
function processOrder(order, payment, email) {
const result = payment.charge(order.total);
if (result.success) {
email.send(order.email, 'Recibo');
}
return result;
}
// Fácil de testear con mocks
test('procesa orden exitosamente', () => {
const mockPayment = {
charge: jest.fn().mockReturnValue({ success: true })
};
const mockEmail = {
send: jest.fn()
};
const order = { total: 100, email: 'user@example.com' };
const result = processOrder(order, mockPayment, mockEmail);
expect(result.success).toBe(true);
expect(mockEmail.send).toHaveBeenCalled();
});
Estrategias de Organización de Tests
// Ejemplo: Archivo de test bien organizado
describe('ShoppingCart', () => {
// Agrupar funcionalidad relacionada
describe('adding items', () => {
test('agrega item a carrito vacío', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, price: 10 });
expect(cart.items).toHaveLength(1);
expect(cart.total).toBe(10);
});
test('incrementa cantidad para item existente', () => {
const cart = new ShoppingCart();
const item = { id: 1, price: 10 };
cart.addItem(item);
cart.addItem(item);
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(2);
expect(cart.total).toBe(20);
});
test('agrega items diferentes por separado', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, price: 10 });
cart.addItem({ id: 2, price: 15 });
expect(cart.items).toHaveLength(2);
expect(cart.total).toBe(25);
});
});
describe('removing items', () => {
let cart;
beforeEach(() => {
cart = new ShoppingCart();
cart.addItem({ id: 1, price: 10 });
cart.addItem({ id: 2, price: 15 });
});
test('elimina item completamente', () => {
cart.removeItem(1);
expect(cart.items).toHaveLength(1);
expect(cart.total).toBe(15);
});
test('decrementa cantidad cuando count > 1', () => {
cart.addItem({ id: 1, price: 10 });
cart.removeItem(1);
const item = cart.items.find(i => i.id === 1);
expect(item.quantity).toBe(1);
expect(cart.total).toBe(25);
});
});
describe('applying discounts', () => {
test('aplica descuento porcentual', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, price: 100 });
cart.applyDiscount({ type: 'percentage', value: 10 });
expect(cart.total).toBe(90);
});
test('aplica descuento de cantidad fija', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, price: 100 });
cart.applyDiscount({ type: 'fixed', value: 25 });
expect(cart.total).toBe(75);
});
test('no aplica descuento por debajo de cero', () => {
const cart = new ShoppingCart();
cart.addItem({ id: 1, price: 10 });
cart.applyDiscount({ type: 'fixed', value: 20 });
expect(cart.total).toBe(0);
});
});
});
Convenciones de Nomenclatura de Tests
Patrón: "should [comportamiento esperado] when [condición]"
test('should return user when ID exists', () => {});
test('should throw error when ID is invalid', () => {});
test('should update quantity when same item added twice', () => {});
Patrón: "[acción] [resultado esperado]"
test('calcula total con impuesto correctamente', () => {});
test('lanza error en división por cero', () => {});
test('filtra tareas completadas', () => {});
Malos Ejemplos - Demasiado Vagos
test('funciona', () => {}); // ¿Qué funciona?
test('testear carrito', () => {}); // ¿Qué del carrito?
test('debería pasar', () => {}); // Sin significado
test('caso extremo', () => {}); // ¿Cuál caso extremo?
Testeando Casos Extremos
// Siempre testea casos extremos y límites
describe('Utilidades de string', () => {
describe('truncate', () => {
test('maneja caso normal', () => {
expect(truncate('Hola Mundo', 8)).toBe('Hola...');
});
test('devuelve original cuando la longitud es suficiente', () => {
expect(truncate('Hola', 10)).toBe('Hola');
});
test('maneja string vacío', () => {
expect(truncate('', 5)).toBe('');
});
test('maneja null', () => {
expect(truncate(null, 5)).toBe('');
});
test('maneja undefined', () => {
expect(truncate(undefined, 5)).toBe('');
});
test('maneja string exactamente en el límite', () => {
expect(truncate('Hola', 5)).toBe('Hola');
});
test('maneja longitud de 0', () => {
expect(truncate('Hola', 0)).toBe('...');
});
test('maneja longitud negativa', () => {
expect(truncate('Hola', -1)).toBe('...');
});
test('maneja caracteres especiales', () => {
expect(truncate('Hola 👋 Mundo', 8)).toBe('Hola...');
});
});
});
🎯 Casos Extremos Comunes a Testear:
- • Inputs vacíos: [], '', {}, null, undefined
- • Límites: 0, -1, valores máximos, límites de arrays
- • Valores especiales: NaN, Infinity, números negativos
- • Tipos: Tipo incorrecto pasado, tipos mixtos
- • Strings: Vacíos, solo espacios, muy largos, caracteres especiales
- • Arrays: Vacíos, un solo item, duplicados, desordenados
Tests Inestables y Cómo Arreglarlos
❌ Causas Comunes de Tests Inestables
- Condiciones de carrera: Operaciones asíncronas no esperadas apropiadamente
- Estado compartido: Tests afectándose entre sí
- Dependencias de tiempo: Tests que dependen de tiempo/fecha actual
- Datos aleatorios: Usando Math.random() en tests
- Problemas de red: Llamadas a API reales agotando tiempo
- Timing de animaciones: UI no lista cuando el test se ejecuta
// ❌ Inestable: Condición de carrera
test('carga datos de usuario', async () => {
fetchUser(1);
// ¡No esperado! El test puede verificar antes de que lleguen los datos
expect(getUserName()).toBe('Alice');
});
// ✅ Arreglado: Apropiadamente esperado
test('carga datos de usuario', async () => {
await fetchUser(1);
expect(getUserName()).toBe('Alice');
});
// ❌ Inestable: Dependiente de tiempo
test('crea timestamp', () => {
const record = createRecord();
expect(record.createdAt).toBe(new Date()); // ¡Los milisegundos pueden diferir!
});
// ✅ Arreglado: Simular tiempo o verificar rango
test('crea timestamp', () => {
const before = Date.now();
const record = createRecord();
const after = Date.now();
expect(record.createdAt).toBeGreaterThanOrEqual(before);
expect(record.createdAt).toBeLessThanOrEqual(after);
});
// Aún mejor: Simular Date
test('crea timestamp', () => {
const mockDate = new Date('2024-01-01');
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
const record = createRecord();
expect(record.createdAt).toEqual(mockDate);
});
// ❌ Inestable: Estado compartido
let userId = 1;
test('crea usuario', () => {
const user = createUser(userId++);
expect(user.id).toBe(1); // ¡Depende del orden de tests!
});
// ✅ Arreglado: Estado independiente
test('crea usuario', () => {
const userId = generateUniqueId();
const user = createUser(userId);
expect(user.id).toBe(userId);
});
Mejores Prácticas de Cobertura de Tests
✅ Buenos Objetivos de Cobertura
// Enfócate en rutas de código críticas
✓ Lógica de negocio: 90-100%
✓ Utilidades: 80-90%
✓ Rutas API: 80-90%
✓ Componentes: 70-80%
✓ General: 70-80%
// No todo necesita 100%
- Getters/setters simples
- Wrappers triviales
- Código generado
- Lógica de presentación UI
⚠️ La Cobertura No Lo Es Todo
// 100% de cobertura no significa libre de bugs
test('función existe', () => {
expect(calculateTotal).toBeDefined();
});
// ¡Esto da cobertura pero no testea nada!
// Mejor: Testear comportamiento
test('calcula total correctamente', () => {
const result = calculateTotal([
{ price: 10, qty: 2 },
{ price: 5, qty: 1 }
]);
expect(result).toBe(25);
});
Depurando Tests Fallidos
// Agregar salida de depuración
test('cálculo complejo', () => {
const input = [1, 2, 3, 4, 5];
const result = complexFunction(input);
console.log('Input:', input);
console.log('Result:', result);
console.log('Expected:', 15);
expect(result).toBe(15);
});
// Usar test.only para ejecutar un solo test
test.only('el test fallido', () => {
// Enfócate solo en este
});
// Usar test.skip para deshabilitar temporalmente
test.skip('no listo aún', () => {
// No se ejecutará
});
// Ejecutar tests en modo watch
// npm test -- --watch
// Ejecutar con salida verbose
// npm test -- --verbose
// Depurar en VS Code
// Agregar breakpoint y usar opción "Debug Test"
Mejores Prácticas de Integración Continua
// .github/workflows/test.yml - GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test -- --coverage
- name: Run integration tests
run: npm run test:integration
- name: Run E2E tests
run: npm run test:e2e
- name: Upload coverage
uses: codecov/codecov-action@v3
- name: Upload screenshots on failure
if: failure()
uses: actions/upload-artifact@v3
with:
name: test-screenshots
path: screenshots/
🚀 Estrategia de Testing CI/CD:
- • Ejecutar tests unitarios en cada commit (retroalimentación rápida)
- • Ejecutar tests de integración en PR (detectar problemas de integración)
- • Ejecutar tests E2E antes del despliegue (verificación final)
- • Fallar el build si los tests fallan
- • Rastrear cobertura a lo largo del tiempo
- • Guardar artefactos (screenshots, videos) en fallas
🎯 Reglas de Oro del Testing
- 1. Testea comportamiento, no implementación - Los tests deberían sobrevivir refactorizaciones
- 2. Cada test debe testear una cosa - Más fácil de entender y depurar
- 3. Los tests deben ser independientes - Ejecutarse en cualquier orden, no compartir estado
- 4. Los tests rápidos se ejecutan más - Mantén tests unitarios bajo 1 segundo
- 5. Mensajes de falla claros - Deben explicar qué salió mal
- 6. Escribe tests mientras codificas - No guardes testing para después
- 7. Rojo-Verde-Refactorizar - TDD cuando tenga sentido
- 8. Testea casos extremos - Los bugs viven en los límites
- 9. Simula dependencias externas - Controla lo que testeas
- 10. Los tests son documentación - Escríbelos claramente
💡 Puntos Clave
- ✓ Escribe código testeable con acoplamiento suelto e inyección de dependencias
- ✓ Organiza tests en grupos lógicos con nomenclatura clara
- ✓ Siempre testea casos extremos y condiciones de error
- ✓ Arregla tests inestables inmediatamente - erosionan la confianza
- ✓ La cobertura es una herramienta, no una meta - apunta a tests significativos
- ✓ Depura sistemáticamente con console.log y test.only
- ✓ Integra testing en tu pipeline CI/CD
- ✓ Balancea velocidad, costo y confianza a través de tipos de tests
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