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:
| Nivel | Velocidad de Ejecución | Fiabilidad | Costo |
|---|---|---|---|
| E2E | Lento | Baja | Alto |
| Integración | Media | Media | Medio |
| Unitarias | Rápido | Alta | Bajo |
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
| Tipo | Descripció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:
| Objetivo | Meta |
|---|---|
| General | 80% o más |
| Lógica de negocio importante | 90% o más |
| Funciones utilitarias | 100% |
| Componentes UI | 70% 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ía | Mejores Prácticas |
|---|---|
| Nomenclatura | El nombre de la prueba debe ser “should + comportamiento esperado” |
| Estructura | Usar patrón AAA (Arrange-Act-Assert) |
| Independencia | Eliminar dependencias entre pruebas |
| Velocidad | Mantener las pruebas unitarias rápidas |
| Fiabilidad | Eliminar pruebas flaky |
| Mantenibilidad | Gestionar el código de pruebas como el código de producción |