Inicio / TypeScript / Conceptos de Backend / Testing en Backend

Testing en Backend

Pirámide de testing, unit tests con mocks, integration tests, supertest y TDD.

🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

Testing en Backend: Estrategias y Buenas Prácticas

El testing no es opcional en el desarrollo profesional backend. Una buena suite de tests permite refactorizar con confianza, detectar regresiones y documentar el comportamiento del sistema.


La pirámide de testing

        ╱‾‾‾‾‾‾‾‾╲
       ╱  E2E/UI   ╲     ← Pocos (lentos, costosos, frágiles)
      ╱─────────────╲
     ╱  Integration  ╲   ← Moderados (BD real, servicios reales)
    ╱─────────────────╲
   ╱     Unit Tests    ╲ ← Muchos (rápidos, aislados, baratos)
  ╱───────────────────╲

Tests unitarios

Testean una unidad de lógica de forma aislada, con dependencias mockeadas.

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

// Sistema bajo test
class PasswordService {
  validate(password: string): { valid: boolean; errors: string[] } {
    const errors: string[] = [];
    if (password.length < 8)         errors.push('Mínimo 8 caracteres');
    if (!/[A-Z]/.test(password))     errors.push('Necesita mayúscula');
    if (!/[0-9]/.test(password))     errors.push('Necesita número');
    if (!/[!@#$%]/.test(password))   errors.push('Necesita símbolo especial');
    return { valid: errors.length === 0, errors };
  }
}

// Tests
describe('PasswordService.validate', () => {
  const service = new PasswordService();

  it('acepta una contraseña válida', () => {
    const result = service.validate('Segura@123');
    expect(result.valid).toBe(true);
    expect(result.errors).toHaveLength(0);
  });

  it('rechaza contraseñas cortas', () => {
    const result = service.validate('Ab@1');
    expect(result.valid).toBe(false);
    expect(result.errors).toContain('Mínimo 8 caracteres');
  });

  it('rechaza contraseñas sin mayúsculas', () => {
    const result = service.validate('segura@123');
    expect(result.errors).toContain('Necesita mayúscula');
  });

  it('puede tener múltiples errores a la vez', () => {
    const result = service.validate('abc');
    expect(result.errors.length).toBeGreaterThan(1);
  });
});

Mocking de dependencias

interface UserRepository {
  findByEmail(email: string): Promise<User | null>;
  save(data: Omit<User, 'id'>): Promise<User>;
}

class UserService {
  constructor(
    private repo:   UserRepository,
    private mailer: Mailer,
    private hasher: PasswordHasher
  ) {}

  async register(email: string, password: string, name: string): Promise<User> {
    const existing = await this.repo.findByEmail(email);
    if (existing) throw new Error('Email ya registrado');

    const hash = await this.hasher.hash(password);
    const user = await this.repo.save({ email, name, passwordHash: hash });
    await this.mailer.sendWelcome(email, name);
    return user;
  }
}

describe('UserService.register', () => {
  let repo:    ReturnType<typeof vi.mocked<UserRepository>>;
  let mailer:  ReturnType<typeof vi.mocked<Mailer>>;
  let hasher:  ReturnType<typeof vi.mocked<PasswordHasher>>;
  let service: UserService;

  beforeEach(() => {
    // Crear mocks frescos en cada test
    repo   = { findByEmail: vi.fn(), save: vi.fn() };
    mailer = { sendWelcome: vi.fn().mockResolvedValue(undefined) };
    hasher = { hash: vi.fn().mockResolvedValue('hashed_password') };
    service = new UserService(repo, mailer, hasher);
  });

  it('registra un nuevo usuario', async () => {
    repo.findByEmail.mockResolvedValue(null);  // no existe
    repo.save.mockResolvedValue({ id: 1, email: 'ana@test.com', name: 'Ana' });

    const user = await service.register('ana@test.com', 'Pass@123', 'Ana');

    expect(user.id).toBe(1);
    expect(repo.save).toHaveBeenCalledWith({
      email: 'ana@test.com',
      name:  'Ana',
      passwordHash: 'hashed_password',
    });
    expect(mailer.sendWelcome).toHaveBeenCalledWith('ana@test.com', 'Ana');
  });

  it('lanza error si el email ya existe', async () => {
    repo.findByEmail.mockResolvedValue({ id: 1, email: 'ana@test.com', name: 'Ana' });

    await expect(service.register('ana@test.com', 'Pass@123', 'Ana'))
      .rejects.toThrow('Email ya registrado');

    expect(repo.save).not.toHaveBeenCalled();
    expect(mailer.sendWelcome).not.toHaveBeenCalled();
  });
});

Tests de integración

Testean que varios componentes funcionan juntos — normalmente con una base de datos real de test.

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { Pool } from 'pg';

describe('UserRepository (integration)', () => {
  let pool: Pool;
  let repo: PgUserRepository;

  beforeAll(async () => {
    // Conecta a la BD de test (configurada en .env.test)
    pool = new Pool({ connectionString: process.env.DATABASE_URL_TEST });
    repo = new PgUserRepository(pool);

    await pool.query(`
      CREATE TABLE IF NOT EXISTS users (
        id SERIAL PRIMARY KEY,
        email VARCHAR(255) UNIQUE NOT NULL,
        name  VARCHAR(100) NOT NULL
      )
    `);
  });

  beforeEach(async () => {
    // Limpia entre tests
    await pool.query('TRUNCATE users RESTART IDENTITY CASCADE');
  });

  afterAll(async () => {
    await pool.end();
  });

  it('guarda y recupera un usuario', async () => {
    const saved = await repo.save({ email: 'ana@test.com', name: 'Ana' });

    expect(saved.id).toBeTypeOf('number');
    expect(saved.email).toBe('ana@test.com');

    const found = await repo.findById(saved.id);
    expect(found?.name).toBe('Ana');
  });

  it('no permite emails duplicados', async () => {
    await repo.save({ email: 'ana@test.com', name: 'Ana' });

    await expect(repo.save({ email: 'ana@test.com', name: 'Otra Ana' }))
      .rejects.toThrow();  // violación de UNIQUE constraint
  });
});

Tests de API (supertest)

import request from 'supertest';
import { app } from '../src/app';

describe('POST /api/users', () => {
  it('crea un usuario con datos válidos', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ email: 'ana@test.com', name: 'Ana', password: 'Segura@123' })
      .expect(201)
      .expect('Content-Type', /json/);

    expect(res.body.data).toMatchObject({
      email: 'ana@test.com',
      name:  'Ana',
    });
    expect(res.body.data.id).toBeDefined();
    expect(res.body.data.password).toBeUndefined();  // nunca exponer contraseña
  });

  it('retorna 422 con datos inválidos', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ email: 'no-es-email', name: '' })
      .expect(422);

    expect(res.body.error.code).toBe('VALIDATION_ERROR');
    expect(res.body.error.details).toHaveProperty('email');
  });

  it('retorna 409 si el email ya existe', async () => {
    // Primer registro
    await request(app)
      .post('/api/users')
      .send({ email: 'ana@test.com', name: 'Ana', password: 'Segura@123' });

    // Segundo con el mismo email
    const res = await request(app)
      .post('/api/users')
      .send({ email: 'ana@test.com', name: 'Otra', password: 'Segura@123' })
      .expect(409);

    expect(res.body.error.code).toBe('CONFLICT');
  });
});

TDD (Test-Driven Development)

Ciclo Red → Green → Refactor:

// 1. RED: escribe el test que falla
it('calcula descuento por volumen', () => {
  const pricer = new VolumeDiscountPricer();
  expect(pricer.calculate(100, 10)).toBe(850);  // 10 unidades: 15% descuento
  expect(pricer.calculate(100, 5)).toBe(475);   // 5 unidades: 5% descuento
  expect(pricer.calculate(100, 1)).toBe(100);   // sin descuento
});

// 2. GREEN: implementa el mínimo código para que pase
class VolumeDiscountPricer {
  calculate(unitPrice: number, quantity: number): number {
    const discount = quantity >= 10 ? 0.15 : quantity >= 5 ? 0.05 : 0;
    return unitPrice * quantity * (1 - discount);
  }
}

// 3. REFACTOR: mejora el diseño sin romper los tests
class VolumeDiscountPricer {
  private readonly tiers = [
    { minQty: 10, discount: 0.15 },
    { minQty: 5,  discount: 0.05 },
    { minQty: 0,  discount: 0    },
  ];

  calculate(unitPrice: number, quantity: number): number {
    const tier    = this.tiers.find(t => quantity >= t.minQty)!;
    return unitPrice * quantity * (1 - tier.discount);
  }
}

Buenas prácticas de testing

Práctica Descripción
AAA (Arrange-Act-Assert) Estructura clara de cada test
Un assertion por test Cada test verifica una sola cosa
Tests deterministas Sin aleatoriedad ni fechas hardcoded (vi.useFakeTimers)
Limpia el estado beforeEach limpia la BD o los mocks
Nombra los tests como especificaciones it('rechaza emails duplicados', ...)
Testea casos borde null, vacío, número máximo, caracteres especiales
No testees implementación Testea comportamiento externo, no detalles internos
Coverage como guía, no meta 80% de coverage con buenos tests > 100% con tests vacíos
🔒

Ejercicio práctico disponible

Tests para OrderService

Desbloquear ejercicios
// Tests para OrderService
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes