El testing es una de las habilidades que más diferencian a un desarrollador junior de uno senior, y sin embargo es uno de los temas más ignorados en los tutoriales de programación. Esta guía te enseña a escribir tests de verdad en JavaScript y TypeScript con Vitest, la herramienta de testing más moderna y rápida del ecosistema, combinada con Testing Library para tests de componentes de UI.
¿Por qué testear tu código?
Los tests no son burocracia. Son una inversión. Un conjunto de tests bien escritos te permite refactorizar código con confianza, detectar regresiones antes de que lleguen a producción, documentar el comportamiento esperado del sistema y onboardear a nuevos desarrolladores de forma más rápida. Los proyectos sin tests se convierten, con el tiempo, en proyectos que nadie se atreve a tocar por miedo a romper algo.
Tipos de tests: la pirámide del testing
Existen tres tipos principales de tests, organizados en una pirámide. En la base están los tests unitarios: rápidos, baratos, prueban una función o módulo de forma aislada. En el medio están los tests de integración: prueban cómo interactúan varios módulos entre sí. En la cima están los tests end-to-end (E2E): lentos, caros, prueban flujos completos desde la perspectiva del usuario en un navegador real.
La pirámide sugiere que deberías tener muchos tests unitarios, algunos de integración y pocos E2E. En la práctica, el balance depende del proyecto, pero la idea central es correcta: los tests más baratos de escribir y mantener son los unitarios, por lo que deben ser la base.
Vitest: el testing runner moderno
Vitest es un framework de testing ultra-rápido construido sobre Vite. Compatible con la API de Jest, pero mucho más rápido gracias a que usa el mismo pipeline de transformación que Vite. Si usas Vite en tu proyecto (y en 2026 casi todo el mundo lo hace), usar Vitest es una obviedad.
# Instalar Vitest
npm install -D vitest
# Para proyectos con UI (React, Vue, etc.)
npm install -D @vitest/ui @vitest/coverage-v8
# Configurar en vite.config.ts o vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom', // Simula un navegador
globals: true, // describe, it, expect globales (sin importar)
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules/', 'dist/']
}
}
})
# Añadir scripts al package.json
# "test": "vitest",
# "test:ui": "vitest --ui",
# "test:coverage": "vitest run --coverage"
Tu primer test con Vitest
Un test tiene tres partes bien definidas conocidas como el patrón AAA: Arrange (preparar el entorno), Act (ejecutar la acción) y Assert (verificar el resultado). Mantener esta estructura hace que los tests sean legibles.
// utils/calculos.ts - Código a testear
export function sumar(a: number, b: number): number {
return a + b
}
export function calcularDescuento(precio: number, porcentaje: number): number {
if (porcentaje < 0 || porcentaje > 100) {
throw new Error('El porcentaje debe estar entre 0 y 100')
}
return precio * (1 - porcentaje / 100)
}
export function formatearPrecio(precio: number, moneda = 'EUR'): string {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: moneda
}).format(precio)
}
// utils/calculos.test.ts - Los tests
import { describe, it, expect } from 'vitest'
import { sumar, calcularDescuento, formatearPrecio } from './calculos'
describe('sumar', () => {
it('suma dos números positivos correctamente', () => {
// Arrange
const a = 2
const b = 3
// Act
const resultado = sumar(a, b)
// Assert
expect(resultado).toBe(5)
})
it('suma números negativos', () => {
expect(sumar(-1, -2)).toBe(-3)
})
it('suma cero con cualquier número devuelve ese número', () => {
expect(sumar(0, 42)).toBe(42)
expect(sumar(42, 0)).toBe(42)
})
})
describe('calcularDescuento', () => {
it('aplica un descuento del 20% correctamente', () => {
expect(calcularDescuento(100, 20)).toBe(80)
})
it('con 0% de descuento devuelve el precio original', () => {
expect(calcularDescuento(99.99, 0)).toBe(99.99)
})
it('con 100% de descuento devuelve 0', () => {
expect(calcularDescuento(50, 100)).toBe(0)
})
it('lanza un error si el porcentaje es negativo', () => {
expect(() => calcularDescuento(100, -10)).toThrow('El porcentaje debe estar entre 0 y 100')
})
it('lanza un error si el porcentaje supera 100', () => {
expect(() => calcularDescuento(100, 110)).toThrow()
})
})
describe('formatearPrecio', () => {
it('formatea en euros por defecto', () => {
const resultado = formatearPrecio(1234.56)
expect(resultado).toContain('1.234,56')
expect(resultado).toContain('€')
})
})
Matchers de Vitest: todas las formas de hacer assertions
Los matchers son las funciones que van después de expect() y definen qué estás comprobando. Vitest es compatible con los matchers de Jest, así que hay muchos:
// Igualdad
expect(2 + 2).toBe(4) // Igualdad estricta (===)
expect({ a: 1 }).toEqual({ a: 1 }) // Igualdad profunda de objetos
expect(valor).toStrictEqual(esperado) // Como toEqual pero más estricto
// Verdad/falsedad
expect(true).toBeTruthy()
expect(false).toBeFalsy()
expect(null).toBeNull()
expect(undefined).toBeUndefined()
expect('hola').toBeDefined()
// Números
expect(3.14).toBeCloseTo(3.141, 2) // Aproximación decimal
expect(10).toBeGreaterThan(5)
expect(3).toBeLessThanOrEqual(3)
// Strings
expect('hola mundo').toContain('mundo')
expect('error: algo falló').toMatch(/error:/i)
// Arrays y objetos
expect([1, 2, 3]).toContain(2)
expect([1, 2, 3]).toHaveLength(3)
expect({ nombre: 'Ana', edad: 28 }).toMatchObject({ nombre: 'Ana' })
// Errores
expect(() => lanzarError()).toThrow()
expect(() => lanzarError()).toThrow('mensaje específico')
expect(() => lanzarError()).toThrow(TypeError)
// Negación con .not
expect(5).not.toBe(3)
expect([]).not.toContain(1)
Mocks y Spies: aislar dependencias
Los mocks te permiten reemplazar dependencias reales (llamadas a API, base de datos, servicios externos) con versiones falsas y controladas. Esto hace que tus tests sean rápidos, deterministas y que no dependan de servicios externos.
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Función que queremos testear
async function obtenerUsuario(id: number) {
const res = await fetch(`https://api.example.com/users/${id}`)
if (!res.ok) throw new Error('Usuario no encontrado')
return res.json()
}
describe('obtenerUsuario', () => {
beforeEach(() => {
// Limpiar mocks antes de cada test
vi.restoreAllMocks()
})
it('devuelve el usuario cuando la API responde bien', async () => {
// Mock de fetch
const usuarioMock = { id: 1, nombre: 'Ana', email: 'ana@test.com' }
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(usuarioMock)
} as unknown as Response)
const usuario = await obtenerUsuario(1)
expect(usuario).toEqual(usuarioMock)
expect(fetch).toHaveBeenCalledWith('https://api.example.com/users/1')
expect(fetch).toHaveBeenCalledTimes(1)
})
it('lanza error cuando la API responde con error', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 404
} as Response)
await expect(obtenerUsuario(999)).rejects.toThrow('Usuario no encontrado')
})
})
// Mock de un módulo completo
vi.mock('./emailService', () => ({
enviarEmail: vi.fn().mockResolvedValue({ enviado: true })
}))
// Mock de módulos de Node
vi.mock('fs', () => ({
readFileSync: vi.fn().mockReturnValue('contenido del archivo')
})
Testing Library: testear componentes UI
Testing Library es una familia de librerías para testear componentes de UI (React, Vue, Svelte, etc.) desde la perspectiva del usuario. Su filosofía es: «cuanto más se parezcan tus tests a cómo el usuario usa tu software, más confianza te darán». En lugar de testear detalles de implementación, testas lo que el usuario ve e interactúa.
# Para React
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
# Para Vue
npm install -D @testing-library/vue @testing-library/user-event
// Componente React a testear
// components/LoginForm.tsx
import { useState } from 'react'
interface Props {
onLogin: (email: string, password: string) => Promise
}
export function LoginForm({ onLogin }: Props) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [cargando, setCargando] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!email || !password) {
setError('Todos los campos son obligatorios')
return
}
setCargando(true)
try {
await onLogin(email, password)
} catch {
setError('Credenciales incorrectas')
} finally {
setCargando(false)
}
}
return (
<form onSubmit={handleSubmit}>
<h1>Iniciar sesión</h1>
{error && <p role="alert">{error}</p>}
<label>
Email
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
</label>
<label>
Contraseña
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</label>
<button type="submit" disabled={cargando}>
{cargando ? 'Cargando...' : 'Entrar'}
</button>
</form>
)
}
// components/LoginForm.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'
describe('LoginForm', () => {
it('renderiza el formulario correctamente', () => {
render(<LoginForm onLogin={vi.fn()} />)
expect(screen.getByRole('heading', { name: /iniciar sesión/i })).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/contraseña/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /entrar/i })).toBeInTheDocument()
})
it('muestra error cuando los campos están vacíos', async () => {
const user = userEvent.setup()
render(<LoginForm onLogin={vi.fn()} />)
await user.click(screen.getByRole('button', { name: /entrar/i }))
expect(screen.getByRole('alert')).toHaveTextContent('Todos los campos son obligatorios')
})
it('llama a onLogin con email y password cuando el formulario es válido', async () => {
const user = userEvent.setup()
const onLoginMock = vi.fn().mockResolvedValue(undefined)
render(<LoginForm onLogin={onLoginMock} />)
await user.type(screen.getByLabelText(/email/i), 'usuario@test.com')
await user.type(screen.getByLabelText(/contraseña/i), 'password123')
await user.click(screen.getByRole('button', { name: /entrar/i }))
await waitFor(() => {
expect(onLoginMock).toHaveBeenCalledWith('usuario@test.com', 'password123')
})
})
it('muestra el estado de carga durante el login', async () => {
const user = userEvent.setup()
const onLoginMock = vi.fn(() => new Promise(() => {})) // Nunca resuelve
render(<LoginForm onLogin={onLoginMock} />)
await user.type(screen.getByLabelText(/email/i), 'test@test.com')
await user.type(screen.getByLabelText(/contraseña/i), '123456')
await user.click(screen.getByRole('button', { name: /entrar/i }))
expect(screen.getByRole('button', { name: /cargando/i })).toBeDisabled()
})
it('muestra error de credenciales cuando el login falla', async () => {
const user = userEvent.setup()
const onLoginMock = vi.fn().mockRejectedValue(new Error('401'))
render(<LoginForm onLogin={onLoginMock} />)
await user.type(screen.getByLabelText(/email/i), 'mal@test.com')
await user.type(screen.getByLabelText(/contraseña/i), 'malpassword')
await user.click(screen.getByRole('button', { name: /entrar/i }))
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Credenciales incorrectas')
})
})
})
Queries de Testing Library: cómo seleccionar elementos
Testing Library tiene un orden de prioridad para seleccionar elementos. El objetivo es seleccionar los elementos de la misma forma que lo haría un usuario o una tecnología de accesibilidad.
La prioridad de queries es la siguiente, de más a menos recomendada: getByRole (por rol ARIA), getByLabelText (por label de formulario), getByPlaceholderText, getByText (por texto visible), getByAltText (por alt de imagen), getByTitle y finalmente getByTestId (solo como último recurso).
// getByRole: la query más recomendada
screen.getByRole('button', { name: /enviar/i })
screen.getByRole('heading', { level: 1 })
screen.getByRole('textbox', { name: /email/i })
screen.getByRole('checkbox', { name: /acepto/i })
screen.getByRole('link', { name: /ir al inicio/i })
// getByLabelText: ideal para inputs de formularios
screen.getByLabelText('Nombre completo')
screen.getByLabelText(/email/i)
// Variantes: getBy (lanza error si no existe), queryBy (null si no existe), findBy (async)
const boton = screen.queryByRole('button', { name: /cancelar/i })
if (boton) { /* existe */ }
const elemento = await screen.findByText('Cargando...') // Espera a que aparezca
Tests asíncronos y waitFor
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'
import { vi } from 'vitest'
it('carga y muestra los productos', async () => {
// Mock de fetch
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 1, nombre: 'Producto A', precio: 29.99 },
{ id: 2, nombre: 'Producto B', precio: 49.99 }
])
} as unknown as Response)
render(<ListaProductos />)
// Esperar a que el loading desaparezca
await waitForElementToBeRemoved(() => screen.queryByText(/cargando/i))
// Verificar que los productos aparecieron
expect(screen.getByText('Producto A')).toBeInTheDocument()
expect(screen.getByText('Producto B')).toBeInTheDocument()
expect(screen.getByText('29,99 €')).toBeInTheDocument()
})
it('muestra un mensaje de error cuando falla la carga', async () => {
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'))
render(<ListaProductos />)
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
})
})
Cobertura de código: ¿cuánto es suficiente?
La cobertura de código mide qué porcentaje de tu código es ejecutado por tus tests. Vitest la genera fácilmente:
# Ejecutar tests con cobertura
npx vitest run --coverage
# Ver informe HTML detallado
npx vitest --ui
Una advertencia importante: la cobertura al 100% no garantiza que tus tests sean buenos. Puedes ejecutar código sin hacer assertions útiles. Un 80% de cobertura con tests bien escritos es mejor que un 100% con tests superficiales. Lo que importa es que testes los comportamientos importantes de tu código: los casos felices, los casos de error y los casos límite.
Setup y teardown: beforeEach, afterEach, beforeAll, afterAll
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from 'vitest'
describe('Suite de tests con setup', () => {
let conexionDB: MockDB
// Se ejecuta una vez antes de todos los tests del describe
beforeAll(async () => {
conexionDB = await crearConexionDB()
})
// Se ejecuta antes de CADA test
beforeEach(() => {
vi.useFakeTimers() // Simular el tiempo
limpiarDB()
})
// Se ejecuta después de CADA test
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
// Se ejecuta una vez después de todos los tests
afterAll(async () => {
await conexionDB.cerrar()
})
it('test 1', () => { /* ... */ })
it('test 2', () => { /* ... */ })
})
Integrar tests en tu flujo de trabajo es tan importante como escribirlos. Configura Vitest en modo --watch durante el desarrollo para que los tests se ejecuten automáticamente al guardar. Añade los tests como parte de tu CI/CD para que ningún código roto llegue a producción. El testing no es algo que haces al final: es algo que haces mientras programas.