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
Objetivos de la sesión
Sección titulada «Objetivos de la sesión»Al terminar, el alumno debe ser capaz de:
- Explicar la pirámide de testing y por qué no todos los tests deben ser del mismo tipo.
- Configurar Vitest con una base de datos de test aislada de la de desarrollo (guarda,
globalSetupy limpieza entre tests). - Escribir tests unitarios de la capa de servicios mockeando el repositorio y comprobando qué error se lanza.
- Escribir tests de integración con Supertest contra la API real (sin levantar un puerto).
- Testear rutas protegidas por JWT generando tokens válidos en los tests.
- 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.
1. Arranque común y por qué testear
Sección titulada «1. Arranque común y por qué testear»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/…):
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)git fetch origingit switch -c mi-s6 origin/s6-start # checkpoint: mi-s6-cp origin/s6-checkpointnpm installnpx prisma generatenpx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seedSi 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:
git add -A && git commit -m "wip: mi trabajo" # guarda lo tuyo (si hay algo)git fetch origingit switch -c mi-s6-cp origin/s6-checkpointnpm installnpx prisma generatenpx prisma migrate dev # desde tu rama pedirá reset (esperado): npx prisma migrate reset y npm run seedcp .env.test.example .env.test # DB de test separada: academia_testnpm test # 3 ficheros, 14 tests en verdenpm 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.
2. La pirámide de testing
Sección titulada «2. La pirámide de testing» /\ /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.
3.1 Instalación y primera ejecución
Sección titulada «3.1 Instalación y primera ejecución»npm install -D vitest@5 @vitest/coverage-v8@5 \ supertest@7 @types/supertestVitest 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):
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 1Es 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):
# .env.test — en la raíz del proyectoDATABASE_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:
# si git aún no lo ignora (tu .gitignore de la S1 no lo tenía), lo añadegit check-ignore -q .env.test || echo ".env.test" >> .gitignoregit check-ignore -v .env.test # → .gitignore:N:.env.test .env.testLa 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.
// test/load-test-env.tsimport { 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;}// test/setup-env.ts — se ejecuta antes de cada fichero de testimport { 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»// test/global-setup.tsimport { 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.
3.4 La configuración
Sección titulada «3.4 La configuración»// 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):
"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»// test/helpers.ts — primera parteimport { 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:
describeagrupa tests relacionados (normalmente por función o módulo).it(otest, 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.
// src/services/courses.service.test.tsimport { 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.mockva en el nivel superior del fichero. Vitest lo eleva por encima de los imports; en Vitest 5, unvi.mockdentro de unito undescribelanza un error.rejects.toMatchObject({ statusCode: 403 }), norejects.toThrow(AppError):NotFoundErroryForbiddenErrorson ambosAppError, así quetoThrow(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: queupdatesí 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 deployencontrar las 2 migraciones y aplicarlas aacademia_test— elglobalSetupha creado la base de datos de test por vosotros. Y el test, en verde. - Ahora romped la guarda a propósito: en
.env.test, cambiadacademia_testporacademiay lanzadnpm 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:
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' });});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.
5. Integration testing con Supertest
Sección titulada «5. Integration testing con Supertest»Los tests de integración prueban la pila completa: ruta → middleware → controller → service → repository → Prisma real contra academia_test.
5.1 Por qué funciona sin app.listen()
Sección titulada «5.1 Por qué funciona sin app.listen()»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.
5.2 Helpers: usuarios reales y tokens
Sección titulada «5.2 Helpers: usuarios reales y tokens»test/helpers.ts completo: sustituye entero al de §3.5 (trae el mismo resetDb más los helpers de tokens y usuarios).
// test/helpers.ts — completo: sustituye al anteriorimport 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}` });createUserguarda un hash Argon2id real (hashPasswordde 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: elsubdel JWT es un string. Si en los tests pusierais un número,requireAuthrechazaría el token y veríais401en todo.- El
seqevita colisiones con el@uniquedel email entre usuarios del mismo test.
5.3 Primer test de integración
Sección titulada «5.3 Primer test de integración»La cabecera del fichero se copia:
// test/courses.routes.test.tsimport 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:
// 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 GETLanzad 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.
// test/courses.routes.test.ts — añade al finaldescribe('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 finaldescribe('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:
// test/auth.routes.test.ts — fichero nuevoimport 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 describeLa ú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:
npm test # 3 ficheros, 14 tests en verde, ~2-3 snpm 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.
7. Cobertura y CI
Sección titulada «7. Cobertura y CI»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:
git switch -c mi-s6-cp origin/s6-checkpointEl 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:
- Copiad 📋 S6·24 a
src/services/s6-tests.ia.test.tsy lanzadnpm test→ verde. - Romped el ownership en
getOwnedCoursedesrc/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í. Lanzadnpm testotra vez. - ¿Qué tests de la IA siguen en verde con el código roto? Para cada uno, explicad por qué no vale (qué comprueba realmente).
- Reescribid el fichero (o añadid tests) hasta que la mutación haga fallar al menos uno. Después, deshaced la mutación.
- Si sobra tiempo: un test de integración de vuestra cosecha, por ejemplo
DELETE /api/v1/courses/:idpor otro instructor →403y 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.
// 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); });});9. Cierre de la parte de backend
Sección titulada «9. Cierre de la parte de backend»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
@@uniqueroto? - ¿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?