TechLead

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

📚 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