Ir al contenido

Sesión 6 — Testing Backend

Bloque: Testing Backend (cierre de toda la parte de backend del curso) Fecha: Lunes 14 de diciembre · 15:30–19:30 (4h) Stack: Node.js ≥ 24, TypeScript, Vitest 5, Supertest 7, Prisma 7 Continúa de: Sesión 5 — OWASP API Security Top 10 y hardening


Al terminar, el alumno debe ser capaz de:

  1. Explicar la pirámide de testing y por qué no todos los tests deben ser del mismo tipo.
  2. Configurar Vitest con una base de datos de test aislada de la de desarrollo (guarda, globalSetup y limpieza entre tests).
  3. Escribir tests unitarios de la capa de servicios mockeando el repositorio y comprobando qué error se lanza.
  4. Escribir tests de integración con Supertest contra la API real (sin levantar un puerto).
  5. Testear rutas protegidas por JWT generando tokens válidos en los tests.
  6. Evaluar si una suite de tests (por ejemplo, generada por IA) protege de algo de verdad, usando una mutación.

Han pasado casi dos semanas desde la sesión 5. Hoy no se añade funcionalidad nueva a AcademIA — se testea todo lo que ya existe: CRUD, autenticación, autorización (RBAC + ownership) y las protecciones de la sesión 5.



Punto de partida, en vuestro clon de academia-api: sigue en tu rama. Si los checks del inicio de la sesión fallan o vas tarde, usa la receta “Cambiar de rama”. Lo que hoy testeamos es lo que escribisteis en S1–S5. Primero la base de datos: docker compose up -d (PostgreSQL 16 · plan B sin Docker: npx prisma dev). Windows: todo en Git Bash (no PowerShell), hoy y en todo el bloque.

Checks del inicio: npm run seed, la API recién arrancada (npm run dev) y, en otra terminal, npx tsx checks/s5.ts → 16 ok, 0 fallos (si sale 429, reinicia la API); npm run typecheck sin errores.

Hoy casi todo se copia de la web (web del curso → Sesión 6 → Chuleta): la infraestructura de test, los helpers y los tests que repiten patrón llevan un ID 📋 S6·NN, el mismo que veis en la slide. A mano (✍️) solo cuatro tests, los que enseñan la idea: el 403 y el del dueño en el unitario, el primer test con Supertest y el PATCH de ownership.

Si fallan o vas tarde, receta Cambiar de rama hacia s6-start (en un clon las ramas solo existen como remotas, origin/…):

📋 S6·01 · receta «Cambiar de rama» a s6-start
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)
git fetch origin
git switch -c mi-s6 origin/s6-start # checkpoint: mi-s6-cp origin/s6-checkpoint
npm install
npx prisma generate
npx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seed

Si vienes de tu propia rama, migrate dev pedirá reset: es lo esperado (tus migraciones tienen otro timestamp); después, npm run seed. Si el commit dice nothing to commit, no pasa nada: sigue. Sin él, quien trae cambios sin guardar de la sesión 5 (por ejemplo en enrollments.controller.ts) ve “Your local changes … would be overwritten by checkout” y git no cambia de rama. npx prisma generate no es opcional: la carpeta generated/prisma está en .gitignore, así que tras cambiar de rama o en un clon nuevo no existe, y cualquier cosa que importe src/db.ts (la app, y en un rato los tests) falla con “Cannot find module ‘../generated/prisma/client.js’”.

Si vais tarde o venís de s6-checkpoint (la suite de hoy ya resuelta, ver §8): la misma receta con la rama del checkpoint, y después preparad la DB de test:

📋 S6·02 · receta al checkpoint + DB de test
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)
git fetch origin
git switch -c mi-s6-cp origin/s6-checkpoint
npm install
npx prisma generate
npx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seed
cp .env.test.example .env.test # DB de test separada: academia_test
npm test # 3 ficheros, 14 tests en verde

npm install es imprescindible aquí: s6-checkpoint trae vitest y supertest nuevos; sin él, “vitest: command not found”.

Lo que hoy damos por sabido (repaso S1–S5)

Sección titulada «Lo que hoy damos por sabido (repaso S1–S5)»

Han pasado dos semanas. Esto es lo que vuelve a aparecer hoy:

De Lo que usaremos hoy Dónde sale
S1 price es Decimal → .toNumber() para compararlo PATCH: el precio no cambió (§6)
S2 FKs Enrollment → Course → User y email @unique Orden de resetDb (§3.5), seq en createUser (§5.2)
S3 Capas ruta → controller → service → repository · app.ts exporta la app sin listen() · NotFoundError 404, ForbiddenError 403 (ambos AppError) · prefijo /api/v1 Unitarios (§4), Supertest (§5)
S4 JWT con sub (string) y role · 401 = sin autenticar, 403 = sin permiso · regla «dueño O ADMIN» · hashPassword (Argon2id) · login con el mismo error Unitarios (§4), rutas protegidas y login (§6)
S5 z.strictObject() → 400 con campos extra · el instructor sale como { id, name } · rate limiter en el login §5.3 y §6

Concepto nuevo de hoy que no ha salido nunca: el mock (§4).

Por qué importa testear una API y no solo “probarla a mano con Postman”:

  • Un test corre en segundos, de forma repetible, y no depende de que alguien recuerde probar el caso límite.
  • Detecta regresiones: cuando cambiéis algo de la sesión 5 dentro de 3 meses, sabréis al instante si rompisteis la autorización de la sesión 4.
  • Es documentación viva: un test bien nombrado explica qué se espera del sistema mejor que un comentario.

/\
/e2e\ ← pocos, lentos, cubren el flujo completo
/------\
/integr. \ ← testean varias piezas juntas (API + DB real)
/----------\
/ unit \ ← muchos, rápidos, aíslan una sola pieza (mocks)
/--------------\
Tipo Qué prueba Velocidad Confianza que da Hoy
Unit Una función/clase aislada, dependencias mockeadas Muy rápida (ms) Baja-media: prueba la lógica en aislamiento, no la integración real Sí — capa de servicios
Integration Varias capas juntas (rutas → controller → service → repo → DB real) Media (decenas-cientos de ms) Alta: prueba el comportamiento real del sistema Sí — con Supertest
E2E Todo el sistema desplegado, desde fuera Lenta (segundos) Muy alta, pero frágil y cara de mantener No hoy

Regla práctica: muchos tests unitarios rápidos en la base de la pirámide, menos tests de integración cubriendo los flujos críticos, y E2E solo para los caminos más importantes de todo el sistema. (En backend, E2E no exige navegador: peticiones HTTP contra una API desplegada también lo son. Supertest arranca la app en local, en un puerto efímero y sin desplegar nada, así que hoy nos quedamos en integración.)


3. Setup de Vitest y base de datos de test aislada

Sección titulada «3. Setup de Vitest y base de datos de test aislada»

Las piezas de esta sección son un solo concepto: una base de datos de test que no puede confundirse con la de desarrollo (la guarda), que se crea y migra sola (globalSetup) y que se vacía antes de cada test (resetDb). Es infraestructura: se copia entera de la web (📋 S6·03 a S6·12), en este orden, y la leemos juntos fichero a fichero. No se teclea nada en esta sección.

📋 S6·03 · instalar Vitest y Supertest (terminal)
npm install -D vitest@5 @vitest/coverage-v8@5 \
supertest@7 @types/supertest

Vitest 5 pide Node ≥ 22.12; el proyecto ya exige Node ≥ 24 desde la sesión 1. dotenv y pg ya están instalados.

Los tres scripts, sin abrir package.json (como npm pkg set en la sesión 1):

📋 S6·04 · scripts test, test:watch y test:coverage (terminal)
npm pkg set scripts.test="vitest run" scripts.test:watch="vitest" scripts.test:coverage="vitest run --coverage"

En package.json quedan así:

"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"

Ejecutad ya npm test, antes de escribir nada más:

No test files found, exiting with code 1

Es lo esperado: Vitest está instalado y el script funciona, pero todavía no hay ningún *.test.ts. Mientras no haya tests, Vitest no ejecuta nada más — ni la guarda ni el globalSetup que vamos a añadir — así que de momento no puede tocar ninguna base de datos. Los veremos actuar en §4.2, con el primer test.

3.2 El problema: nada carga .env.test por sí solo

Sección titulada «3.2 El problema: nada carga .env.test por sí solo»

Los tests van a hacer deleteMany() antes de cada caso. Si corren contra la base de datos de desarrollo, os borran los datos con los que estáis trabajando. Y aquí está la trampa: Vitest no lee .env.test por sí solo, y src/config.ts carga .env (import 'dotenv/config'). Sin hacer nada, los tests apuntarían a academia, la de desarrollo.

Cread .env.test en la raíz con este contenido (en s6-checkpoint ya viene como .env.test.example):

📋 S6·05 · .env.test (en la raíz)
# .env.test — en la raíz del proyecto
DATABASE_URL="postgresql://academia:academia@localhost:5432/academia_test"
JWT_SECRET="secreto-solo-para-tests-de-al-menos-32-caracteres"
NODE_ENV="test"

Copiad la URL entera: si falta academia:academia@, la guarda la deja pasar (termina en _test) pero el globalSetup rompe con “SASL: … client password must be a string”. Y comprobad que git no lo va a subir:

📋 S6·06 · comprobar que git ignora .env.test (terminal)
# si git aún no lo ignora (tu .gitignore de la S1 no lo tenía), lo añade
git check-ignore -q .env.test || echo ".env.test" >> .gitignore
git check-ignore -v .env.test # → .gitignore:N:.env.test .env.test

La solución del repo: una función que carga .env.test por encima de lo que haya y se niega a seguir si la URL no acaba en _test. Leedla con calma: la condición del if es la guarda.

📋 S6·07 · test/load-test-env.ts completo
// test/load-test-env.ts
import { config } from 'dotenv';
// Carga .env.test por encima de cualquier variable ya definida y se niega a
// seguir si la URL no apunta a una base de datos *_test: los tests hacen
// deleteMany() y no queremos vaciar la base de datos de desarrollo.
export function loadTestEnv() {
config({ path: '.env.test', override: true, quiet: true });
const url = process.env.DATABASE_URL ?? '';
if (!/\/\w+_test(\?|$)/.test(url)) {
throw new Error(
`DATABASE_URL de test no termina en _test: "${url}". ` +
'Revisa .env.test',
);
}
return url;
}

📋 S6·08 · test/setup-env.ts completo
// test/setup-env.ts — se ejecuta antes de cada fichero de test
import { loadTestEnv } from './load-test-env.js';
loadTestEnv();

Como setup-env.ts corre antes de que el fichero de test importe app.ts, cuando config.ts valida el entorno ya ve los valores de test (dotenv/config no pisa variables ya definidas).

3.3 Crear y migrar la DB de test una sola vez

Sección titulada «3.3 Crear y migrar la DB de test una sola vez»

📋 S6·09 · test/global-setup.ts completo
// test/global-setup.ts
import { execSync } from 'node:child_process';
import pg from 'pg';
import { loadTestEnv } from './load-test-env.js';
// Se ejecuta UNA vez antes de toda la suite: crea la DB de test si no existe
// y le aplica las migraciones (migrate deploy: sin preguntas, como en CI).
export default async function setup() {
const url = loadTestEnv();
const dbName = new URL(url).pathname.slice(1);
const admin = new pg.Client({ // a la base "postgres", que siempre existe
connectionString: url.replace(`/${dbName}`, '/postgres') });
await admin.connect();
const { rowCount } = await admin.query(
'SELECT 1 FROM pg_database WHERE datname = $1', [dbName]);
// dbName ya lo validó loadTestEnv: es seguro interpolarlo
if (!rowCount) await admin.query(`CREATE DATABASE "${dbName}"`);
await admin.end();
execSync('npx prisma migrate deploy',
{ stdio: 'inherit', env: process.env });
}

pg ya está instalado (lo usa el adaptador de Prisma). pg.Client es una conexión suelta, sin Prisma: nos conectamos a la base postgres (la que Postgres trae siempre) porque academia_test aún puede no existir, y desde ahí la creamos.

migrate deploy (y no migrate dev): aplica las migraciones existentes sin preguntar ni generar nuevas — lo mismo que se hará en CI o en producción.

📋 S6·10 · vitest.config.ts completo (en la raíz)
// vitest.config.ts (en la raíz)
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node', globals: false, // imports explícitos
// crea y migra la DB de test, una vez
globalSetup: './test/global-setup.ts',
// carga .env.test antes de cada fichero
setupFiles: ['./test/setup-env.ts'],
fileParallelism: false, // una sola DB de test → en serie
coverage: { provider: 'v8', include: ['src/**'],
exclude: ['src/playground/**', 'src/seed-manual.ts',
'src/server.ts', 'src/crud.ts'] },
},
});

globals: false obliga a importar describe, it y expect en cada fichero. El bloque coverage hace que npm run test:coverage (§7) mida solo src/, no los propios tests.

tsconfig.json: sustituid la línea include por esta (así queda en s6-end), para que npm run typecheck compruebe también los helpers y los tests (si no, el typecheck en verde no los cubre):

📋 S6·11 · línea include de tsconfig.json
"include": ["src", "checks", "test", "prisma7.config.ts", "vitest.config.ts"]

fileParallelism: false: por defecto Vitest ejecuta ficheros en paralelo. Si auth.routes.test.ts vacía la base de datos mientras courses.routes.test.ts está a mitad de un test, aparecen fallos intermitentes que no se reproducen al repetir. Con una sola DB compartida, ficheros en serie.

3.5 Limpiar entre tests: borrar en orden inverso a las FKs

Sección titulada «3.5 Limpiar entre tests: borrar en orden inverso a las FKs»

📋 S6·12 · test/helpers.ts, primera parte (resetDb)
// test/helpers.ts — primera parte
import { prisma } from '../src/db.js';
// Borra en orden inverso a las FKs: primero lo que apunta
// a otras tablas.
export async function resetDb() {
await prisma.enrollment.deleteMany();
await prisma.course.deleteMany();
await prisma.category.deleteMany();
await prisma.user.deleteMany();
}

Si se borra User antes que Course, la base de datos rechaza el borrado por la foreign key — otra vez las constraints de la sesión 2 haciendo su trabajo. Cada fichero de integración lo llama con beforeEach(resetDb) a nivel de fichero, no dentro de un describe: así ningún bloque nuevo que alguien añada al fichero se queda sin limpieza.

Alternativa más avanzada (mención): envolver cada test en una transacción que se revierte al final. Es más rápida, pero más compleja de montar con Prisma; para el volumen de este proyecto, deleteMany sobra.


4. Unit testing de servicios (mockeando el repositorio)

Sección titulada «4. Unit testing de servicios (mockeando el repositorio)»

Aquí es donde se cobra la arquitectura en capas de la sesión 3: vamos a testear coursesService.updateCourse(courseId, actor, data) — la misma firma de las sesiones 4 y 5, con actor = { id, role } — sin tocar la base de datos ni Express, mockeando coursesRepository.

Qué es un mock. Un mock (o test double) sustituye una dependencia real —aquí, el repositorio— por funciones falsas: devuelven lo que les digáis y apuntan cómo las llamaron. El servicio no sabe que el repositorio es falso, así que lo probamos sin base de datos.

API Qué hace
vi.mock(ruta) todas las funciones del módulo pasan a ser mocks (por defecto devuelven undefined)
vi.mocked(fn) la misma función, tipada como mock (solo para TypeScript)
.mockResolvedValue(x) al llamarla devuelve una promesa que resuelve a x
await expect(promesa).rejects.… la promesa tiene que fallar (el servicio lanzó)
Awaited<ReturnType<typeof f>> · as never «lo que devuelve f tras el await» · «TypeScript, fíate»: objeto parcial a propósito

Lo de TypeScript es solo para que compile: en un unitario no necesitamos un Course completo, solo los campos que la función usa (instructorId).

Sintaxis clave:

  • describe agrupa tests relacionados (normalmente por función o módulo).
  • it (o test, son alias) define un caso concreto.
  • expect(valor).matcher(esperado) — matchers comunes: .toBe() (igualdad estricta), .toEqual() (igualdad profunda), .toMatchObject() (contiene al menos esos campos), .toHaveBeenCalledWith() (con qué se llamó el mock) y .not.toHaveBeenCalled() (que no se llamó).

4.1 vi.mock para sustituir el módulo del repositorio

Sección titulada «4.1 vi.mock para sustituir el módulo del repositorio»

El esqueleto del fichero se copia: imports, el vi.mock, el helper course() y el describe vacío con su beforeEach.

📋 S6·13 · src/services/courses.service.test.ts, esqueleto
// src/services/courses.service.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { coursesRepository }
from '../repositories/courses.repository.js';
import { coursesService } from './courses.service.js';
// Sustituye el módulo entero del repositorio por funciones mock: el servicio
// se prueba sin base de datos ni Express.
vi.mock('../repositories/courses.repository.js');
const course = (instructorId: number) =>
({ id: 1, instructorId }) as Awaited<
ReturnType<typeof coursesRepository.findById>
>;
describe('coursesService.updateCourse', () => {
beforeEach(() => { vi.resetAllMocks(); });
// aquí dentro, los it de las slides siguientes
});

✍️ A mano, dentro del describe: el primer test, el que enseña la idea — lanza 403 y no escribe.

it('lanza 403 si el usuario no es el dueño ni admin, y no actualiza',
async () => {
vi.mocked(coursesRepository.findById)
.mockResolvedValue(course(99));
// toThrow(AppError) no distinguiría 403 de 404
await expect(
coursesService.updateCourse(1, { id: 42, role: 'INSTRUCTOR' },
{ title: 'Nuevo título' }),
).rejects.toMatchObject({ statusCode: 403 });
expect(coursesRepository.update).not.toHaveBeenCalled();
});

Tres detalles que importan:

  • vi.mock va en el nivel superior del fichero. Vitest lo eleva por encima de los imports; en Vitest 5, un vi.mock dentro de un it o un describe lanza un error.
  • rejects.toMatchObject({ statusCode: 403 }), no rejects.toThrow(AppError): NotFoundError y ForbiddenError son ambos AppError, así que toThrow(AppError) pasaría igual con un 404 que con un 403. El test tiene que comprobar qué error.
  • expect(update).not.toHaveBeenCalled(): que lance 403 no basta; lo que protege los datos es que no se escriba. Y en el caso ADMIN se comprueba lo contrario: que update sí se llamó.

4.2 Primer npm test con un test de verdad: la guarda y el globalSetup en acción

Sección titulada «4.2 Primer npm test con un test de verdad: la guarda y el globalSetup en acción»

En cuanto tengáis escrito el primer it (el del 403), lanzad npm test:

  • La primera vez veréis a migrate deploy encontrar las 2 migraciones y aplicarlas a academia_test — el globalSetup ha creado la base de datos de test por vosotros. Y el test, en verde.
  • Ahora romped la guarda a propósito: en .env.test, cambiad academia_test por academia y lanzad npm test → aborta con “DATABASE_URL de test no termina en _test”, sin tocar la DB de desarrollo. Arriba del error veréis también “No test files found, exiting with code 1”: ignoradlo, el que cuenta es el de la guarda (ningún test se ha borrado). Restaurad el fichero.

La protección no es “acordarse de tener bien el .env.test”: el código se niega a hacer daño. Es la idea de la sesión 4 (config.ts que falla al arrancar) aplicada al testing.

Después, los otros tres casos, también dentro del describe. El 404 y el ADMIN siguen el mismo patrón y se copian:

📋 S6·14 · test del 404 (dentro del describe)
it('lanza 404 si el curso no existe', async () => {
vi.mocked(coursesRepository.findById).mockResolvedValue(null);
await expect(
coursesService.updateCourse(
1, { id: 42, role: 'INSTRUCTOR' }, { title: 'x' }),
).rejects.toMatchObject({ statusCode: 404 });
});

✍️ A mano: el del dueño, que comprueba el resultado y con qué se llamó al repositorio.

it('actualiza el curso cuando el usuario es el dueño', async () => {
vi.mocked(coursesRepository.findById)
.mockResolvedValue(course(42));
vi.mocked(coursesRepository.update)
.mockResolvedValue({ id: 1, title: 'Nuevo título' } as never);
const result = await coursesService.updateCourse(
1, { id: 42, role: 'INSTRUCTOR' }, { title: 'Nuevo título' });
expect(result.title).toBe('Nuevo título');
expect(coursesRepository.update)
.toHaveBeenCalledWith(1, { title: 'Nuevo título' });
});

📋 S6·15 · test del ADMIN (dentro del describe)
it('permite actualizar a un ADMIN aunque no sea el dueño',
async () => {
vi.mocked(coursesRepository.findById)
.mockResolvedValue(course(99));
vi.mocked(coursesRepository.update)
.mockResolvedValue({ id: 1 } as never);
await coursesService.updateCourse(
1, { id: 42, role: 'ADMIN' }, { title: 'x' });
expect(coursesRepository.update)
.toHaveBeenCalledWith(1, { title: 'x' });
});

Volved a lanzar npm test: 4 tests en verde.

El caso ADMIN y el 403 son los más importantes: cubren exactamente la regla “ownership O admin” de la sesión 4, en milisegundos y sin Postgres.

4.3 ¿Protegen de algo? Comprobadlo rompiendo el código

Sección titulada «4.3 ¿Protegen de algo? Comprobadlo rompiendo el código»

Prueba de mutación manual: en getOwnedCourse de src/services/courses.service.ts (o como se llame en tu código), cambiad

if (!isOwner && !isAdmin) throw new ForbiddenError('You do not own this course');

por !isOwner || !isAdmin y lanzad npm test → fallan 2 tests (el del dueño y el del ADMIN). Deshaced el cambio. Un test que sigue verde con el código roto no está protegiendo nada — esta idea es la base de la práctica final.


Los tests de integración prueban la pila completa: ruta → middleware → controller → service → repository → Prisma real contra academia_test.

Recordad la sesión 3: app.ts exporta la app de Express sin app.listen() (eso lo hace server.ts). Supertest recibe esa app y, como no está escuchando, la arranca él mismo en un puerto efímero (listen(0)) y le hace peticiones HTTP reales por loopback. Vosotros no llamáis a listen() ni elegís puerto, así que no choca con una API que tengáis arrancada en el 3000.

test/helpers.ts completo: sustituye entero al de §3.5 (trae el mismo resetDb más los helpers de tokens y usuarios).

📋 S6·16 · test/helpers.ts completo (sustituye al anterior)
// test/helpers.ts — completo: sustituye al anterior
import jwt from 'jsonwebtoken';
import { prisma } from '../src/db.js';
import type { AuthPayload }
from '../src/middlewares/require-auth.js';
import { hashPassword } from '../src/services/password.js';
// Borra en orden inverso a las FKs: primero lo que apunta
// a otras tablas.
export async function resetDb() {
await prisma.enrollment.deleteMany();
await prisma.course.deleteMany();
await prisma.category.deleteMany();
await prisma.user.deleteMany();
}
export function generateTestToken(payload: AuthPayload) {
return jwt.sign(payload, process.env.JWT_SECRET!, {
algorithm: 'HS256', expiresIn: '1h',
});
}
let seq = 0;
export async function createUser(role: AuthPayload['role'],
password = 'Passw0rd!') {
seq++;
const user = await prisma.user.create({
data: { email: `${role.toLowerCase()}${seq}@test.dev`,
name: `${role} ${seq}`, role,
password: await hashPassword(password) },
});
return { user,
token: generateTestToken({ sub: String(user.id), role }),
password };
}
export const bearer = (token: string) =>
({ Authorization: `Bearer ${token}` });
  • createUser guarda un hash Argon2id real (hashPassword de la sesión 4): así el mismo usuario sirve para probar el login.
  • sub: String(user.id) — igual que la app desde la sesión 4: el sub del JWT es un string. Si en los tests pusierais un número, requireAuth rechazaría el token y veríais 401 en todo.
  • El seq evita colisiones con el @unique del email entre usuarios del mismo test.

La cabecera del fichero se copia:

📋 S6·17 · test/courses.routes.test.ts, cabecera
// test/courses.routes.test.ts
import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest';
import { app } from '../src/app.js';
import { prisma } from '../src/db.js';
import { bearer, createUser, resetDb } from './helpers.js';
// Supertest arranca la app en un puerto efímero:
// vosotros no llamáis a app.listen() ni elegís puerto.
beforeEach(resetDb);

✍️ A mano, debajo: el primer test con Supertest — filas reales en la DB, una petición HTTP y tres comprobaciones.

// test/courses.routes.test.ts (sigue, tras el beforeEach)
describe('GET /api/v1/courses', () => {
it('devuelve solo los cursos publicados', async () => {
const { user } = await createUser('INSTRUCTOR');
await prisma.course.createMany({ data: [
{ title: 'Publicado', price: 10, published: true,
instructorId: user.id },
{ title: 'Borrador', price: 10, published: false,
instructorId: user.id },
] });
const res = await request(app).get('/api/v1/courses');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].title).toBe('Publicado');
});

Los otros dos casos del GET se copian, y cierran el describe:

📋 S6·18 · GET no expone datos + 404, y cierre del describe
// sigue dentro del describe GET
it('no expone el email ni el hash del instructor', async () => {
const { user } = await createUser('INSTRUCTOR');
await prisma.course.create({ data: { title: 'Curso', price: 10,
published: true, instructorId: user.id } });
const res = await request(app).get('/api/v1/courses');
expect(res.body[0].instructor)
.toEqual({ id: user.id, name: user.name });
});
it('responde 404 (no 500) a un curso inexistente', async () => {
const res = await request(app).get('/api/v1/courses/99999');
expect(res.status).toBe(404);
});
}); // cierra el describe GET

Lanzad npm test: 2 ficheros, 7 tests en verde (los 4 unitarios + estos 3).


6. Testear rutas protegidas por JWT y el login

Sección titulada «6. Testear rutas protegidas por JWT y el login»

Con createUser tenemos tokens válidos sin pasar por el login en cada test. El describe del POST repite el mismo patrón cuatro veces: se copia entero y lo leemos juntos.

📋 S6·19 · describe POST /api/v1/courses completo (al final de courses.routes.test.ts)
// test/courses.routes.test.ts — añade al final
describe('POST /api/v1/courses', () => {
const body = { title: 'Nuevo curso', price: 49.99 };
it('401 sin token', async () => {
const res = await request(app)
.post('/api/v1/courses').send(body);
expect(res.status).toBe(401);
});
it('403 si el token es de un STUDENT', async () => {
const { token } = await createUser('STUDENT');
const res = await request(app).post('/api/v1/courses')
.set(bearer(token)).send(body);
expect(res.status).toBe(403);
});
it('201 como INSTRUCTOR, y el instructor es quien hace la petición',
async () => {
const { user, token } = await createUser('INSTRUCTOR');
const res = await request(app).post('/api/v1/courses')
.set(bearer(token)).send(body);
expect(res.status).toBe(201);
expect(res.body.instructorId).toBe(user.id);
});
it('400 si el body trae campos no permitidos (mass assignment)',
async () => {
const { token } = await createUser('INSTRUCTOR');
const res = await request(app).post('/api/v1/courses')
.set(bearer(token)).send({ ...body, instructorId: 1 });
expect(res.status).toBe(400);
});
}); // cierra el describe POST

✍️ A mano, al final del fichero: el de ownership, que comprueba en la DB que el precio no cambió.

// test/courses.routes.test.ts — al final
describe('PATCH /api/v1/courses/:id (ownership)', () => {
it('403 si otro instructor intenta editar el curso', async () => {
const owner = await createUser('INSTRUCTOR');
const other = await createUser('INSTRUCTOR');
const course = await prisma.course.create({ data: {
title: 'Curso', price: 10, instructorId: owner.user.id } });
const res = await request(app)
.patch(`/api/v1/courses/${course.id}`)
.set(bearer(other.token)).send({ price: 1 });
expect(res.status).toBe(403);
expect((await prisma.course.findUniqueOrThrow({
where: { id: course.id } })).price.toNumber()).toBe(10);
});
});

Qué cubre cada uno: 401 (autenticación, sesión 4), 403 (RBAC), 201 con instructorId sacado de req.user y no del body (sesión 4), 400 por el strictObject de la sesión 5, y ownership comprobando que el precio no cambió en la DB — igual que en el unitario, lo importante no es solo el código de estado sino que no se escribió nada.

6.1 El login: fichero nuevo test/auth.routes.test.ts

Sección titulada «6.1 El login: fichero nuevo test/auth.routes.test.ts»

El login va en su propio fichero, con sus imports y su beforeEach(resetDb) a nivel de fichero. Primero el caso feliz (createUser devuelve también la contraseña en claro, y como guarda un hash Argon2id real, el login se prueba de verdad); después, el mismo error para email y contraseña. Se copia entero:

📋 S6·20 · test/auth.routes.test.ts completo (fichero nuevo)
// test/auth.routes.test.ts — fichero nuevo
import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest';
import { app } from '../src/app.js';
import { createUser, resetDb } from './helpers.js';
beforeEach(resetDb);
describe('POST /api/v1/auth/login', () => {
it('devuelve un token con credenciales correctas', async () => {
const { user, password } = await createUser('STUDENT');
const res = await request(app).post('/api/v1/auth/login')
.send({ email: user.email, password });
expect(res.status).toBe(200);
expect(res.body.token).toEqual(expect.any(String));
});
it('mismo 401 y mismo mensaje si falla el email o la contraseña',
async () => {
const { user } = await createUser('STUDENT');
const wrongPassword = await request(app)
.post('/api/v1/auth/login')
.send({ email: user.email, password: 'mala' });
const wrongEmail = await request(app)
.post('/api/v1/auth/login')
.send({ email: 'nadie@test.dev', password: 'mala' });
expect(wrongPassword.status).toBe(401);
expect(wrongEmail.status).toBe(401);
expect(wrongEmail.body).toEqual(wrongPassword.body);
});
}); // cierra el describe

La última línea del segundo test es la que importa: si los mensajes difirieran, la API revelaría qué emails están registrados (enumeración de usuarios).

¿Y el rate limiter de la sesión 5? Supertest hace todas las peticiones desde la misma IP y el contador está en memoria: tras unos cuantos logins fallidos, los tests empezarían a recibir 429. Por eso el limitador se salta en test (NODE_ENV="test" viene en .env.test):

// src/middlewares/rate-limit.ts (extracto)
// En tests no limitamos: Supertest hace todas las peticiones desde la misma IP
// y el contador en memoria se comparte entre tests.
const skip = () => config.NODE_ENV === 'test';

Resultado esperado:

📋 S6·21 · npm test dos veces seguidas (terminal)
npm test # 3 ficheros, 14 tests en verde, ~2-3 s
npm test # otra vez: mismo resultado (nada residual entre ejecuciones)

14 = 4 unitarios + 8 de courses.routes.test.ts (3 GET, 4 POST, 1 PATCH) + 2 de auth.routes.test.ts.


📋 S6·22 · cobertura y coverage/ fuera de git (terminal)
npm run test:coverage # informe en coverage/
echo "coverage" >> .gitignore # que no acabe en git

(En Windows, el echo … >> desde Git Bash.)

La cobertura dice qué líneas se ejecutaron durante los tests, no si la lógica es correcta — un test sin ningún expect útil puede dar 100% de cobertura y no comprobar nada. Es una métrica de guía, no un objetivo en sí mismo. (La práctica de ahora lo demuestra.) La configuración es el bloque coverage de vitest.config.ts (§3.4).

CI (mención): en un proyecto real, esta suite se ejecuta automáticamente en cada push (GitHub Actions u otro), y un PR no se puede fusionar si los tests fallan. El globalSetup con migrate deploy está pensado justo para eso: arranca contra una DB vacía sin intervención manual. Se retoma con detalle en el bloque de DevOps del máster.


8. Práctica final: auditar una suite generada por IA

Sección titulada «8. Práctica final: auditar una suite generada por IA»

Punto de control: quien no tenga la suite en verde, receta «Cambiar de rama» de §1 con esta línea en lugar del git switch (trae los 3 ficheros de test resueltos), y después cp .env.test.example .env.test y npm test:

📋 S6·23 · línea del checkpoint para la receta
git switch -c mi-s6-cp origin/s6-checkpoint

El fichero de la IA, s6-tests.ia.test.ts, no está en ninguna rama del repo: copiadlo de la web (📋 S6·24) a src/services/s6-tests.ia.test.ts.

Le pedimos a un asistente de IA “Escribe tests para coursesService.updateCourse” y nos dio s6-tests.ia.test.ts. Pasa en verde. La pregunta es otra: ¿protege de algo?

Enunciado para el alumno:

  1. Copiad 📋 S6·24 a src/services/s6-tests.ia.test.ts y lanzad npm test → verde.
  2. Romped el ownership en getOwnedCourse de src/services/courses.service.ts (o como se llame en tu código): if (!isOwner && !isAdmin) → if (isOwner && !isAdmin), es decir, el dueño ya no puede editar y el ajeno sí. Lanzad npm test otra vez.
  3. ¿Qué tests de la IA siguen en verde con el código roto? Para cada uno, explicad por qué no vale (qué comprueba realmente).
  4. Reescribid el fichero (o añadid tests) hasta que la mutación haga fallar al menos uno. Después, deshaced la mutación.
  5. Si sobra tiempo: un test de integración de vuestra cosecha, por ejemplo DELETE /api/v1/courses/:id por otro instructor → 403 y el curso sigue existiendo.

Criterio de “hecho”: la suite está en verde con el código correcto y al menos un test vuestro falla con la mutación aplicada.

📋 S6·24 · src/services/s6-tests.ia.test.ts (la suite de la IA)
// src/services/s6-tests.ia.test.ts
// ─────────────────────────────────────────────────────────────────────────
// Generado por un asistente de IA (Copilot, modo agente) · Sesión 6
// Prompt: "Escribe tests para coursesService.updateCourse"
// La suite pasa en verde. Tu tarea: ¿protege de algo?
// 1. Invierte la condición de ownership en courses.service.ts
// (if (!isOwner && !isAdmin) → if (isOwner && !isAdmin)) y vuelve a correrla.
// 2. ¿Qué tests siguen en verde con el código roto? Esos no valen.
// ─────────────────────────────────────────────────────────────────────────
import { describe, expect, it, vi } from 'vitest';
import { AppError } from '../errors/app-error.js';
import { coursesRepository } from '../repositories/courses.repository.js';
import { coursesService } from './courses.service.js';
vi.mock('../repositories/courses.repository.js');
describe('coursesService.updateCourse', () => {
it('llama al repositorio', async () => {
vi.mocked(coursesRepository.findById).mockResolvedValue({ id: 1, instructorId: 1 } as never);
vi.mocked(coursesRepository.update).mockResolvedValue({ id: 1, title: 'Nuevo' } as never);
await coursesService.updateCourse(1, { id: 1, role: 'ADMIN' }, { title: 'Nuevo' }).catch(() => {});
expect(coursesRepository.findById).toHaveBeenCalledWith(1);
});
it('devuelve el curso actualizado', async () => {
vi.mocked(coursesRepository.update).mockResolvedValue({ id: 1, title: 'Nuevo' } as never);
const updated = await coursesRepository.update(1, { title: 'Nuevo' });
expect(updated.title).toBe('Nuevo');
});
it('lanza un error si algo va mal', async () => {
vi.mocked(coursesRepository.findById).mockResolvedValue(null);
await expect(
coursesService.updateCourse(1, { id: 1, role: 'INSTRUCTOR' }, { title: 'x' }),
).rejects.toThrow(AppError);
});
});

El 1 de diciembre cerramos el bloque de ORMs, APIs REST y Seguridad. Hoy cerramos toda la parte de backend del curso: ORMs → APIs REST → Autenticación/Autorización → Seguridad → Testing. Cada sesión añadió una capa sobre la misma base de código (AcademIA), y hoy esa base queda verificada automáticamente — incluidas las protecciones de seguridad, que ahora tienen un test que avisa si alguien las rompe.

Preguntas de cierre (las respondéis entre vosotros):

  • ¿Por qué un test unitario que mockea el repositorio no habría detectado un @@unique roto?
  • ¿Por qué un test de integración sí lo detecta?
  • Si tuvierais que elegir solo 3 tests para todo el proyecto por falta de tiempo, ¿cuáles serían y por qué?
  • Cuando la IA os genere tests, ¿cómo vais a saber si valen?