Patrones de Diseño de Estrategias de Pruebas - Desde la Pirámide de Pruebas hasta el Diseño Práctico

Avanzado | 2025.12.02

La Importancia de la Estrategia de Pruebas

Para garantizar la calidad del software, una estrategia de pruebas adecuada es indispensable. Un diseño de pruebas eficiente permite un desarrollo sostenible mientras equilibra la velocidad de desarrollo con la calidad.

flowchart TB
    subgraph Purpose["Propósitos de las Pruebas"]
        subgraph QA["1. Aseguramiento de Calidad (Quality Assurance)"]
            QA1["Detección temprana de bugs"]
            QA2["Prevención de regresiones"]
            QA3["Verificación de especificaciones"]
        end
        subgraph Design["2. Mejora del Diseño (Design Improvement)"]
            D1["Mejora de la testeabilidad"]
            D2["Promoción del acoplamiento débil"]
            D3["Clarificación de interfaces"]
        end
        subgraph Docs["3. Documentación"]
            Doc1["Especificaciones ejecutables"]
            Doc2["Ejemplos de uso"]
            Doc3["Explicación de casos límite"]
        end
    end

Pirámide de Pruebas

flowchart TB
    subgraph Pyramid["Pirámide de Pruebas"]
        E2E["Pruebas E2E<br/>(Pocas, alto costo)"]
        Integration["Pruebas de Integración<br/>(Cantidad media)"]
        Unit["Pruebas Unitarias<br/>(Muchas, bajo costo)"]

        E2E --> Integration --> Unit
    end

Características:

NivelVelocidad de EjecuciónFiabilidadCosto
E2ELentoBajaAlto
IntegraciónMediaMediaMedio
UnitariasRápidoAltaBajo

Pruebas Unitarias

Principios Básicos (F.I.R.S.T)

// Pruebas unitarias siguiendo los principios F.I.R.S.T

// Fast (Rápido) - Las pruebas deben ejecutarse rápidamente
// Independent (Independiente) - No debe haber dependencias entre pruebas
// Repeatable (Repetible) - Debe dar el mismo resultado cada vez que se ejecuta
// Self-Validating (Auto-validación) - Éxito/fallo debe ser claro
// Timely (Oportuno) - Escribir antes o después del código de producción

import { describe, it, expect, beforeEach } from 'vitest';

// Función utilitaria a probar
function calculateTax(price: number, taxRate: number): number {
  if (price < 0) throw new Error('Price cannot be negative');
  if (taxRate < 0 || taxRate > 1) throw new Error('Invalid tax rate');
  return Math.round(price * taxRate);
}

describe('calculateTax', () => {
  // Casos normales
  it('should calculate tax correctly', () => {
    expect(calculateTax(1000, 0.1)).toBe(100);
  });

  it('should round to nearest integer', () => {
    expect(calculateTax(999, 0.1)).toBe(100); // 99.9 → 100
  });

  // Pruebas de valores límite
  it('should return 0 for zero price', () => {
    expect(calculateTax(0, 0.1)).toBe(0);
  });

  it('should handle zero tax rate', () => {
    expect(calculateTax(1000, 0)).toBe(0);
  });

  it('should handle 100% tax rate', () => {
    expect(calculateTax(1000, 1)).toBe(1000);
  });

  // Casos de error
  it('should throw for negative price', () => {
    expect(() => calculateTax(-100, 0.1)).toThrow('Price cannot be negative');
  });

  it('should throw for invalid tax rate', () => {
    expect(() => calculateTax(1000, 1.5)).toThrow('Invalid tax rate');
    expect(() => calculateTax(1000, -0.1)).toThrow('Invalid tax rate');
  });
});

Patrón AAA

// Patrón Arrange-Act-Assert

import { describe, it, expect } from 'vitest';

class ShoppingCart {
  private items: Array<{ name: string; price: number; quantity: number }> = [];

  addItem(name: string, price: number, quantity: number = 1): void {
    this.items.push({ name, price, quantity });
  }

  removeItem(name: string): void {
    this.items = this.items.filter(item => item.name !== name);
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  getItemCount(): number {
    return this.items.reduce((sum, item) => sum + item.quantity, 0);
  }
}

describe('ShoppingCart', () => {
  it('should calculate total correctly', () => {
    // Arrange (Preparar)
    const cart = new ShoppingCart();
    cart.addItem('Apple', 100, 3);
    cart.addItem('Banana', 80, 2);

    // Act (Actuar)
    const total = cart.getTotal();

    // Assert (Afirmar)
    expect(total).toBe(460); // 100*3 + 80*2
  });

  it('should remove item correctly', () => {
    // Arrange
    const cart = new ShoppingCart();
    cart.addItem('Apple', 100, 1);
    cart.addItem('Banana', 80, 1);

    // Act
    cart.removeItem('Apple');

    // Assert
    expect(cart.getTotal()).toBe(80);
    expect(cart.getItemCount()).toBe(1);
  });
});

Test Doubles

flowchart TB
    subgraph TestDoubles["Tipos de Test Doubles"]
        subgraph Stub["1. Stub"]
            S1["Devuelve valores predefinidos"]
            S2["Simula dependencias externas"]
            S3["Solo controla la salida para una entrada"]
        end
        subgraph Mock["2. Mock"]
            M1["Verifica llamadas esperadas"]
            M2["Confirma número de llamadas y argumentos"]
            M3["Se usa para verificar comportamiento"]
        end
        subgraph Spy["3. Spy"]
            Sp1["Registra llamadas mientras usa la implementación real"]
            Sp2["Permite verificar llamadas después"]
            Sp3["Mock parcial"]
        end
        subgraph Fake["4. Fake"]
            F1["Versión simplificada de la implementación"]
            F2["Como DB en memoria, etc."]
            F3["Misma interfaz que producción"]
        end
    end

Ejemplos de Implementación de Test Doubles

import { describe, it, expect, vi, beforeEach } from 'vitest';

// Interfaces de dependencias
interface EmailService {
  send(to: string, subject: string, body: string): Promise<boolean>;
}

interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

interface User {
  id: string;
  email: string;
  name: string;
}

// Clase a probar
class UserService {
  constructor(
    private userRepo: UserRepository,
    private emailService: EmailService
  ) {}

  async notifyUser(userId: string, message: string): Promise<boolean> {
    const user = await this.userRepo.findById(userId);
    if (!user) {
      throw new Error('User not found');
    }

    return this.emailService.send(
      user.email,
      'Notification',
      message
    );
  }
}

describe('UserService', () => {
  let userRepo: UserRepository;
  let emailService: EmailService;
  let userService: UserService;

  beforeEach(() => {
    // Crear Stub
    userRepo = {
      findById: vi.fn(),
      save: vi.fn(),
    };

    // Crear Mock
    emailService = {
      send: vi.fn(),
    };

    userService = new UserService(userRepo, emailService);
  });

  it('should send notification to user', async () => {
    // Configurar Stub (devuelve valores predefinidos)
    const mockUser: User = {
      id: '1',
      email: 'test@example.com',
      name: 'Test User',
    };
    vi.mocked(userRepo.findById).mockResolvedValue(mockUser);
    vi.mocked(emailService.send).mockResolvedValue(true);

    // Ejecutar
    const result = await userService.notifyUser('1', 'Hello!');

    // Verificar resultado
    expect(result).toBe(true);

    // Verificar Mock (confirmar llamadas)
    expect(emailService.send).toHaveBeenCalledWith(
      'test@example.com',
      'Notification',
      'Hello!'
    );
    expect(emailService.send).toHaveBeenCalledTimes(1);
  });

  it('should throw error when user not found', async () => {
    // Configurar Stub (devuelve null)
    vi.mocked(userRepo.findById).mockResolvedValue(null);

    // Verificar excepción
    await expect(userService.notifyUser('999', 'Hello!'))
      .rejects.toThrow('User not found');

    // Confirmar que emailService no fue llamado
    expect(emailService.send).not.toHaveBeenCalled();
  });
});

// Ejemplo de Fake (repositorio en memoria)
class FakeUserRepository implements UserRepository {
  private users: Map<string, User> = new Map();

  async findById(id: string): Promise<User | null> {
    return this.users.get(id) ?? null;
  }

  async save(user: User): Promise<void> {
    this.users.set(user.id, user);
  }

  // Métodos auxiliares para pruebas
  seed(users: User[]): void {
    users.forEach(user => this.users.set(user.id, user));
  }

  clear(): void {
    this.users.clear();
  }
}

Pruebas de Integración

// Pruebas de integración de API
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createServer, Server } from 'http';
import { app } from '../src/app';

describe('API Integration Tests', () => {
  let server: Server;
  let baseURL: string;

  beforeAll(async () => {
    server = createServer(app);
    await new Promise<void>(resolve => {
      server.listen(0, () => resolve());
    });
    const address = server.address();
    const port = typeof address === 'object' ? address?.port : 0;
    baseURL = `http://localhost:${port}`;
  });

  afterAll(async () => {
    await new Promise<void>(resolve => server.close(() => resolve()));
  });

  describe('POST /api/users', () => {
    it('should create a new user', async () => {
      const response = await fetch(`${baseURL}/api/users`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: 'Test User',
          email: 'test@example.com',
        }),
      });

      expect(response.status).toBe(201);

      const data = await response.json();
      expect(data).toMatchObject({
        name: 'Test User',
        email: 'test@example.com',
      });
      expect(data.id).toBeDefined();
    });

    it('should return 400 for invalid data', async () => {
      const response = await fetch(`${baseURL}/api/users`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: '', // Nombre vacío
        }),
      });

      expect(response.status).toBe(400);
    });
  });

  describe('GET /api/users/:id', () => {
    it('should return user by id', async () => {
      // Crear usuario previamente
      const createResponse = await fetch(`${baseURL}/api/users`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: 'Get Test',
          email: 'get@example.com',
        }),
      });
      const created = await createResponse.json();

      // Obtener el usuario creado
      const response = await fetch(`${baseURL}/api/users/${created.id}`);
      expect(response.status).toBe(200);

      const data = await response.json();
      expect(data.id).toBe(created.id);
    });

    it('should return 404 for non-existent user', async () => {
      const response = await fetch(`${baseURL}/api/users/non-existent-id`);
      expect(response.status).toBe(404);
    });
  });
});

TDD (Desarrollo Guiado por Pruebas)

flowchart TB
    subgraph TDD["Ciclo TDD (Red-Green-Refactor)"]
        Red["RED<br/>(Fallo)"]
        Green["GREEN<br/>(Éxito)"]
        Refactor["REFACTOR<br/>(Mejora)"]

        Red -->|"Escribir prueba que falla"| Green
        Green -->|"Pasar con código mínimo"| Refactor
        Refactor -->|"Mejorar el código"| Red
    end

Ejemplo Práctico de TDD

// Paso 1: RED - Escribir prueba que falla
describe('StringCalculator', () => {
  it('should return 0 for empty string', () => {
    const calc = new StringCalculator();
    expect(calc.add('')).toBe(0);
  });
});

// Paso 2: GREEN - Pasar con código mínimo
class StringCalculator {
  add(numbers: string): number {
    return 0;
  }
}

// Paso 3: Añadir siguiente prueba (RED)
describe('StringCalculator', () => {
  it('should return 0 for empty string', () => {
    const calc = new StringCalculator();
    expect(calc.add('')).toBe(0);
  });

  it('should return number for single number', () => {
    const calc = new StringCalculator();
    expect(calc.add('1')).toBe(1);
    expect(calc.add('5')).toBe(5);
  });
});

// Paso 4: GREEN - Pasar la prueba
class StringCalculator {
  add(numbers: string): number {
    if (numbers === '') return 0;
    return parseInt(numbers, 10);
  }
}

// Paso 5: Añadir más pruebas
describe('StringCalculator', () => {
  // ... pruebas anteriores

  it('should return sum for multiple numbers', () => {
    const calc = new StringCalculator();
    expect(calc.add('1,2')).toBe(3);
    expect(calc.add('1,2,3')).toBe(6);
  });

  it('should handle newlines as delimiter', () => {
    const calc = new StringCalculator();
    expect(calc.add('1\n2,3')).toBe(6);
  });

  it('should support custom delimiter', () => {
    const calc = new StringCalculator();
    expect(calc.add('//;\n1;2')).toBe(3);
  });

  it('should throw for negative numbers', () => {
    const calc = new StringCalculator();
    expect(() => calc.add('-1,2')).toThrow('Negatives not allowed: -1');
  });
});

// Implementación final
class StringCalculator {
  add(numbers: string): number {
    if (numbers === '') return 0;

    let delimiter = /,|\n/;
    let numString = numbers;

    // Procesar delimitador personalizado
    if (numbers.startsWith('//')) {
      const delimiterEnd = numbers.indexOf('\n');
      delimiter = new RegExp(numbers.slice(2, delimiterEnd));
      numString = numbers.slice(delimiterEnd + 1);
    }

    const nums = numString.split(delimiter).map(n => parseInt(n, 10));

    // Verificar números negativos
    const negatives = nums.filter(n => n < 0);
    if (negatives.length > 0) {
      throw new Error(`Negatives not allowed: ${negatives.join(', ')}`);
    }

    return nums.reduce((sum, n) => sum + n, 0);
  }
}

Estrategia de Cobertura

Tipos de Cobertura

TipoDescripción
Cobertura de Sentencias (Statement)Si cada línea fue ejecutada
Cobertura de Ramas (Branch)Si cada rama (if/else) fue ejecutada
Cobertura de Funciones (Function)Si cada función fue llamada
Cobertura de Líneas (Line)Si cada línea fue ejecutada

Objetivos Recomendados:

ObjetivoMeta
General80% o más
Lógica de negocio importante90% o más
Funciones utilitarias100%
Componentes UI70% o más

Nota: La cobertura es solo uno de los indicadores de calidad. Incluso con 100%, pueden existir bugs.

Configuración de Cobertura en Vitest

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'tests/',
        '**/*.d.ts',
        '**/*.config.*',
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80,
      },
    },
  },
});

Estrategia de Pruebas Frontend

// Pruebas de componentes React
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';

// Componente a probar
function LoginForm({ onSubmit }: { onSubmit: (data: { email: string; password: string }) => void }) {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    onSubmit({
      email: formData.get('email') as string,
      password: formData.get('password') as string,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Email
        <input type="email" name="email" required />
      </label>
      <label>
        Password
        <input type="password" name="password" required />
      </label>
      <button type="submit">Login</button>
    </form>
  );
}

describe('LoginForm', () => {
  it('should render form fields', () => {
    render(<LoginForm onSubmit={() => {}} />);

    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
  });

  it('should call onSubmit with form data', async () => {
    const user = userEvent.setup();
    const handleSubmit = vi.fn();

    render(<LoginForm onSubmit={handleSubmit} />);

    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /login/i }));

    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
    });
  });
});

Mejores Prácticas

CategoríaMejores Prácticas
NomenclaturaEl nombre de la prueba debe ser “should + comportamiento esperado”
EstructuraUsar patrón AAA (Arrange-Act-Assert)
IndependenciaEliminar dependencias entre pruebas
VelocidadMantener las pruebas unitarias rápidas
FiabilidadEliminar pruebas flaky
MantenibilidadGestionar el código de pruebas como el código de producción

Enlaces de Referencia

← Volver a la lista