๐Ÿงช

Testing Profesional

๐Ÿ‘จโ€๐Ÿณ Chef

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 bugCosto relativo
Durante desarrollo1x
Durante code review2x
Durante QA10x
En produccion100x
Despues de perdida de clientes1000x

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)
TipoQue pruebaVelocidadFragilidad
UnitUna funcion aisladamsMuy estable
IntegrationVarios modulos juntossegundosModerada
E2EFlujo completo como usuariominutosFragil

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 codigoCoverage recomendado
Logica de negocio80-90%
Utilidades90%+
UI components60-70%
Glue code30-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


Recursos