Skip to content

Testing ​

Los tests son código que verifica que tu código funciona. Te permiten cambiar y refactorizar sin miedo a romper algo, y son un requisito en equipos profesionales. Usarás Vitest para pruebas unitarias y Supertest para probar endpoints de Express sin abrir un puerto.

Concepto ​

Analogía: antes de entregar un coche, el fabricante prueba frenos, luces y motor por separado (pruebas unitarias) y luego lo maneja en pista (pruebas de integración).

TipoQué pruebaVelocidadHerramienta
UnitariaUna función aisladaMuy rápidaVitest
IntegraciónVarias piezas juntas (ruta + controlador + BD)MediaVitest + Supertest
End-to-end (E2E)La app completa como un usuarioLentaPlaywright

La pirámide de testing: muchas unitarias, varias de integración, pocas E2E.

Estructura de un test (patrón AAA):

  1. Arrange (preparar): datos y estado inicial.
  2. Act (actuar): ejecutar lo que pruebas.
  3. Assert (afirmar): verificar el resultado.

Instalación ​

bash
npm i -D vitest supertest
json
{
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:cov": "vitest run --coverage"
  }
}

INFO

Node también trae un test runner integrado (node --test con node:test y node:assert). Vitest es más cómodo (watch, mocks, cobertura, misma API que Jest) y es el que usan React y Vue, así aprendes una sola herramienta.

Pruebas unitarias ​

js
export function calcularTotal(items, { iva = 0.16, cupon } = {}) {
  if (!Array.isArray(items)) throw new TypeError('items debe ser un arreglo')

  const subtotal = items.reduce((suma, item) => {
    if (item.cantidad <= 0) throw new RangeError('La cantidad debe ser mayor a 0')
    return suma + item.precio * item.cantidad
  }, 0)

  const descuento = cupon === 'BIENVENIDA10' ? subtotal * 0.1 : 0
  const total = (subtotal - descuento) * (1 + iva)
  return Math.round(total * 100) / 100
}

export function slugify(texto) {
  return texto
    .normalize('NFD')
    .replace(/[̀-ͯ]/g, '') // quita acentos
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
}
js
import { describe, it, expect } from 'vitest'
import { calcularTotal, slugify } from './precios.js'

describe('calcularTotal', () => {
  it('suma precio por cantidad y aplica IVA', () => {
    // Arrange
    const items = [{ precio: 100, cantidad: 2 }, { precio: 50, cantidad: 1 }]
    // Act
    const total = calcularTotal(items)
    // Assert
    expect(total).toBe(290) // 250 * 1.16
  })

  it('aplica el cupón BIENVENIDA10', () => {
    expect(calcularTotal([{ precio: 100, cantidad: 1 }], { cupon: 'BIENVENIDA10', iva: 0 })).toBe(90)
  })

  it('devuelve 0 con carrito vacío', () => {
    expect(calcularTotal([])).toBe(0)
  })

  it('lanza error si la cantidad es inválida', () => {
    expect(() => calcularTotal([{ precio: 10, cantidad: 0 }])).toThrow(RangeError)
  })

  it('lanza error si items no es arreglo', () => {
    expect(() => calcularTotal(null)).toThrow('items debe ser un arreglo')
  })
})

describe('slugify', () => {
  it.each([
    ['Hola Mundo', 'hola-mundo'],
    ['  Café con Leche ', 'cafe-con-leche'],
    ['¡Oferta 2x1!', 'oferta-2x1']
  ])('convierte "%s" en "%s"', (entrada, esperado) => {
    expect(slugify(entrada)).toBe(esperado)
  })
})
bash
npm test
txt
 ✓ src/utils/precios.test.js (8 tests) 4ms
 Test Files  1 passed (1)
      Tests  8 passed (8)

Matchers más usados ​

js
expect(valor).toBe(5)                    // igualdad estricta (===)
expect(obj).toEqual({ a: 1 })            // igualdad profunda de objetos/arrays
expect(obj).toMatchObject({ a: 1 })      // contiene al menos esas propiedades
expect(lista).toHaveLength(3)
expect(lista).toContain('ana')
expect(texto).toMatch(/hola/i)
expect(valor).toBeNull()
expect(valor).toBeDefined()
expect(numero).toBeGreaterThan(10)
expect(fn).toThrow()
await expect(promesa).resolves.toBe(1)
await expect(promesa).rejects.toThrow('error')

Mocks: simular dependencias ​

Para probar una función que envía emails o llama a otra API, no quieres enviar emails reales. Los mocks reemplazan esas dependencias.

js
import { enviarEmail } from './email.service.js'

export async function registrarUsuario(repo, datos) {
  const existe = await repo.buscarPorEmail(datos.email)
  if (existe) throw new Error('Email duplicado')
  const usuario = await repo.crear(datos)
  await enviarEmail(usuario.email, '¡Bienvenido!')
  return usuario
}
js
export async function enviarEmail(para, asunto) {
  // aquí iría Resend, Nodemailer, etc.
  throw new Error('No llamar en tests')
}
js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { registrarUsuario } from './registro.service.js'
import { enviarEmail } from './email.service.js'

// Reemplaza el módulo completo por funciones simuladas
vi.mock('./email.service.js', () => ({
  enviarEmail: vi.fn().mockResolvedValue(true)
}))

describe('registrarUsuario', () => {
  let repo

  beforeEach(() => {
    vi.clearAllMocks()
    // Repositorio falso (inyección de dependencias)
    repo = {
      buscarPorEmail: vi.fn().mockResolvedValue(null),
      crear: vi.fn().mockImplementation(async (d) => ({ id: 1, ...d }))
    }
  })

  it('crea el usuario y envía email de bienvenida', async () => {
    const usuario = await registrarUsuario(repo, { email: 'ana@correo.com' })

    expect(usuario).toEqual({ id: 1, email: 'ana@correo.com' })
    expect(repo.crear).toHaveBeenCalledOnce()
    expect(enviarEmail).toHaveBeenCalledWith('ana@correo.com', '¡Bienvenido!')
  })

  it('rechaza emails duplicados sin enviar correo', async () => {
    repo.buscarPorEmail.mockResolvedValue({ id: 9 })

    await expect(registrarUsuario(repo, { email: 'ana@correo.com' })).rejects.toThrow('Email duplicado')
    expect(enviarEmail).not.toHaveBeenCalled()
  })
})

Pruebas de integración con Supertest ​

Gracias a que separamos app.js de index.js (lección 9), Supertest puede usar la app sin listen:

js
import express from 'express'

const app = express()
app.use(express.json())

let tareas = []
let id = 1

app.get('/api/tareas', (req, res) => res.json(tareas))

app.post('/api/tareas', (req, res) => {
  const { titulo } = req.body ?? {}
  if (!titulo) return res.status(400).json({ error: 'El título es obligatorio' })
  const tarea = { id: id++, titulo, hecha: false }
  tareas.push(tarea)
  res.status(201).json(tarea)
})

app.delete('/api/tareas/:id', (req, res) => {
  const antes = tareas.length
  tareas = tareas.filter((t) => t.id !== Number(req.params.id))
  if (tareas.length === antes) return res.status(404).json({ error: 'No encontrada' })
  res.sendStatus(204)
})

// Sólo para tests: reiniciar el estado
export function _reiniciar() {
  tareas = []
  id = 1
}

export default app
js
import { describe, it, expect, beforeEach } from 'vitest'
import request from 'supertest'
import app, { _reiniciar } from '../src/app.js'

describe('API de tareas', () => {
  beforeEach(() => _reiniciar())

  it('GET /api/tareas devuelve un arreglo vacío al inicio', async () => {
    const res = await request(app).get('/api/tareas')

    expect(res.status).toBe(200)
    expect(res.body).toEqual([])
  })

  it('POST /api/tareas crea una tarea', async () => {
    const res = await request(app)
      .post('/api/tareas')
      .send({ titulo: 'Escribir tests' }) // envía JSON

    expect(res.status).toBe(201)
    expect(res.headers['content-type']).toMatch(/json/)
    expect(res.body).toMatchObject({ id: 1, titulo: 'Escribir tests', hecha: false })
  })

  it('POST sin título responde 400', async () => {
    const res = await request(app).post('/api/tareas').send({})
    expect(res.status).toBe(400)
    expect(res.body.error).toBeDefined()
  })

  it('DELETE elimina y luego responde 404', async () => {
    await request(app).post('/api/tareas').send({ titulo: 'Temporal' })

    await request(app).delete('/api/tareas/1').expect(204)
    await request(app).delete('/api/tareas/1').expect(404)
  })
})

Probar rutas con autenticación ​

js
import request from 'supertest'
import app from '../src/app.js'

it('flujo de registro, perfil y logout', async () => {
  // agent conserva las cookies entre peticiones, como un navegador
  const agente = request.agent(app)

  await agente
    .post('/api/auth/registro')
    .send({ nombre: 'Ana', email: 'ana@test.com', password: 'Secreta123' })
    .expect(201)

  const perfil = await agente.get('/api/auth/perfil').expect(200)
  expect(perfil.body.usuario.email).toBe('ana@test.com')
  expect(perfil.body.usuario.passwordHash).toBeUndefined()

  await agente.post('/api/auth/logout').expect(204)
  await agente.get('/api/auth/perfil').expect(401)
})

Base de datos en tests ​

Opciones, de más simple a más realista:

  1. Repositorios falsos (mocks) para unitarias.
  2. Una base de datos de pruebas separada (.env.test con otra DATABASE_URL), limpiando tablas en beforeEach.
  3. Contenedores efímeros con Testcontainers (levanta un PostgreSQL real por suite).
js
// vitest.config.js
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'node',
    setupFiles: ['./tests/setup.js'],  // ej. cargar .env.test y limpiar la BD
    fileParallelism: false,             // si comparten la misma BD
    coverage: { provider: 'v8', include: ['src/**'] }
  }
})
js
// tests/setup.js
process.loadEnvFile('.env.test')

Cobertura ​

bash
npm i -D @vitest/coverage-v8
npm run test:cov

Muestra qué porcentaje de líneas ejecutaron tus tests. Un 70–80 % en la lógica de negocio es una meta razonable; el 100 % no garantiza ausencia de errores.

Buena práctica

  • Prueba comportamiento (lo que devuelve la API), no detalles internos.
  • Cada test debe ser independiente: reinicia el estado en beforeEach.
  • Escribe un test que reproduzca cada bug antes de arreglarlo: así no vuelve.
  • Ejecuta los tests en cada push con GitHub Actions.

Error común

Olvidar await en un test asíncrono. El test termina antes de que la promesa falle y pasa en verde aunque esté mal. Siempre await request(...) y await expect(...).rejects.

GitHub Actions: tests automáticos ​

yaml
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test

Ejercicios ​

  1. Escribe tests para una función validarPassword(pw) que devuelva un arreglo de errores: mínimo 8 caracteres, una mayúscula y un número.
  2. Agrega a los tests de tareas un caso para GET /api/tareas que verifique que, tras crear dos tareas, la respuesta tiene longitud 2.
Ver solución
js
export function validarPassword(pw = '') {
  const errores = []
  if (pw.length < 8) errores.push('Mínimo 8 caracteres')
  if (!/[A-Z]/.test(pw)) errores.push('Debe incluir una mayúscula')
  if (!/[0-9]/.test(pw)) errores.push('Debe incluir un número')
  return errores
}
js
import { describe, it, expect } from 'vitest'
import { validarPassword } from './validarPassword.js'

describe('validarPassword', () => {
  it('acepta una contraseña válida', () => {
    expect(validarPassword('Secreta123')).toEqual([])
  })

  it('detecta todos los errores a la vez', () => {
    expect(validarPassword('abc')).toEqual([
      'Mínimo 8 caracteres',
      'Debe incluir una mayúscula',
      'Debe incluir un número'
    ])
  })

  it('detecta falta de número', () => {
    expect(validarPassword('SecretaLarga')).toContain('Debe incluir un número')
  })
})
js
it('lista las tareas creadas', async () => {
  await request(app).post('/api/tareas').send({ titulo: 'Una' })
  await request(app).post('/api/tareas').send({ titulo: 'Dos' })

  const res = await request(app).get('/api/tareas')
  expect(res.body).toHaveLength(2)
  expect(res.body.map((t) => t.titulo)).toEqual(['Una', 'Dos'])
})

Resumen ​

  • Vitest: describe, it, expect; vi.fn() y vi.mock() para simular dependencias.
  • Supertest: request(app).get(...) prueba endpoints sin abrir puertos; request.agent conserva cookies.
  • Separa app.js de index.js y usa una base de datos de pruebas aislada.
  • Automatiza los tests con GitHub Actions en cada push.

Academia Web — aprende, copia y construye.