Módulo 21: Calidad y testing
Objetivos del módulo
- Entender qué es la calidad del código y aplicar convenciones de legibilidad
- Configurar ESLint (flat config) y Prettier, integrarlos entre sí y con VS Code, y usar EditorConfig
- Documentar con JSDoc y obtener comprobación de tipos en VS Code con
// @ts-check - Dar los primeros pasos con TypeScript y ejecutarlo directamente con Node.js 24
- Conocer los tipos de tests y escribir tests unitarios con Vitest: matchers,
test.each, mocks, temporizadores falsos, código asíncrono, DOM y cobertura - Practicar TDD con un ejemplo paso a paso y conocer Playwright para tests E2E
- Automatizar la calidad con Git hooks (husky + lint-staged) e integración continua con GitHub Actions
1. ¿Por qué preocuparse por la calidad?
📘 Concepto: El código se escribe una vez, pero se lee decenas de veces: tú dentro de seis meses, tus compañeros, quien revise tu pull request. Código de calidad es el que se entiende rápido, hace lo que dice, es fácil de cambiar sin romper otra cosa y tiene pruebas que lo demuestran.
Piensa en una cocina profesional: no basta con que el plato salga rico; los cuchillos están siempre en el mismo sitio, cada recipiente está etiquetado y hay controles de higiene. Las herramientas de este módulo son ese orden y esos controles, pero automatizados.
| Herramienta | Qué vigila | Pregunta que responde |
|---|---|---|
| Convenciones | Nombres y estructura | ¿Se entiende? |
| ESLint (linter) | Errores probables y malas prácticas | ¿Hay algo sospechoso? |
| Prettier (formateador) | Estilo visual: sangrías, comillas, saltos | ¿Tiene el mismo aspecto en todo el proyecto? |
| JSDoc / TypeScript | Tipos de datos | ¿Paso a cada función lo que espera? |
| Tests | Comportamiento | ¿Hace lo que debe, y lo seguirá haciendo? |
| CI (integración continua) | Todo lo anterior en cada cambio | ¿Puedo fusionar esto sin miedo? |
Legibilidad y convenciones
// ❌ Difícil de leer
function c(a, b) { let r = 0; for (let i = 0; i < a.length; i++) if (a[i].t == b) r += a[i].p * a[i].q; return r; }
// ✅ Se explica solo
function calcularTotalPorCategoria(productos, categoria) {
return productos
.filter((producto) => producto.categoria === categoria)
.reduce((total, producto) => total + producto.precio * producto.cantidad, 0);
}
| Elemento | Convención en JavaScript | Ejemplo |
|---|---|---|
| Variables y funciones | camelCase; funciones con verbo | totalPedido, calcularIva() |
| Booleanos | Prefijo es, tiene, puede | esValido, tieneStock |
| Clases | PascalCase | CarritoCompra |
| Constantes globales | MAYUSCULAS_CON_GUIONES | MAX_INTENTOS, URL_API |
| Ficheros de módulos | camelCase.js o kebab-case.js (elige uno) | carritoCompra.js |
| Privados | #campo en clases | #saldo |
Reglas de oro: funciones cortas que hacen una sola cosa, sin “números mágicos” (if (edad >= 18) → if (edad >= EDAD_MINIMA)), retornos tempranos en lugar de if anidados, y comentarios que explican el porqué, no el qué.
2. ESLint: el revisor automático
📘 Concepto: Un linter analiza el código sin ejecutarlo y avisa de errores probables (variables sin usar,
==en vez de===, código inalcanzable…) y de patrones problemáticos. ESLint es el linter estándar de JavaScript. Su versión actual es la 10, que solo admite el formato de configuración moderno o flat config (eslint.config.js).
Instalación con el asistente
npm init @eslint/config@latest
El asistente te pregunta qué quieres analizar (JavaScript, JSON, Markdown, CSS), si tu proyecto usa módulos ES, si utilizas algún framework o TypeScript y si el código se ejecuta en el navegador o en Node. Después instala los paquetes (eslint, @eslint/js, globals) como devDependencies y crea un eslint.config.js con las reglas recomendadas.
⚠️ Importante: Si encuentras tutoriales con ficheros
.eslintrc.jsono.eslintrc.js, son del formato antiguo, que ESLint 10 ya no admite. Tampoco funcionan los comentarios/* eslint-env browser */: ahora los entornos se declaran conglobalsen la configuración.
Entender la flat config
La configuración es un array de objetos. Cada objeto se aplica a los ficheros que indica files, y los posteriores sobrescriben a los anteriores. Esta es una versión completa y comentada de lo que genera el asistente:
// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import { defineConfig, globalIgnores } from 'eslint/config';
export default defineConfig([
// 1. Carpetas que nunca se analizan
globalIgnores(['dist/', 'coverage/']),
// 2. Reglas recomendadas + entorno navegador para el código de la app
{
files: ['**/*.js'],
plugins: { js },
extends: ['js/recommended'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: { ...globals.browser },
},
rules: {
eqeqeq: ['error', 'always'], // obliga a usar === y !==
'no-var': 'error',
'prefer-const': 'error',
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], // permite _parametro
},
},
// 3. Los tests y los ficheros de configuración se ejecutan en Node
{
files: ['test/**/*.js', '*.config.js'],
languageOptions: { globals: { ...globals.node } },
},
]);
| Nivel | Significado |
|---|---|
'off' o 0 | Regla desactivada |
'warn' o 1 | Aviso (no hace fallar el comando) |
'error' o 2 | Error (el comando termina con código 1 y la CI falla) |
Ejecutar ESLint
Con este código en src/malo.js:
var contador = 0;
let nombre = 'Ana';
function saludar(persona, _extra) {
if (persona == null) {
console.log("vacío")
}
return 'Hola ' + persona;
}
const sinUsar = 3;
saludar(nombre);
contador++;
$ npx eslint src/malo.js
src/malo.js
1:1 error Unexpected var, use let or const instead no-var
2:5 error 'nombre' is never reassigned. Use 'const' instead prefer-const
4:15 error Expected '===' and instead saw '==' eqeqeq
5:5 warning Unexpected console statement. Only these console methods are allowed: warn, error no-console
9:7 error 'sinUsar' is assigned a value but never used no-unused-vars
11:1 error 'contador' is assigned a value but never used no-unused-vars
11:1 error The value assigned to 'contador' is not used in subsequent statements no-useless-assignment
✖ 7 problems (6 errors, 1 warning)
2 errors and 0 warnings potentially fixable with the `--fix` option.
Fíjate en que ESLint no se queja de las comillas dobles ni del punto y coma que falta en la línea 5: eso es cosa del formateador. Añade los scripts "lint": "eslint ." y "lint:fix": "eslint . --fix" a tu package.json.
💡 Consejo: Si en un caso concreto necesitas saltarte una regla, hazlo de forma explícita y justificada:
// eslint-disable-next-line no-console -- log de depuración temporal. Nunca desactives reglas “porque molestan” sin entender por qué existen.
Integración con VS Code
Instala la extensión oficial ESLint (Microsoft, dbaeumer.vscode-eslint). Verás los problemas subrayados mientras escribes y podrás corregirlos con Ctrl + .. En la sección siguiente configuraremos que se corrijan solos al guardar.
3. Prettier: formato automático
📘 Concepto: Prettier es un formateador “con opiniones”: reescribe tu código con un estilo uniforme (sangría, comillas, punto y coma, longitud de línea…). Así se acaban las discusiones de estilo en el equipo y los diffs de Git solo muestran cambios reales.
npm install -D prettier
Configúralo con un fichero .prettierrc.json en la raíz:
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}
Los ficheros que no debe tocar van en .prettierignore (misma sintaxis que .gitignore: dist/, coverage/, package-lock.json…).
npx prettier --check . # comprueba (útil en CI): falla si algún fichero no está formateado
npx prettier --write . # formatea todos los ficheros
El fichero de antes, tras prettier --write, queda con comillas simples y punto y coma: console.log('vacío');.
Prettier + ESLint sin conflictos
Cada herramienta a lo suyo: ESLint para la calidad, Prettier para el formato. Para que ninguna regla de ESLint choque con Prettier, añade eslint-config-prettier al final de la configuración:
npm install -D eslint-config-prettier
// eslint.config.js
import eslintConfigPrettier from 'eslint-config-prettier/flat';
// ...
export default defineConfig([
// ...el resto de la configuración
eslintConfigPrettier, // desactiva las reglas de estilo que ya gestiona Prettier
]);
Formatear al guardar en VS Code
Instala la extensión Prettier - Code formatter (esbenp.prettier-vscode) y crea .vscode/settings.json en el proyecto (así lo comparte todo el equipo):
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
Al guardar, ESLint corrige lo que puede y Prettier formatea. Añade también los scripts "format": "prettier --write ." y "format:check": "prettier --check .".
4. EditorConfig
EditorConfig es un fichero .editorconfig en la raíz del proyecto que entienden casi todos los editores (VS Code con su extensión, los IDE de JetBrains de serie). Garantiza lo más básico incluso en ficheros que Prettier no toca:
# .editorconfig
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
💡 Consejo:
end_of_line = lfevita el clásico problema de Windows (CRLF) frente a Linux/macOS (LF), que hace que Git marque todas las líneas como modificadas. Prettier también usalfpor defecto.
5. JSDoc y // @ts-check: tipos sin TypeScript
📘 Concepto: JSDoc es un formato de comentarios
/** ... */para documentar funciones, parámetros y tipos. VS Code lo lee para mostrarte ayuda al escribir y, si añades// @ts-checkal principio del fichero, usa el motor de TypeScript para comprobar los tipos de tu JavaScript sin cambiar de lenguaje.
// @ts-check
/**
* @typedef {Object} Linea
* @property {string} nombre
* @property {number} precio Precio unitario en euros
* @property {number} cantidad
*/
/**
* Calcula el total de un carrito aplicando un descuento opcional.
* @param {Linea[]} lineas
* @param {number} [descuento=0] Porcentaje entre 0 y 100
* @returns {number} Total redondeado a 2 decimales
* @throws {RangeError} Si el descuento no está entre 0 y 100
* @example
* calcularTotal([{ nombre: 'Libro', precio: 20, cantidad: 2 }], 10); // 36
*/
export function calcularTotal(lineas, descuento = 0) {
if (descuento < 0 || descuento > 100) {
throw new RangeError('El descuento debe estar entre 0 y 100');
}
const bruto = lineas.reduce((suma, l) => suma + l.precio * l.cantidad, 0);
return Math.round(bruto * (1 - descuento / 100) * 100) / 100;
}
calcularTotal([{ nombre: 'Lápiz', precio: '1.5', cantidad: 2 }]);
// ~~~~~ Type 'string' is not assignable to type 'number'.
| Etiqueta | Uso |
|---|---|
@param {tipo} nombre | Parámetro ([nombre] si es opcional, [nombre=valor] con valor por defecto) |
@returns {tipo} | Valor devuelto |
@typedef + @property | Define un tipo de objeto reutilizable |
@type {tipo} | Tipo de una variable: /** @type {Map<string, number>} */ |
@throws, @example, @deprecated | Documentación adicional |
Tipos habituales: string, number, boolean, string[], Array<Linea>, Promise<Usuario>, 'claro' | 'oscuro' (unión de literales), ?string (puede ser null), HTMLElement.
Para activar la comprobación en todo el proyecto sin poner // @ts-check en cada fichero, crea un jsconfig.json:
{
"compilerOptions": { "checkJs": true, "target": "esnext", "module": "nodenext", "strict": true },
"include": ["src"]
}
💡 Consejo: JSDoc +
@ts-checkes una excelente puerta de entrada a los tipos: te protege de muchos errores y, si algún día migras a TypeScript, ya tendrás medio trabajo hecho.
6. Introducción a TypeScript
📘 Concepto: TypeScript es JavaScript con anotaciones de tipos. Lo creó Microsoft y es un superconjunto: todo JavaScript válido es TypeScript válido. Los tipos se comprueban antes de ejecutar y después se eliminan: el navegador y Node ejecutan JavaScript normal. Desde TypeScript 7 (julio de 2026), el compilador
tscestá reescrito en Go y es hasta diez veces más rápido.
Tipos básicos e interfaces
// tipos.ts
let titulo: string = 'El Quijote';
let paginas: number = 1250;
let disponible: boolean = true;
let etiquetas: string[] = ['clásico', 'novela'];
let coordenadas: [number, number] = [40.4, -3.7]; // tupla
// Uniones: el valor puede ser de varios tipos
type Tema = 'claro' | 'oscuro';
let tema: Tema = 'claro';
// tema = 'azul'; // ❌ Type '"azul"' is not assignable to type 'Tema'
// Interfaces: la forma de un objeto
interface Libro {
id: number;
titulo: string;
autor: string;
anio?: number; // opcional
readonly isbn: string; // no se puede modificar
}
function describir(libro: Libro): string {
return `${libro.titulo}, de ${libro.autor}${libro.anio ? ` (${libro.anio})` : ''}`;
}
const quijote: Libro = { id: 1, titulo: 'El Quijote', autor: 'Cervantes', isbn: '978-84' };
console.log(describir(quijote)); // 'El Quijote, de Cervantes'
// describir({ id: 2, titulo: 'Sin autor' });
// ❌ Type '{ id: number; titulo: string; }' is missing the following properties from type 'Libro': autor, isbn
// Genéricos: funciones que trabajan con cualquier tipo
function primero<T>(lista: T[]): T | undefined {
return lista[0];
}
const n = primero([10, 20, 30]); // n es de tipo number | undefined
A cambio de algo más de sintaxis y un paso de comprobación, obtienes errores detectados al escribir (no en producción), autocompletado y refactorizaciones fiables y código que se documenta solo.
Ejecutar TypeScript con Node.js 24
Node.js 24 ejecuta ficheros .ts directamente gracias al type stripping (eliminación de tipos), activado por defecto y estable desde Node 24.12:
node tipos.ts
# El Quijote, de Cervantes
⚠️ Importante: Node no comprueba los tipos: solo los borra y ejecuta el resultado. Un error de tipos no impedirá que el programa se ejecute. Para comprobarlos usa
tsc. Además, solo admite sintaxis “borrable”:enum,namespacecon código, propiedades en parámetros del constructor y decoradores dan error. En losimportdebes escribir la extensión:import { x } from './utilidades.ts'.
Comprobar tipos con tsc
npm install -D typescript
npx tsc --init # crea tsconfig.json con opciones recomendadas (incluye "strict": true)
npx tsc --noEmit # comprueba los tipos de todo el proyecto sin generar ficheros
Si descomentas las dos líneas erróneas de tipos.ts:
$ npx tsc --noEmit
tipos.ts(11,1): error TS2322: Type '"azul"' is not assignable to type 'Tema'.
tipos.ts(29,11): error TS2739: Type '{ id: number; titulo: string; }' is missing the following properties from type 'Libro': autor, isbn
Para que tu código funcione tanto con tsc como con el type stripping de Node, el manual de Node recomienda estas opciones en tsconfig.json: "noEmit": true, "erasableSyntaxOnly": true, "verbatimModuleSyntax": true y "rewriteRelativeImportExtensions": true.
TypeScript con Vite
Para el navegador, lo más cómodo es Vite, que entiende TypeScript sin configuración: npm create vite@latest mi-app-ts -- --template vanilla-ts. Vite elimina los tipos al servir y al compilar, y la plantilla incluye tsc en el script de build ("build": "tsc && vite build") para que no se publique nada con errores de tipos.
7. Testing: por qué y de qué tipos
📘 Concepto: Un test automatizado es código que ejecuta tu código y comprueba que el resultado es el esperado. Su gran valor no es encontrar errores hoy, sino detectar mañana que un cambio ha roto algo que funcionaba (una regresión), y darte confianza para refactorizar.
flowchart TB
E2E["E2E (extremo a extremo)<br/>pocos · lentos · muy realistas<br/>Playwright"]
INT["Integración<br/>varios módulos juntos (API + almacenamiento + DOM)"]
UNIT["Unitarios<br/>muchos · rapidísimos · funciones aisladas<br/>Vitest"]
E2E --- INT --- UNIT
| Tipo | Qué prueba | Ejemplo | Velocidad |
|---|---|---|---|
| Unitario | Una función o módulo aislado | calcularTotal() devuelve 44.5 | Milisegundos |
| Integración | Varias piezas juntas | El servicio de libros + fetch simulado + caché | Rápido |
| E2E (end-to-end) | La aplicación entera en un navegador real, como un usuario | Buscar “Quijote” y ver resultados | Segundos |
Es la pirámide de tests: muchos unitarios en la base, menos de integración y unos pocos E2E para los flujos críticos.
Un buen test sigue el patrón AAA: Arrange (preparar los datos), Act (ejecutar) y Assert (comprobar). Y debe ser rápido, independiente de los demás y determinista (mismo resultado siempre, sin depender de la hora, la red o el azar).
8. Vitest
📘 Concepto: Vitest es un framework de tests creado por el equipo de Vite. Reutiliza la configuración de Vite (así que entiende tus
import, alias y TypeScript sin configurar nada), es muy rápido, tiene un modo watch que relanza solo los tests afectados y su API es compatible con Jest, el framework más usado históricamente. La versión actual es la 5.
Instalación y primer test
npm install -D vitest
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"coverage": "vitest run --coverage"
}
}
vitest arranca en modo watch (se queda vigilando los cambios); vitest run ejecuta una vez y termina (lo que necesita la CI). Vitest busca ficheros *.test.js o *.spec.js. Vamos a probar la función calcularTotal de la sección 5, guardada en src/carrito.js:
// test/carrito.test.js
import { describe, it, expect } from 'vitest';
import { calcularTotal } from '../src/carrito.js';
describe('calcularTotal', () => {
const lineas = [
{ nombre: 'Libro', precio: 20, cantidad: 2 },
{ nombre: 'Lápiz', precio: 1.5, cantidad: 3 },
];
it('suma precio por cantidad de cada línea', () => {
expect(calcularTotal(lineas)).toBe(44.5);
});
it('aplica el descuento indicado', () => {
expect(calcularTotal(lineas, 10)).toBe(40.05);
});
it('devuelve 0 con un carrito vacío', () => {
expect(calcularTotal([])).toBe(0);
});
it('lanza RangeError si el descuento no es válido', () => {
expect(() => calcularTotal(lineas, 150)).toThrow(RangeError);
expect(() => calcularTotal(lineas, -1)).toThrow('entre 0 y 100');
});
});
$ npx vitest run --reporter=verbose
✓ test/carrito.test.js > calcularTotal > suma precio por cantidad de cada línea 3ms
✓ test/carrito.test.js > calcularTotal > aplica el descuento indicado 1ms
...
Test Files 1 passed (1)
Tests 4 passed (4)
describe agrupa tests relacionados; it y test son sinónimos y definen un caso; expect(valor) crea una aserción a la que encadenas un matcher.
⚠️ Importante: Para comprobar que algo lanza un error, envuélvelo en una función:
expect(() => calcularTotal(lineas, 150)).toThrow(). Si escribesexpect(calcularTotal(lineas, 150)), el error salta antes de queexpectpueda capturarlo.
Matchers principales
| Matcher | Comprueba | Ejemplo |
|---|---|---|
toBe(valor) | Igualdad estricta (Object.is): primitivos o misma referencia | expect(2 + 2).toBe(4) |
toEqual(obj) | Igualdad profunda (mismo contenido) | expect({ a: 1 }).toEqual({ a: 1 }) |
toStrictEqual(obj) | Como toEqual, pero también compara undefined y clases | |
toBeCloseTo(n) | Decimales aproximados | expect(0.1 + 0.2).toBeCloseTo(0.3) |
toBeTruthy() / toBeFalsy() | Verdadero / falso en sentido amplio | expect('').toBeFalsy() |
toBeNull() / toBeUndefined() / toBeDefined() | null / undefined / no undefined | |
toBeGreaterThan(n) / toBeLessThanOrEqual(n) | Comparaciones numéricas | expect(5).toBeGreaterThan(3) |
toContain(x) / toContainEqual(obj) | Un array o cadena contiene un elemento / un objeto igual | expect(['a', 'b']).toContain('b') |
toHaveLength(n) / toHaveProperty(ruta, valor?) | Longitud / propiedad (admite 'a.b') | expect([1, 2, 3]).toHaveLength(3) |
toMatch(regex) / toMatchObject(obj) | Texto que cumple una regex / objeto con al menos esas propiedades | expect(u).toMatchObject({ nombre: 'Ana' }) |
toBeInstanceOf(Clase) | Instancia de una clase | expect(new Date()).toBeInstanceOf(Date) |
toThrow(msg?) | Lanza un error (con ese tipo o texto) | expect(() => f()).toThrow(RangeError) |
.not | Niega cualquier matcher | expect(x).not.toBeNull() |
expect.any(Tipo) | Cualquier valor de ese tipo (dentro de toEqual) | { creado: expect.any(Date) } |
⚠️ Importante:
toBecon objetos compara referencias:expect({ a: 1 }).toBe({ a: 1 })falla. Para comparar el contenido usatoEqual.
Tests parametrizados con test.each
Cuando quieres probar la misma lógica con muchos datos:
import { test, expect } from 'vitest';
import { slugificar } from '../src/texto.js';
test.each([
['Hola Mundo', 'hola-mundo'],
[' Año Nuevo ', 'ano-nuevo'],
['¿Qué tal, España?', 'que-tal-espana'],
['JavaScript & Node.js', 'javascript-node-js'],
])('slugificar(%s) → %s', (entrada, esperado) => {
expect(slugificar(entrada)).toBe(esperado);
});
Vitest muestra un test por fila, sustituyendo cada %s por los datos: ✓ slugificar(Hola Mundo) → hola-mundo, ✓ slugificar( Año Nuevo ) → ano-nuevo…
Preparación y limpieza: hooks
import { beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
beforeAll(() => { /* una vez antes de todos los tests del fichero o del describe */ });
beforeEach(() => { /* antes de CADA test: reiniciar datos, limpiar el DOM... */ });
afterEach(() => { /* después de cada test: restaurar mocks, temporizadores... */ });
afterAll(() => { /* una vez al final: cerrar conexiones */ });
Mocks: vi.fn y vi.spyOn
📘 Concepto: Un mock (o doble de prueba) es una función o un objeto falso que sustituye a una dependencia real (la red, la base de datos, la fecha…). Sirve para que el test sea rápido y determinista, y para comprobar cómo se ha llamado a esa dependencia.
import { it, expect, vi } from 'vitest';
it('vi.fn crea una función espía configurable', () => {
const tirarDado = vi.fn().mockReturnValueOnce(6).mockReturnValue(1);
expect(tirarDado()).toBe(6); // primera llamada
expect(tirarDado()).toBe(1); // las siguientes
expect(tirarDado).toHaveBeenCalledTimes(2);
expect(tirarDado.mock.calls).toEqual([[], []]); // argumentos de cada llamada
});
La técnica más sencilla para poder usar mocks es la inyección de dependencias: que la función reciba lo que necesita en lugar de usarlo “a fuego”.
// src/usuarios.js
export async function obtenerUsuario(id, fetchFn = fetch) {
const respuesta = await fetchFn(`https://api.ejemplo.com/usuarios/${id}`);
if (!respuesta.ok) throw new Error(`HTTP ${respuesta.status}`);
return respuesta.json();
}
// test/usuarios.test.js
import { describe, it, expect, vi } from 'vitest';
import { obtenerUsuario } from '../src/usuarios.js';
describe('obtenerUsuario', () => {
it('devuelve el usuario si la respuesta es correcta', async () => {
const fetchFalso = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: 1, nombre: 'Ana' }) });
const usuario = await obtenerUsuario(1, fetchFalso);
expect(usuario).toEqual({ id: 1, nombre: 'Ana' });
expect(fetchFalso).toHaveBeenCalledWith('https://api.ejemplo.com/usuarios/1');
});
it('rechaza si el servidor responde con error', async () => {
const fetchFalso = vi.fn().mockResolvedValue({ ok: false, status: 404 });
await expect(obtenerUsuario(99, fetchFalso)).rejects.toThrow('HTTP 404');
});
it('sin inyección, se puede sustituir el fetch global', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: 2 }) }));
await expect(obtenerUsuario(2)).resolves.toEqual({ id: 2 });
vi.unstubAllGlobals();
});
});
vi.spyOn(objeto, 'metodo') espía un método existente (y opcionalmente cambia su comportamiento):
import { it, expect, vi, afterEach } from 'vitest';
import { registro, procesarPedido } from '../src/pedidos.js';
afterEach(() => vi.restoreAllMocks()); // devuelve los métodos originales
it('registra el pedido procesado', () => {
const espia = vi.spyOn(registro, 'info').mockImplementation(() => {}); // silencia el log
const resultado = procesarPedido({ id: 7 });
expect(resultado.estado).toBe('procesado');
expect(espia).toHaveBeenCalledWith('Procesando pedido 7');
});
| Herramienta o matcher | Uso |
|---|---|
vi.fn() + .mockReturnValue(v) / .mockReturnValueOnce(v) | Función falsa y su valor devuelto (siempre / una vez) |
.mockResolvedValue(v) / .mockRejectedValue(e) / .mockImplementation(fn) | Promesa resuelta / rechazada / implementación propia |
vi.spyOn(obj, 'metodo') / vi.restoreAllMocks() | Espía un método real / restaura los originales |
vi.stubGlobal('nombre', valor) | Sustituye una global (fetch, localStorage…) |
vi.mock('./modulo.js') | Sustituye un módulo entero en sus import |
toHaveBeenCalled() / toHaveBeenCalledOnce() / toHaveBeenCalledTimes(n) | Se ha llamado / una vez / n veces |
toHaveBeenCalledWith(...args) / toHaveBeenLastCalledWith(...args) | Alguna llamada / la última, con esos argumentos |
💡 Consejo: En Vitest 5, el historial de llamadas de los mocks se limpia automáticamente antes de cada test (la opción
clearMocksestá activada por defecto), así que un test no “hereda” las llamadas del anterior. Para restaurar los métodos originales devi.spyOnsigue usandovi.restoreAllMocks().
Temporizadores falsos
Probar un debounce de 1 segundo no debería tardar 1 segundo. Con vi.useFakeTimers() controlas el reloj:
// src/autoguardado.js
export function crearAutoguardado(guardar, espera = 1000) {
let temporizador;
return (datos) => {
clearTimeout(temporizador);
temporizador = setTimeout(() => guardar(datos), espera);
};
}
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { crearAutoguardado } from '../src/autoguardado.js';
describe('crearAutoguardado', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('solo guarda una vez, cuando el usuario deja de escribir', () => {
const guardar = vi.fn();
const autoguardar = crearAutoguardado(guardar, 1000);
autoguardar('H');
autoguardar('Ho');
autoguardar('Hola');
vi.advanceTimersByTime(999);
expect(guardar).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(guardar).toHaveBeenCalledOnce();
expect(guardar).toHaveBeenLastCalledWith('Hola');
});
it('permite fijar la fecha actual', () => {
vi.setSystemTime(new Date('2026-12-25T10:00:00Z'));
expect(new Date().toISOString()).toBe('2026-12-25T10:00:00.000Z');
});
});
Desde Vitest 5, vi.useFakeTimers() y vi.setSystemTime() controlan también Temporal.Now, además de Date.
Código asíncrono
it('con async/await', async () => {
expect(await cargarDatos()).toHaveLength(3);
});
it('con resolves / rejects', async () => {
await expect(cargarDatos()).resolves.toHaveLength(3);
await expect(cargarDatos('url-mala')).rejects.toThrow('HTTP 404');
});
⚠️ Importante: No olvides el
awaitdelante deexpect(...).resolveso.rejects. En Vitest 5, una aserción asíncrona sinawaithace fallar el test (antes solo mostraba un aviso).
Probar el DOM: jsdom y happy-dom
Vitest se ejecuta en Node, donde no existe document. Para probar código que toca el DOM, se simula con jsdom o happy-dom (más rápido):
npm install -D jsdom # o: npm install -D happy-dom
Puedes activarlo para un fichero con un comentario al principio, o para todo el proyecto en la configuración (sección siguiente):
// @vitest-environment jsdom
import { describe, it, expect, beforeEach } from 'vitest';
import { renderizarLista } from '../src/lista.js';
describe('renderizarLista', () => {
beforeEach(() => {
document.body.innerHTML = '<ul id="lista"><li>viejo</li></ul>';
});
it('pinta un <li> por elemento y no interpreta HTML (textContent, protección XSS)', () => {
const ul = document.querySelector('#lista');
renderizarLista(ul, ['Leche', '<img src=x onerror=alert(1)>']);
expect([...ul.querySelectorAll('li')].map((li) => li.textContent))
.toEqual(['Leche', '<img src=x onerror=alert(1)>']);
expect(ul.querySelector('img')).toBeNull();
});
});
En estos entornos también existen localStorage, addEventListener, click()… de modo que puedes probar formularios y eventos.
Configuración y cobertura
// vitest.config.js (o dentro de vite.config.js, en la clave "test")
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom', // entorno por defecto para todos los tests
include: ['test/**/*.test.js'],
coverage: {
provider: 'v8',
include: ['src/**/*.js'],
reporter: ['text', 'html'], // tabla en la terminal + informe en coverage/index.html
thresholds: { lines: 80 }, // falla si la cobertura de líneas baja del 80 %
},
},
});
npm install -D @vitest/coverage-v8
npx vitest run --coverage # tabla con % de sentencias, ramas, funciones y líneas por fichero
💡 Consejo: La cobertura indica qué líneas se han ejecutado durante los tests, no si están bien probadas. Un 100 % sin buenas aserciones no vale nada. Úsala para descubrir zonas olvidadas, no como objetivo en sí.
9. TDD paso a paso
📘 Concepto: TDD (Test-Driven Development, desarrollo guiado por pruebas) invierte el orden habitual: primero escribes un test que falla, luego el código mínimo para que pase, y después mejoras el código con la red de seguridad de los tests. Es el ciclo Rojo → Verde → Refactorizar.
flowchart LR
R["🔴 Rojo<br/>escribe un test que falla"] --> V["🟢 Verde<br/>código mínimo para pasarlo"]
V --> F["🔵 Refactorizar<br/>mejora sin cambiar el comportamiento"]
F --> R
Objetivo: una función validarPassword(password) que devuelva { valida, errores } y exija al menos 8 caracteres, un número y una mayúscula.
Ciclo 1: longitud mínima
🔴 Escribimos el test antes de que exista la función:
// test/password.test.js
import { describe, it, expect } from 'vitest';
import { validarPassword } from '../src/password.js';
describe('validarPassword', () => {
it('rechaza contraseñas de menos de 8 caracteres', () => {
const resultado = validarPassword('Ab1');
expect(resultado.valida).toBe(false);
expect(resultado.errores).toContain('Debe tener al menos 8 caracteres');
});
});
Falla (ni siquiera existe el fichero). 🟢 Escribimos lo mínimo para que pase:
// src/password.js
export function validarPassword(password) {
const errores = [];
if (password.length < 8) errores.push('Debe tener al menos 8 caracteres');
return { valida: errores.length === 0, errores };
}
Ciclo 2: al menos un número
🔴 Nuevo test, que falla. 🟢 Añadimos a la función la línea marcada y vuelve a pasar:
it('exige al menos un número', () => {
expect(validarPassword('Abcdefgh').errores).toContain('Debe contener al menos un número');
});
// En src/password.js:
if (!/\d/.test(password)) errores.push('Debe contener al menos un número'); // ← nueva línea
Ciclo 3: al menos una mayúscula (¡también la Ñ!)
🔴 Pensar en los tests nos hace descubrir un caso que habríamos olvidado: las mayúsculas españolas.
it('exige al menos una mayúscula (también Ñ o letras con tilde)', () => {
expect(validarPassword('abcdefg1').errores).toContain('Debe contener al menos una mayúscula');
expect(validarPassword('ñandúes1Ñ').valida).toBe(true);
});
Si implementamos con /[A-Z]/, el segundo expect falla: Ñ no está entre A y Z. Usamos la propiedad Unicode \p{Lu} (letra mayúscula) del módulo 8 y la prueba pasa.
Refactorizar
🔵 Tres if casi iguales: convertimos las reglas en datos. Los tests garantizan que no rompemos nada:
// src/password.js — versión final
const REGLAS = [
{ cumple: (p) => p.length >= 8, mensaje: 'Debe tener al menos 8 caracteres' },
{ cumple: (p) => /\d/.test(p), mensaje: 'Debe contener al menos un número' },
{ cumple: (p) => /\p{Lu}/u.test(p), mensaje: 'Debe contener al menos una mayúscula' },
];
/** @param {string} password @returns {{ valida: boolean, errores: string[] }} */
export function validarPassword(password) {
const errores = REGLAS.filter((regla) => !regla.cumple(password)).map((regla) => regla.mensaje);
return { valida: errores.length === 0, errores };
}
Y completamos la batería:
it('devuelve todos los errores a la vez', () => {
expect(validarPassword('abc').errores).toHaveLength(3);
});
it.each(['Segura123', 'OtraClave9', 'MAYUSCULAS1'])('acepta %s', (password) => {
expect(validarPassword(password)).toEqual({ valida: true, errores: [] });
});
Añadir una regla nueva (por ejemplo, “un símbolo”) es ahora añadir un test y una línea al array.
💡 Consejo: TDD brilla en la lógica de negocio (cálculos, validaciones, transformaciones de datos). Para interfaces que cambian a menudo, muchos equipos escriben los tests justo después del código. Lo importante es que el test exista y que lo hayas visto fallar alguna vez: un test que nunca falla puede no estar comprobando nada.
10. Tests E2E con Playwright
Playwright (de Microsoft) controla navegadores reales (Chromium, Firefox y WebKit, el motor de Safari) para probar la aplicación completa como lo haría un usuario.
npm init playwright@latest # instala @playwright/test, los navegadores y un ejemplo
// e2e/busqueda.spec.js
import { test, expect } from '@playwright/test';
test('buscar un libro muestra resultados', async ({ page }) => {
await page.goto('http://localhost:5173/');
await page.getByLabel('Buscar libros').fill('quijote');
await page.getByRole('button', { name: 'Buscar' }).click();
await expect(page.getByRole('heading', { name: /Quijote/ }).first()).toBeVisible();
});
Se ejecutan con npx playwright test (en los tres navegadores) o con npx playwright test --ui (modo visual para depurar paso a paso).
💡 Consejo: Fíjate en que los selectores (
getByLabel,getByRole) son los mismos que usa un lector de pantalla: si tu aplicación es accesible, es fácil de probar.
11. Automatizar: Git hooks e integración continua
Git hooks con husky y lint-staged
Un Git hook es un script que Git ejecuta automáticamente en ciertos momentos, como antes de cada commit. husky facilita configurarlos y lint-staged ejecuta las herramientas solo sobre los ficheros que vas a commitear (rápido incluso en proyectos grandes).
npm install -D husky lint-staged
npx husky init # crea .husky/pre-commit y añade el script "prepare": "husky"
Sustituye el contenido de .husky/pre-commit por npx lint-staged y añade a package.json:
{
"lint-staged": {
"*.js": ["eslint --fix", "prettier --write"],
"*.{css,html,json,md}": "prettier --write"
}
}
Ahora, si intentas commitear código con errores de ESLint, el commit se cancela hasta que lo arregles.
⚠️ Importante: Los hooks son locales y cualquiera puede saltárselos (
git commit --no-verify). Son una ayuda, no una garantía: la garantía es la integración continua.
Integración continua con GitHub Actions
📘 Concepto: La integración continua (CI) ejecuta automáticamente el lint y los tests en un servidor limpio cada vez que alguien sube código o abre un pull request. Si algo falla, GitHub lo marca en rojo y se puede impedir la fusión.
Crea el fichero .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
calidad:
runs-on: ubuntu-latest
steps:
- name: Descargar el código
uses: actions/checkout@v7
- name: Instalar Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- name: Instalar dependencias
run: npm ci
- name: Lint
run: npm run lint
- name: Formato
run: npm run format:check
- name: Tests con cobertura
run: npx vitest run --coverage
on indica cuándo se ejecuta (cada push a main y cada pull request), runs-on la máquina virtual, uses una acción reutilizable (descargar el repositorio, instalar Node…), run un comando de terminal, y cache: npm reutiliza la caché de npm entre ejecuciones. Sube el fichero, y en la pestaña Actions del repositorio verás cada ejecución. En Settings → Branches puedes crear una regla que exija que la CI pase antes de fusionar en main.
💡 Consejo: Por seguridad, muchas organizaciones fijan las acciones por su hash de commit en lugar de por etiqueta (
actions/checkout@<sha>), porque una etiqueta se puede mover a código distinto. Dependabot puede mantener esos hashes actualizados.
12. Ejercicios
Ejercicio 1: Poner orden en un proyecto
Coge uno de tus proyectos anteriores, añade ESLint (con el asistente), Prettier, eslint-config-prettier y EditorConfig. Corrige todos los errores, explica por escrito tres de ellos y configura VS Code para formatear y corregir al guardar.
Ejercicio 2: Tipos con JSDoc
Documenta con JSDoc un módulo de al menos cinco funciones (con @typedef para los objetos), añade // @ts-check y corrige los errores de tipos que aparezcan. Después, conviértelo a TypeScript (.ts), ejecútalo con node y compruébalo con npx tsc --noEmit.
Ejercicio 3: Batería de tests para utilidades
Escribe con Vitest tests para formatearPrecio(centimos), capitalizar(texto) y agruparPor(array, clave), con al menos un test.each por función y casos límite (vacíos, negativos, tildes). Consigue una cobertura del 100 % y explica si eso garantiza que no hay errores.
Ejercicio 4: Mocks de red
Crea buscarLibros(termino, fetchFn) que llame a una API y devuelva un array normalizado. Prueba con vi.fn(): la URL construida (con encodeURIComponent), la respuesta correcta, un error HTTP 500, un error de red (mockRejectedValue) y que no se llame a fetch si el término está vacío.
Ejercicio 5: TDD de un carrito
Con TDD estricto (test primero, siempre), implementa una clase Carrito con añadir(producto, cantidad), quitar(id), total() y aplicarCupon(codigo) (DESC10 = 10 %, ENVIOGRATIS). Haz un commit por cada ciclo rojo-verde-refactorizar.
Ejercicio 6: Probar un componente del DOM y automatizarlo todo
Escribe montarContador(contenedor), que cree un botón y un <output>, y pruébala con happy-dom: estado inicial, que click() incremente el valor y que se guarde en localStorage.
Ejercicio 7: Pipeline completo
Configura husky + lint-staged y un workflow de GitHub Actions que ejecute lint, formato y tests con un umbral de cobertura del 80 %. Abre un pull request con un test que falle a propósito, comprueba que la CI lo detecta y arréglalo.
Resumen
| Concepto | Comando / sintaxis |
|---|---|
| Configurar ESLint | npm init @eslint/config@latest → eslint.config.js |
| Flat config | defineConfig([{ files, extends: ['js/recommended'], languageOptions, rules }]) |
| Ejecutar ESLint | npx eslint . / npx eslint . --fix |
| Prettier | .prettierrc.json, npx prettier --write . / --check .; eslint-config-prettier/flat al final |
| VS Code y EditorConfig | editor.formatOnSave + source.fixAll.eslint; .editorconfig |
| JSDoc | /** @param {number} a @returns {number} */ + // @ts-check |
| TypeScript en Node 24 | node fichero.ts (sin comprobar tipos) + npx tsc --noEmit |
| Test en Vitest | describe, it/test, expect(x).toBe(y); toEqual compara contenido |
| Errores | expect(() => f()).toThrow(RangeError) |
| Parametrizados | test.each([[a, b], ...])('nombre %s', (a, b) => ...) |
| Mocks | vi.fn().mockResolvedValue(v), vi.spyOn(obj, 'm'), vi.stubGlobal |
| Temporizadores | vi.useFakeTimers(), vi.advanceTimersByTime(ms), vi.setSystemTime() |
| Asíncrono | await expect(p).resolves... / .rejects.toThrow() |
| DOM | // @vitest-environment jsdom o environment: 'happy-dom' |
| Cobertura | npx vitest run --coverage (@vitest/coverage-v8) |
| TDD | Rojo → Verde → Refactorizar |
| E2E | npm init playwright@latest, npx playwright test |
| Git hooks | npx husky init + lint-staged en package.json |
| CI | .github/workflows/ci.yml con npm ci, lint y vitest run |