Tu red de seguridad para cambios
Imagina que eres cirujano y tienes que operar sin anestesia ni monitores. Cada corte es un riesgo, y no sabes si algo salio mal hasta que es demasiado tarde. Programar sin tests es exactamente asi.
Los tests no son para encontrar bugs. Son para darte la confianza de hacer cambios sin miedo a romper todo.
El costo de los bugs
| Etapa donde se encuentra el bug | Costo relativo |
|---|---|
| Durante desarrollo | 1x |
| Durante code review | 2x |
| Durante QA | 10x |
| En produccion | 100x |
| Despues de perdida de clientes | 1000x |
La piramide de testing
/\
/E2E\ Pocos, lentos, fragiles
/โโโโโโ\ (5% de tus tests)
/Integration\ Balance costo/valor
/โโโโโโโโโโโโโโ\ (15% de tus tests)
/ Unit Tests \ Muchos, rapidos, estables
/โโโโโโโโโโโโโโโโโโ\ (80% de tus tests)
| Tipo | Que prueba | Velocidad | Fragilidad |
|---|---|---|---|
| Unit | Una funcion aislada | ms | Muy estable |
| Integration | Varios modulos juntos | segundos | Moderada |
| E2E | Flujo completo como usuario | minutos | Fragil |
Unit Testing con Vitest
Setup basico
npm install -D vitest @vitest/coverage-v8
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
});
Patron AAA (Arrange, Act, Assert)
// src/utils/price.ts
export function calculateTotal(items: Item[], taxRate: number): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return subtotal * (1 + taxRate);
}
// src/utils/price.test.ts
import { describe, it, expect } from 'vitest';
import { calculateTotal } from './price';
describe('calculateTotal', () => {
it('calcula total con impuesto', () => {
// Arrange - Preparar datos
const items = [
{ price: 100, quantity: 2 },
{ price: 50, quantity: 1 },
];
const taxRate = 0.16;
// Act - Ejecutar funcion
const result = calculateTotal(items, taxRate);
// Assert - Verificar resultado
expect(result).toBe(290); // (200 + 50) * 1.16 = 290
});
it('retorna 0 para carrito vacio', () => {
expect(calculateTotal([], 0.16)).toBe(0);
});
it('maneja tax rate de 0', () => {
const items = [{ price: 100, quantity: 1 }];
expect(calculateTotal(items, 0)).toBe(100);
});
});
Mocking: Cuando y como
// src/services/user.ts
import { db } from './database';
import { sendEmail } from './email';
export async function createUser(email: string, name: string) {
const user = await db.user.create({ data: { email, name } });
await sendEmail(email, 'Welcome!', `Hello ${name}`);
return user;
}
// src/services/user.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createUser } from './user';
import { db } from './database';
import { sendEmail } from './email';
// Mock de modulos externos
vi.mock('./database', () => ({
db: {
user: {
create: vi.fn(),
},
},
}));
vi.mock('./email', () => ({
sendEmail: vi.fn(),
}));
describe('createUser', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('crea usuario y envia email', async () => {
// Arrange
const mockUser = { id: 1, email: 'test@example.com', name: 'Test' };
vi.mocked(db.user.create).mockResolvedValue(mockUser);
vi.mocked(sendEmail).mockResolvedValue(undefined);
// Act
const result = await createUser('test@example.com', 'Test');
// Assert
expect(db.user.create).toHaveBeenCalledWith({
data: { email: 'test@example.com', name: 'Test' },
});
expect(sendEmail).toHaveBeenCalledWith(
'test@example.com',
'Welcome!',
'Hello Test'
);
expect(result).toEqual(mockUser);
});
});
Integration Testing
Testing de APIs con Supertest
// src/app.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from './app';
import { db } from './database';
describe('API /users', () => {
beforeAll(async () => {
// Setup: limpiar y poblar DB de test
await db.user.deleteMany();
await db.user.create({
data: { email: 'existing@test.com', name: 'Existing' },
});
});
afterAll(async () => {
await db.$disconnect();
});
it('GET /users retorna lista de usuarios', async () => {
const response = await request(app)
.get('/users')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toHaveLength(1);
expect(response.body[0].email).toBe('existing@test.com');
});
it('POST /users crea nuevo usuario', async () => {
const response = await request(app)
.post('/users')
.send({ email: 'new@test.com', name: 'New User' })
.expect(201);
expect(response.body.email).toBe('new@test.com');
});
it('POST /users con email duplicado retorna 400', async () => {
const response = await request(app)
.post('/users')
.send({ email: 'existing@test.com', name: 'Duplicate' })
.expect(400);
expect(response.body.error).toContain('already exists');
});
});
Testcontainers: Bases de datos reales
// src/db.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';
describe('Database Integration', () => {
let container;
let prisma;
beforeAll(async () => {
// Iniciar contenedor PostgreSQL real
container = await new PostgreSqlContainer()
.withDatabase('testdb')
.start();
// Conectar Prisma al contenedor
prisma = new PrismaClient({
datasources: {
db: { url: container.getConnectionUri() },
},
});
// Ejecutar migraciones
await prisma.$executeRaw`CREATE TABLE users (id SERIAL, email TEXT, name TEXT)`;
}, 60000); // Timeout largo para iniciar contenedor
afterAll(async () => {
await prisma.$disconnect();
await container.stop();
});
it('inserta y lee usuario de PostgreSQL real', async () => {
await prisma.$executeRaw`INSERT INTO users (email, name) VALUES ('test@db.com', 'DB Test')`;
const users = await prisma.$queryRaw`SELECT * FROM users WHERE email = 'test@db.com'`;
expect(users).toHaveLength(1);
expect(users[0].name).toBe('DB Test');
});
});
E2E Testing con Playwright
Setup
npm init playwright@latest
Test E2E basico
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test('usuario puede hacer login', async ({ page }) => {
// Navegar a la pagina
await page.goto('/login');
// Llenar formulario
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'password123');
// Click en submit
await page.click('[data-testid="submit"]');
// Verificar redireccion a dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Welcome');
});
test('muestra error con credenciales invalidas', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'wrong@example.com');
await page.fill('[data-testid="password"]', 'wrongpass');
await page.click('[data-testid="submit"]');
// Verificar mensaje de error
await expect(page.locator('[data-testid="error"]')).toBeVisible();
await expect(page.locator('[data-testid="error"]')).toContainText('Invalid credentials');
});
});
Page Object Pattern
// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.locator('[data-testid="email"]');
this.passwordInput = page.locator('[data-testid="password"]');
this.submitButton = page.locator('[data-testid="submit"]');
this.errorMessage = page.locator('[data-testid="error"]');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
// e2e/login.spec.ts (usando Page Object)
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('login exitoso', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
TDD: Test Driven Development
El ciclo Red-Green-Refactor
โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ
โ RED โ โโโ โ GREEN โ โโโ โ REFACTOR โ โโโ
โ โ โ โ โ โ โ
โ Escribe โ โ Escribe โ โ Mejora โ โ
โ test que โ โ codigo โ โ codigo โ โ
โ falla โ โ minimo โ โ sin โ โ
โ โ โ para โ โ romper โ โ
โ โ โ pasar โ โ tests โ โ
โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Ejemplo TDD: Validador de password
// Paso 1: RED - Test que falla
// password.test.ts
describe('validatePassword', () => {
it('rechaza password menor a 8 caracteres', () => {
expect(validatePassword('short')).toBe(false);
});
});
// validatePassword no existe aun โ test falla โ
// Paso 2: GREEN - Codigo minimo
// password.ts
export function validatePassword(password: string): boolean {
return password.length >= 8;
}
// Test pasa โ
// Paso 3: RED - Nuevo requisito
it('rechaza password sin mayusculas', () => {
expect(validatePassword('lowercase123')).toBe(false);
});
// Test falla โ
// Paso 4: GREEN
export function validatePassword(password: string): boolean {
if (password.length < 8) return false;
if (!/[A-Z]/.test(password)) return false;
return true;
}
// Paso 5: REFACTOR
export function validatePassword(password: string): boolean {
const rules = [
(p: string) => p.length >= 8,
(p: string) => /[A-Z]/.test(p),
];
return rules.every(rule => rule(password));
}
// Tests siguen pasando โ
Coverage: No te obsesiones
100% coverage es una trampa
// Este codigo tiene 100% coverage...
function add(a, b) {
return a + b;
}
// Con este test...
test('adds numbers', () => {
expect(add(1, 2)).toBe(3);
});
// Pero que pasa con add('1', '2')? โ '12' (bug!)
// Coverage no garantiza calidad
Coverage util vs vanity
| Tipo de codigo | Coverage recomendado |
|---|---|
| Logica de negocio | 80-90% |
| Utilidades | 90%+ |
| UI components | 60-70% |
| Glue code | 30-50% |
Testing en CI/CD
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/test
- name: Run E2E tests
run: npx playwright test
- name: Upload coverage
uses: codecov/codecov-action@v3
Checklist de testing
- Unit tests para logica de negocio (80%+ coverage)
- Integration tests para APIs y DB
- E2E tests para flujos criticos de usuario
- Tests corren en CI antes de merge
- Mocks solo para dependencias externas
- Tests son independientes (no dependen del orden)
- Tests son deterministicos (no flaky)
Practica
-> Testing Fullstack con Vitest + Playwright -> Testing de APIs con Testcontainers