Tema
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).
| Tipo | Qué prueba | Velocidad | Herramienta |
|---|---|---|---|
| Unitaria | Una función aislada | Muy rápida | Vitest |
| Integración | Varias piezas juntas (ruta + controlador + BD) | Media | Vitest + Supertest |
| End-to-end (E2E) | La app completa como un usuario | Lenta | Playwright |
La pirámide de testing: muchas unitarias, varias de integración, pocas E2E.
Estructura de un test (patrón AAA):
- Arrange (preparar): datos y estado inicial.
- Act (actuar): ejecutar lo que pruebas.
- Assert (afirmar): verificar el resultado.
Instalación
bash
npm i -D vitest supertestjson
{
"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 testtxt
✓ 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 appjs
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:
- Repositorios falsos (mocks) para unitarias.
- Una base de datos de pruebas separada (
.env.testcon otraDATABASE_URL), limpiando tablas enbeforeEach. - 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:covMuestra 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 testEjercicios
- 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. - Agrega a los tests de tareas un caso para
GET /api/tareasque 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()yvi.mock()para simular dependencias. - Supertest:
request(app).get(...)prueba endpoints sin abrir puertos;request.agentconserva cookies. - Separa
app.jsdeindex.jsy usa una base de datos de pruebas aislada. - Automatiza los tests con GitHub Actions en cada push.