Módulo 21: Testing
Objetivos del módulo
- Entender qué cambia al probar una aplicación Next.js respecto a una SPA de React: código que solo existe dentro del servidor de Next y que fuera de él lanza errores
- Configurar Vitest con dos proyectos, uno con DOM simulado para los Client Components y otro de Node para el código de servidor, sin plugins extra para los alias
@/ - Probar Client Components que usan
useRouter,usePathnameyuseSearchParamssimulandonext/navigation - Probar formularios con
useActionStatesustituyendo la Server Action por una función simulada - Saber, comprobado, qué se puede probar de un Server Component asíncrono con Vitest y qué no, y cómo hacerlo
- Probar Server Actions y la capa de datos contra una base de datos SQLite de prueba, con el esquema y los datos del módulo 16
- Probar Route Handlers y
proxy.tsllamando a sus funciones con objetosRequestconstruidos a mano - Escribir pruebas end-to-end con Playwright contra el resultado de
next build, arrancado connext startpor la opciónwebServer - Organizar una suite completa: qué va en cada nivel, cuánto tarda y qué falla típicamente
- Saber qué cambia si sigues en Next.js 15
💡 Consejo: Todo el código de este módulo se ha ejecutado con Next.js 16.4.3, React 19.3, Vitest 5.0.5, Vite 8.3.5, Testing Library (React 16.3, user-event 14.6, jest-dom 7.0), jsdom 30, Playwright 1.64.1,
drizzle-orm0.45.4 y@libsql/client0.18.0 sobre Node.js 24 en Windows 11. Las salidas que ves (resultados de Vitest, mensajes de error, tabla del build, informe de Playwright) son las reales. Las alternativas para Next.js 15 se han ejecutado en Next.js 15.5.28. Usa siempre el último parche de Next.js 16.4.
📘 Concepto: Este módulo no vuelve a explicar las herramientas desde cero. El módulo 22 del Curso de React ya cuenta qué es una prueba unitaria, de integración y E2E, cómo funcionan Vitest (
describe,test,expect,vi.fn,vi.mock), Testing Library (consultas por rol,userEvent,findBy), MSW para simular la red y Playwright (localizadores,expectcon espera automática, traces). Si no has escrito nunca una prueba, léelo primero. Aquí nos centramos en lo que es propio de Next.js: Server Components, Server Actions, Route Handlers,proxy,next/navigation, la caché y el servidor de producción.
1. Por qué probar Next.js es distinto
En una SPA hecha con React y Vite, todo el código acaba en el navegador. Una prueba con Vitest y jsdom simula ese navegador y puede ejecutar cualquier componente: si el componente pide datos con fetch, MSW intercepta la petición y devuelve lo que quieras. El modelo es sencillo: un solo entorno, el navegador, y una frontera clara, la red.
Una aplicación Next.js tiene tres clases de código, y solo una se parece a eso:
| Código | Dónde se ejecuta de verdad | Qué necesita para funcionar |
|---|---|---|
Client Components ('use client') | Navegador (y una vez en el servidor para el HTML inicial) | El router de Next montado, si usan next/navigation |
Server Components, Server Actions, Route Handlers, proxy.ts | Servidor de Next | La petición en curso (cookies(), headers(), connection()), el contexto de caché (cacheTag, updateTag), a veces la base de datos |
| Lógica pura (validación, formatos, cálculos) | Donde la llames | Nada |
El problema está en la segunda fila. Muchas funciones de Next solo funcionan dentro de Next: dependen de un contexto que el servidor crea para cada petición (en Node, un AsyncLocalStorage) o de que el compilador de Next haya transformado tu código ('use cache', 'use server'). Vitest no es Next: ejecuta tus ficheros con Vite, sin ese contexto ni esas transformaciones.
Esto se comprobó en este módulo llamando a cada función desde una prueba de Vitest, sin simular nada. Estos son los errores reales:
| Lo que se llamó en Vitest | Error real |
|---|---|
import 'server-only' | This module cannot be imported from a Client Component module. It should only be used from a Server Component. |
cacheTag('x') | `cacheTag()` is only available with the `cacheComponents` config. |
cacheLife('days') | `cacheLife()` is only available with the `cacheComponents` config. |
updateTag('x') | updateTag can only be called from within a Server Action. To invalidate cache tags in Route Handlers or other contexts, use revalidateTag instead. |
await connection() | `connection` was called outside a request scope. |
await cookies() | `cookies` was called outside a request scope. |
render(<Buscador />) con useRouter | invariant expected app router to be mounted |
Fíjate en los dos primeros: server-only cree que lo estás importando desde el navegador, y cacheTag cree que no has activado cacheComponents (sí lo está, en next.config.ts). Los mensajes son los que da Next cuando falta su contexto, y no describen bien la causa real, que es “esto no es Next”. Ahora ya sabes reconocerlos.
La consecuencia práctica es una regla para todo el módulo:
📘 Concepto: Con Vitest pruebas tu código y sustituyes el de Next (con
vi.mock). Con Playwright pruebas tu código dentro de Next, sin sustituir nada. Lo que solo tiene sentido dentro de Next (que'use cache'guarde de verdad, queupdateTagrenueve la página, que<Suspense>haga streaming, que elproxyse ejecute antes que la página) se comprueba en las pruebas E2E.
Figura 1. Cada pieza de una aplicación Next.js y la herramienta con la que se prueba. Cuanto más a la derecha, más realista y más lenta es la prueba.
1.1. Lo que dice la documentación oficial
La guía de Next (en tu proyecto, node_modules/next/dist/docs/01-app/02-guides/testing/) tiene páginas para Vitest, Jest, Playwright y Cypress. Sobre los Server Components asíncronos dice:
Since
asyncServer Components are new to the React ecosystem, Vitest currently does not support them. While you can still run unit tests for synchronous Server and Client Components, we recommend using E2E tests forasynccomponents.
Es verdad a medias: en el apartado 5 verás que sí se puede probar un Server Component asíncrono con Vitest si cumples dos condiciones, y qué pasa exactamente cuando no las cumples. La recomendación de fondo sigue siendo buena: la página entera, con sus <Suspense> y su caché, se prueba con E2E.
1.2. La aplicación de este módulo
Las pruebas se escriben sobre una parte de la aplicación EMPRESA: la gestión de departamentos, con la base de datos del módulo 16. Estos son los ficheros que se prueban:
app/
├── layout.tsx
├── departamentos/
│ ├── page.tsx página (◐): buscador + lista + formulario
│ ├── buscador.tsx Client Component: useRouter, usePathname, useSearchParams
│ ├── lista.tsx Server Component asíncrono: lee la capa de datos
│ ├── formulario-alta.tsx Client Component: useActionState
│ ├── acciones.ts Server Actions: altaDepartamento, bajaDepartamento
│ ├── favoritos.ts Server Action con cookies() y redirect() (ejercicio 4)
│ └── [id]/page.tsx ficha del departamento, con generateStaticParams
└── api/departamentos/
├── route.ts GET (lista, ?q=) y POST (alta)
└── [id]/route.ts GET (uno)
lib/
├── validacion.ts esquema Zod y formatearPresupuesto (lógica pura)
└── db/ capa de datos del módulo 16
├── index.ts
├── errores.ts
└── departamentos.ts
proxy.ts protege /gestion
db/schema.ts, drizzle/, scripts/, datos.json de recursos/empresa-db.zip
La capa lib/db es la del módulo 16 (apartado 8), sin cambios, con una función más (contarEmpleados, apartado 8 de este módulo). Las Server Actions son las del apartado 10.2 de ese módulo con un único cambio: el esquema de Zod se ha movido a lib/validacion.ts para reutilizarlo en el Route Handler y probarlo por separado. Un fichero 'use server' solo puede exportar funciones asíncronas, así que el esquema no podía exportarse desde acciones.ts.
// lib/validacion.ts
import { z } from 'zod'
export const esquemaDepartamento = z.object({
nombre: z.string().trim().min(2, 'Mínimo 2 caracteres').max(40).toUpperCase(),
localidad: z.string().trim().min(2, 'Mínimo 2 caracteres'),
presupuesto: z
.string()
.trim()
.transform((v) => (v === '' ? null : Number(v)))
.pipe(z.number().nonnegative('No puede ser negativo').nullable()),
})
export function formatearPresupuesto(presupuesto: number | null): string {
if (presupuesto === null) return 'Sin presupuesto'
return presupuesto.toLocaleString('es-ES', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
useGrouping: 'always',
})
}
useGrouping: 'always' no es un capricho: con 'es-ES', toLocaleString no separa los miles en números de cuatro cifras (1500 sale “1500 €”, pero 15000 sale “15.000 €”). Es la norma de la RAE para cifras de cuatro dígitos, y una prueba con 1500 lo detecta enseguida.
2. Preparar el proyecto
2.1. Instalar los paquetes
Parte del proyecto del módulo 16 (con recursos/empresa-db.zip ya descomprimido y npm run db:preparar ejecutado) e instala:
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event
npm install -D @playwright/test
npx playwright install chromium
| Paquete | Para qué |
|---|---|
vitest | Ejecutor de pruebas. Usa Vite para transformar TypeScript y JSX |
@vitejs/plugin-react | Transforma el JSX con el runtime automático de React (sin import React) |
jsdom | DOM simulado en Node, para los Client Components |
@testing-library/react, @testing-library/dom | render, screen y las consultas por rol |
@testing-library/jest-dom | Comparadores de DOM: toBeInTheDocument, toHaveValue, toBeDisabled… |
@testing-library/user-event | Simula al usuario: escribir, pulsar, tabular |
@playwright/test | Pruebas E2E en navegadores reales |
⚠️ Importante: Vitest 5 exige una versión moderna de
@types/node. La plantilla decreate-next-app16.4 trae"@types/node": "^20", y con ellanpm install -D vitestfalla conERESOLVE(conflicto de dependencias). Actualiza antes los tipos de Node:npm install -D @types/node@24. No uses--forceni--legacy-peer-depspara saltarte el error: instalarías una combinación que nadie ha probado.
La guía oficial de Next añade un paquete más, vite-tsconfig-paths, para que Vitest entienda los alias @/ del tsconfig.json. Con Vite 8 ya no hace falta: Vite lee los paths del tsconfig.json si activas resolve.tsconfigPaths. Lo verás en la configuración.
2.2. vitest.config.mts con dos proyectos
Los Client Components necesitan un DOM (jsdom). El código de servidor no solo no lo necesita: no debe tenerlo. Una Server Action que se prueba con jsdom ve un window y un document que en el servidor real no existen, y la base de datos o Request pueden comportarse de forma distinta. Por eso la configuración define dos proyectos de Vitest, cada uno con su entorno, su carpeta y su fichero de preparación:
// vitest.config.mts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
resolve: { tsconfigPaths: true }, // los alias @/ del tsconfig.json, sin plugins
test: {
projects: [
{
extends: true, // hereda plugins y resolve de arriba
test: {
name: 'cliente',
environment: 'jsdom',
include: ['tests/cliente/**/*.test.{ts,tsx}'],
setupFiles: ['tests/preparar-cliente.ts'],
},
},
{
extends: true,
test: {
name: 'servidor',
environment: 'node',
include: ['tests/servidor/**/*.test.{ts,tsx}'],
setupFiles: ['tests/preparar-servidor.ts'],
},
},
],
},
})
- La extensión
.mtsle dice a Node que el fichero es un módulo ES, aunquepackage.jsonno tenga"type": "module". Es lo que recomienda la guía de Next. extends: truehace que cada proyecto herede la configuración de la raíz (plugins,resolve). Sin ella, el proyecto empieza de cero y los imports con@/fallan.includesepara las pruebas por carpeta. Otra opción es elegir el entorno por fichero con un comentario en la primera línea,// @vitest-environment jsdom, pero con carpetas el criterio se ve de un vistazo.next buildcomprueba los tipos de todo el proyecto (también detests/y devitest.config.mts), porque eltsconfig.jsonde la plantilla incluye**/*.tsy**/*.tsx. Una prueba con un error de tipos rompe el build de producción. Es una buena noticia: tus pruebas no se quedan desfasadas sin que te enteres.
📘 Concepto: ¿Por qué no Jest? Next.js tiene también una guía para Jest con
next/jest, que configura SWC con las mismas transformaciones que usa Next. Funciona, pero Jest necesita más configuración para ESM y TypeScript, y es más lento. Vitest es la opción que usan los cursos de React y Next.js de esta web y la que usa la plantillawith-vitestde Next. Todo lo que verás sobre qué simular es igual en Jest, cambiandoviporjest.
2.3. Ficheros de preparación
tests/preparar-cliente.ts se ejecuta antes de cada fichero del proyecto cliente:
// tests/preparar-cliente.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(() => {
cleanup() // desmonta lo que haya renderizado la prueba anterior
vi.clearAllMocks() // borra las llamadas registradas en los vi.fn()
})
@testing-library/jest-dom/vitestañade los comparadores de DOM alexpectde Vitest, y también sus tipos: como este fichero está dentro del proyecto de TypeScript,toBeInTheDocument()compila en todas las pruebas.cleanup()es necesario porque no activamosglobals: trueen Vitest. Testing Library solo se limpia sola cuando encuentra unafterEachglobal. Sin esta línea, el DOM de una prueba se queda en la siguiente y las consultas encuentran elementos duplicados.vi.clearAllMocks()pone a cero el registro de llamadas de cadavi.fn(), para quetoHaveBeenCalledTimes(1)no cuente las llamadas de la prueba anterior. No borra las implementaciones definidas en losvi.mock.
tests/preparar-servidor.ts es más interesante, porque aquí se sustituye todo lo que en el apartado 1 lanzaba errores:
// tests/preparar-servidor.ts
import { afterEach, vi } from 'vitest'
// 1. Base de datos de pruebas: SQLite en memoria, nueva en cada proceso.
// Se fija ANTES de que nadie importe lib/db.
process.env.DATABASE_URL = ':memory:'
// 2. server-only lanza un error fuera de Next: en las pruebas no protege nada.
vi.mock('server-only', () => ({}))
// 3. Las funciones de caché de Next solo funcionan dentro de Next.
vi.mock('next/cache', () => ({
cacheTag: vi.fn(),
cacheLife: vi.fn(),
updateTag: vi.fn(),
revalidateTag: vi.fn(),
revalidatePath: vi.fn(),
refresh: vi.fn(),
}))
// 4. connection() exige una petición en curso: la sustituimos y dejamos el resto de next/server.
vi.mock('next/server', async (importOriginal) => ({
...(await importOriginal<typeof import('next/server')>()),
connection: vi.fn(async () => {}),
}))
afterEach(() => {
vi.clearAllMocks()
})
Cada bloque responde a uno de los errores de la tabla del apartado 1:
DATABASE_URLse fija en el fichero de preparación porque se ejecuta antes que el fichero de pruebas, y por tanto antes de quelib/db/index.tslea la variable para crear el cliente. Lo verás a fondo en el apartado 7.server-onlyexiste para que el build falle si un Client Component importa la capa de datos (módulo 16, apartado 8.3). En una prueba de Node no hay Client Components de los que protegerse, así que lo cambiamos por un módulo vacío.next/cachese sustituye entero por funciones vacías (vi.fn()). AsílistarDepartamentosse ejecuta como una función normal y, además, puedes comprobar que la acción llamó aupdateTag('departamentos').next/serverse sustituye solo en parte:importOriginalcarga el módulo real y se reemplaza únicamenteconnection.NextRequestyNextResponsesiguen siendo los de verdad, y los necesitamos para los Route Handlers.
💡 Consejo: Un
vi.mockescrito en un fichero desetupFilesse aplica a todos los ficheros de prueba de ese proyecto. Es el sitio para las simulaciones que siempre necesitas (las de Next). Las simulaciones propias de una prueba (una acción concreta, unos datos concretos) van en el fichero de esa prueba.
2.4. Scripts, .gitignore y la vía para Windows
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest",
"test:run": "vitest run",
"test:e2e": "playwright test",
"db:migrar": "drizzle-kit migrate",
"db:cargar": "tsx scripts/cargar-datos.ts",
"db:preparar": "drizzle-kit migrate && tsx scripts/cargar-datos.ts"
}
}
npm testdeja Vitest vigilando: cada vez que guardas un fichero, repite las pruebas afectadas. Es el modo para trabajar.npm run test:runejecuta todo una vez y termina con código de salida 0 o 1. Es el modo para la integración continua.npm run test -- --project servidorejecuta solo un proyecto.
Añade a .gitignore lo que generan las pruebas:
e2e.db
e2e.db-*
/test-results/
/playwright-report/
/playwright/.cache/
⚠️ Importante: En Windows, Vitest puede fallar al arrancar con
[vitest-pool-runner]: Timeout waiting for worker to respond, sobre todo en equipos con poca memoria o con el antivirus revisando cada fichero que se abre. Vitest lanza un proceso por núcleo, y alguno no llega a contestar a tiempo. La solución es usar hilos en lugar de procesos y uno solo:npx vitest run --pool=threads --maxWorkers=1(o, envitest.config.mts,pool: 'threads'ymaxWorkers: 1dentro detest). Funciona siempre, pero es más lento: en el equipo de pruebas de este módulo, la suite completa tardó 60 s con la configuración por defecto y 212 s con un solo hilo.
3. Lógica pura: la base de la pirámide
La forma más barata de probar es no depender de nada. Todo lo que puedas sacar de los componentes y las acciones a funciones normales (validar, convertir, formatear, calcular) se prueba en milisegundos, sin simular nada:
// tests/servidor/validacion.test.ts
import { describe, expect, test } from 'vitest'
import { esquemaDepartamento, formatearPresupuesto } from '@/lib/validacion'
describe('esquemaDepartamento', () => {
test('normaliza el nombre y convierte el presupuesto', () => {
const r = esquemaDepartamento.parse({ nombre: ' calidad ', localidad: 'Toledo', presupuesto: '1200' })
expect(r).toEqual({ nombre: 'CALIDAD', localidad: 'Toledo', presupuesto: 1200 })
})
test('el presupuesto vacío es null, no 0', () => {
const r = esquemaDepartamento.parse({ nombre: 'Calidad', localidad: 'Toledo', presupuesto: '' })
expect(r.presupuesto).toBeNull()
})
test.each([
['-5', 'No puede ser negativo'],
['abc', 'Invalid input: expected number, received NaN'],
])('rechaza el presupuesto %j', (presupuesto, mensaje) => {
const r = esquemaDepartamento.safeParse({ nombre: 'Calidad', localidad: 'Toledo', presupuesto })
expect(r.success).toBe(false)
expect(r.error?.issues[0].message).toBe(mensaje)
})
})
describe('formatearPresupuesto', () => {
test.each([
[500000, '500.000 €'],
[1500, '1.500 €'],
[null, 'Sin presupuesto'],
])('%j -> %s', (valor, esperado) => {
// toLocaleString usa un espacio duro (U+00A0) antes del símbolo €
expect(formatearPresupuesto(valor).replace(/\u00a0/g, ' ')).toBe(esperado)
})
})
Dos detalles que se descubrieron escribiendo estas pruebas:
- El mensaje de
abces el de Zod 4 por defecto, en inglés:Invalid input: expected number, received NaN.Number('abc')daNaN, yz.number()rechazaNaN. Si quieres un mensaje propio, pásalo:z.number({ error: 'Escribe un número' }). La prueba te avisa de que ese mensaje llegaría al usuario tal cual. - El espacio antes de
€no es un espacio normal, sino un espacio de no separación (U+00A0), para que la cifra y el símbolo no se separen al saltar de línea. Si comparas con'500.000 €'escrito con la barra espaciadora, la prueba falla con dos cadenas que parecen iguales. Por eso se normaliza conreplace.
test.each repite la misma prueba con varias filas de datos y pone cada valor en el nombre (%j lo escribe como JSON, %s como texto). En la salida de Vitest aparecen como pruebas separadas:
✓ |servidor| tests/servidor/validacion.test.ts > esquemaDepartamento > rechaza el presupuesto "-5" 4ms
✓ |servidor| tests/servidor/validacion.test.ts > esquemaDepartamento > rechaza el presupuesto "abc" 4ms
✓ |servidor| tests/servidor/validacion.test.ts > formatearPresupuesto > 500000 -> 500.000 € 2ms
✓ |servidor| tests/servidor/validacion.test.ts > formatearPresupuesto > 1500 -> 1.500 € 4ms
✓ |servidor| tests/servidor/validacion.test.ts > formatearPresupuesto > null -> Sin presupuesto 1ms
La etiqueta |servidor| es el nombre del proyecto de Vitest en el que se ha ejecutado.
4. Client Components con Vitest
4.1. El componente: un buscador que escribe en la URL
El buscador de departamentos guarda el texto buscado en la URL (/departamentos?q=ventas), como se explicó en el módulo 18: así la búsqueda se puede compartir, recargar y deshacer con el botón Atrás. Para eso usa los tres hooks de next/navigation:
// app/departamentos/buscador.tsx
'use client'
import { useState, type FormEvent } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
export function Buscador() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [texto, setTexto] = useState(searchParams.get('q') ?? '')
function irA(q: string) {
const parametros = new URLSearchParams(searchParams)
if (q) parametros.set('q', q)
else parametros.delete('q')
const consulta = parametros.toString()
router.push(consulta ? `${pathname}?${consulta}` : pathname)
}
function enviar(evento: FormEvent<HTMLFormElement>) {
evento.preventDefault()
irA(texto.trim())
}
return (
<form role="search" onSubmit={enviar} className="flex gap-2">
<label className="flex items-center gap-2">
Buscar departamento
<input
value={texto}
onChange={(e) => setTexto(e.target.value)}
className="rounded border px-2 py-1"
/>
</label>
<button className="rounded bg-indigo-700 px-3 py-1 text-white">Buscar</button>
{texto && (
<button
type="button"
onClick={() => {
setTexto('')
irA('')
}}
className="rounded border px-3 py-1"
>
Limpiar
</button>
)}
</form>
)
}
Lo que queremos comprobar es nuestra lógica: que se recorta el texto, que se conservan los demás parámetros de la URL y que “Limpiar” quita q. No queremos comprobar el router de Next: eso ya lo prueba el equipo de Next.
4.2. Sin simular nada: invariant expected app router to be mounted
Si renderizas el buscador tal cual, la prueba falla antes de llegar a ninguna aserción:
Error: invariant expected app router to be mounted
useRouter de next/navigation lee el router de un contexto de React que crea el layout raíz de Next al arrancar la aplicación. En una prueba no hay aplicación: el componente se monta solo, sin ese proveedor alrededor, y el hook se niega a continuar. usePathname y useSearchParams también leen contextos que solo existen dentro de Next, así que se simulan los tres juntos.
⚠️ Importante: No intentes “montar el router de verdad” en la prueba importando los contextos internos de Next (
next/dist/shared/lib/app-router-context.shared-runtime). Son rutas internas que cambian sin aviso entre versiones. Lo que sí es estable es la API pública denext/navigation, y esa es la que se simula.
4.3. Simular next/navigation con vi.mock y vi.hoisted
// tests/cliente/buscador.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { Buscador } from '@/app/departamentos/buscador'
// vi.mock se eleva al principio del fichero: lo que use tiene que crearse con vi.hoisted
const navegacion = vi.hoisted(() => ({
push: vi.fn(),
parametros: new URLSearchParams(),
}))
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: navegacion.push,
replace: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
prefetch: vi.fn(),
}),
usePathname: () => '/departamentos',
useSearchParams: () => navegacion.parametros,
}))
describe('Buscador', () => {
beforeEach(() => {
navegacion.parametros = new URLSearchParams()
})
test('navega a ?q= con el texto escrito, sin espacios sobrantes', async () => {
const usuario = userEvent.setup()
render(<Buscador />)
await usuario.type(screen.getByRole('textbox', { name: 'Buscar departamento' }), ' ventas ')
await usuario.click(screen.getByRole('button', { name: 'Buscar' }))
expect(navegacion.push).toHaveBeenCalledTimes(1)
expect(navegacion.push).toHaveBeenCalledWith('/departamentos?q=ventas')
})
test('arranca con el valor que ya tiene la URL y conserva los demás parámetros', async () => {
navegacion.parametros = new URLSearchParams('q=inf&orden=nombre')
const usuario = userEvent.setup()
render(<Buscador />)
const caja = screen.getByRole('textbox', { name: 'Buscar departamento' })
expect(caja).toHaveValue('inf')
await usuario.clear(caja)
await usuario.type(caja, 'mar{Enter}')
expect(navegacion.push).toHaveBeenCalledWith('/departamentos?q=mar&orden=nombre')
})
test('Limpiar quita q de la URL y vacía la caja', async () => {
navegacion.parametros = new URLSearchParams('q=ventas')
const usuario = userEvent.setup()
render(<Buscador />)
await usuario.click(screen.getByRole('button', { name: 'Limpiar' }))
expect(navegacion.push).toHaveBeenCalledWith('/departamentos')
expect(screen.getByRole('textbox')).toHaveValue('')
expect(screen.queryByRole('button', { name: 'Limpiar' })).not.toBeInTheDocument()
})
})
Cómo funciona:
vi.mock('next/navigation', fábrica)sustituye el módulo para este fichero: cuandobuscador.tsximportauseRouter, recibe la función de la fábrica. Vitest mueve las llamadas avi.mockal principio del fichero, antes de losimport, para que la sustitución esté lista cuando se cargue el componente.vi.hoistedexiste por ese movimiento. La fábrica se ejecuta antes que el resto del fichero, así que no puede leer variables declaradas en él. Se comprobó conconst router = { push: vi.fn() }arriba yuseRouter: vi.fn().mockReturnValue(router)en la fábrica: el fichero entero falla conError: [vitest] There was an error when mocking a module. If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file.(Curiosamente,useRouter: () => ({ push })con unconst pushnormal funciona, porque la variable solo se lee al renderizar, cuando ya existe. No te fíes de esa casualidad.)vi.hoistedeleva también su contenido, y lo que devuelve está disponible en la fábrica y en las pruebas.useSearchParamsdevuelve unURLSearchParamsnormal. El de Next es unReadonlyURLSearchParams, que tiene los mismos métodos de lectura (get,has,toString…) pero lanza error ensetodelete. Para estas pruebas basta con el estándar. Si tu componente modificara por error el objeto que recibe, en la aplicación real fallaría y en la prueba no: por eso el componente hace una copia connew URLSearchParams(searchParams).- El objeto de
useRoutertiene todos los métodos del router real, aunque este componente solo usepush. Si mañana alguien cambiapushporreplace, la prueba falla con un mensaje claro (“expected push to have been called”) en lugar de unreplace is not a function. {Enter}enusuario.typepulsa la tecla Intro, que envía el formulario. Es otra forma de comprobar que el botón no es la única vía.
💡 Consejo: Si muchos ficheros de prueba necesitan
next/navigation, mueve elvi.mockatests/preparar-cliente.tsy exporta desde un módulo de ayuda el objetonavegacionpara que cada prueba configure la URL. No lo hagas desde el primer día: con uno o dos ficheros, tener la simulación a la vista hace la prueba más fácil de leer.
4.4. next/link no necesita simulación
El componente Link de next/link sí funciona en jsdom sin simular nada: se renderiza como un <a href> normal. La lista de departamentos del apartado 5 lo usa y la prueba comprueba su href sin preparar nada. Lo que Link hace dentro de Next (precargar la página de destino y navegar sin recargar) se comprueba con Playwright.
4.5. Formularios con Server Actions: simular la acción
El formulario de alta es un Client Component que llama a una Server Action a través de useActionState (módulo 10):
// app/departamentos/formulario-alta.tsx
'use client'
import { useActionState } from 'react'
import { altaDepartamento, type EstadoFormulario } from './acciones'
const inicial: EstadoFormulario = { ok: true, mensaje: '' }
const campos = ['nombre', 'localidad', 'presupuesto'] as const
export function FormularioAlta() {
const [estado, accion, pendiente] = useActionState(altaDepartamento, inicial)
return (
<form action={accion} aria-label="Nuevo departamento" className="space-y-2 rounded bg-white p-4 shadow">
<h2 className="font-semibold">Nuevo departamento</h2>
<div className="flex flex-wrap gap-2">
{campos.map((campo) => (
<label key={campo} className="flex flex-col text-sm capitalize">
{campo}
<input
name={campo}
className="rounded border px-2 py-1"
inputMode={campo === 'presupuesto' ? 'decimal' : undefined}
aria-invalid={estado.errores?.[campo] ? true : undefined}
/>
<span className="text-xs normal-case text-red-700">{estado.errores?.[campo]?.[0]}</span>
</label>
))}
</div>
<button disabled={pendiente} className="rounded bg-indigo-700 px-3 py-1 text-white disabled:opacity-50">
{pendiente ? 'Guardando…' : 'Crear'}
</button>
{estado.mensaje && (
<p role="status" className={estado.ok ? 'text-green-700' : 'text-red-700'}>
{estado.mensaje}
</p>
)}
</form>
)
}
En la aplicación real, altaDepartamento no es la función que escribiste: el compilador de Next la sustituye en el paquete del navegador por una referencia que hace una petición POST al servidor. En Vitest no hay compilador de Next, así que si no simulas nada, el formulario importaría la acción real… y con ella Zod, lib/db, server-only y next/cache. En jsdom, eso es exactamente lo que no quieres.
La solución es sustituir el módulo acciones.ts entero por una función simulada. Para el formulario, una Server Action es solo eso: una función asíncrona que recibe (estadoAnterior, formData) y devuelve un estado nuevo.
// tests/cliente/formulario-alta.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test, vi } from 'vitest'
import { FormularioAlta } from '@/app/departamentos/formulario-alta'
import { altaDepartamento } from '@/app/departamentos/acciones'
// Sustituimos el módulo de acciones entero: ni Zod, ni base de datos, ni Next.
vi.mock('@/app/departamentos/acciones', () => ({
altaDepartamento: vi.fn(),
}))
const alta = vi.mocked(altaDepartamento)
describe('FormularioAlta', () => {
test('envía los campos a la acción y muestra su mensaje', async () => {
alta.mockResolvedValue({ ok: true, mensaje: 'Creado el departamento 71 (CALIDAD).' })
const usuario = userEvent.setup()
render(<FormularioAlta />)
await usuario.type(screen.getByRole('textbox', { name: 'nombre' }), 'Calidad')
await usuario.type(screen.getByRole('textbox', { name: 'localidad' }), 'Toledo')
await usuario.click(screen.getByRole('button', { name: 'Crear' }))
expect(await screen.findByRole('status')).toHaveTextContent('Creado el departamento 71 (CALIDAD).')
// useActionState llama a la acción con (estadoAnterior, formData)
expect(alta).toHaveBeenCalledTimes(1)
const [estadoAnterior, datos] = alta.mock.calls[0]
expect(estadoAnterior).toEqual({ ok: true, mensaje: '' })
expect(Object.fromEntries(datos)).toEqual({ nombre: 'Calidad', localidad: 'Toledo', presupuesto: '' })
})
test('marca los campos con error que devuelve la acción', async () => {
alta.mockResolvedValue({
ok: false,
mensaje: 'Revisa los campos marcados.',
errores: { localidad: ['Mínimo 2 caracteres'] },
})
const usuario = userEvent.setup()
render(<FormularioAlta />)
await usuario.click(screen.getByRole('button', { name: 'Crear' }))
expect(await screen.findByRole('status')).toHaveTextContent('Revisa los campos marcados.')
expect(screen.getByText('Mínimo 2 caracteres')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: /localidad/ })).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByRole('textbox', { name: 'nombre' })).not.toHaveAttribute('aria-invalid')
})
test('desactiva el botón mientras la acción está pendiente', async () => {
let terminar!: (estado: Awaited<ReturnType<typeof altaDepartamento>>) => void
alta.mockImplementation(() => new Promise((resolver) => (terminar = resolver)))
const usuario = userEvent.setup()
render(<FormularioAlta />)
await usuario.click(screen.getByRole('button', { name: 'Crear' }))
expect(await screen.findByRole('button', { name: 'Guardando…' })).toBeDisabled()
terminar({ ok: true, mensaje: 'Hecho.' })
expect(await screen.findByRole('button', { name: 'Crear' })).toBeEnabled()
expect(screen.getByRole('status')).toHaveTextContent('Hecho.')
})
})
Lo que demuestra cada prueba:
- El contrato con la acción. La primera prueba comprueba lo que el formulario envía (
FormDatacon los tres campos, el presupuesto vacío como'') y que pinta lo que la acción devuelve. Si alguien cambia elnamede un<input>, falla aquí, no en producción. - Los errores por campo. La acción simulada devuelve un estado de error inventado; la prueba comprueba que el componente lo pinta en el campo correcto y lo marca con
aria-invalid. No hace falta que Zod falle de verdad: eso se prueba en el apartado 7. - El estado pendiente. Una promesa que no se resuelve hasta que tú llamas a
terminardeja el formulario “a medias”, y puedes comprobar el botón desactivado con su texto “Guardando…”. Es la técnica para probar cualquier estado de carga.
vi.mocked(altaDepartamento) no cambia nada en tiempo de ejecución: solo le dice a TypeScript que esa función es un vi.fn(), para que mockResolvedValue y mock.calls tengan tipos. Si la acción devolviera un estado con otra forma, mockResolvedValue no compilaría.
📘 Concepto:
useActionStatey<form action={función}>son de React 19, no de Next. Por eso funcionan en jsdom: React intercepta el envío del formulario, construye elFormDatay llama a tu función, aunque no haya ningún servidor. Lo único que es de Next es el viaje de la llamada por la red cuando la función es una Server Action, y ese viaje se prueba con Playwright.
Figura 2. Qué es real y qué está simulado en cada clase de prueba. El borde naranja marca la frontera: la base de datos de prueba en el servidor y la acción simulada en el cliente.
5. Server Components asíncronos: qué se puede probar y qué no
5.1. El componente
La lista de departamentos es un Server Component asíncrono: espera el filtro de la URL y la lista de la capa de datos, y devuelve una tabla. La página le pasa el filtro como promesa, sin await, dentro de un <Suspense>, como se explicó en el módulo 09:
// app/departamentos/lista.tsx
import Link from 'next/link'
import { listarDepartamentos } from '@/lib/db/departamentos'
import { formatearPresupuesto } from '@/lib/validacion'
export async function ListaDepartamentos({ filtro }: { filtro: Promise<string> }) {
const [q, lista] = await Promise.all([filtro, listarDepartamentos()])
const visibles = q
? lista.filter((d) => d.nombre.toLowerCase().includes(q.toLowerCase()))
: lista
if (visibles.length === 0) return <p>No hay departamentos que contengan «{q}».</p>
return (
<table className="w-full max-w-3xl bg-white shadow">
<thead>
<tr className="bg-indigo-100 text-left">
<th className="p-2">Nº</th>
<th className="p-2">Nombre</th>
<th className="p-2">Localidad</th>
<th className="p-2">Presupuesto</th>
<th className="p-2">Empleados</th>
</tr>
</thead>
<tbody>
{visibles.map((d) => (
<tr key={d.deptId} className="border-t">
<td className="p-2">{d.deptId}</td>
<td className="p-2">
<Link href={`/departamentos/${d.deptId}`} className="text-indigo-700 underline">
{d.nombre}
</Link>
</td>
<td className="p-2">{d.localidad}</td>
<td className="p-2">{formatearPresupuesto(d.presupuesto)}</td>
<td className="p-2">{d.empleados}</td>
</tr>
))}
</tbody>
</table>
)
}
// app/departamentos/page.tsx
import { Suspense } from 'react'
import { Buscador } from './buscador'
import { FormularioAlta } from './formulario-alta'
import { ListaDepartamentos } from './lista'
export default function PaginaDepartamentos({ searchParams }: PageProps<'/departamentos'>) {
// No hacemos await aquí: pasamos la promesa hacia abajo, dentro de <Suspense>
const filtro = searchParams.then((sp) => (typeof sp.q === 'string' ? sp.q : ''))
return (
<main className="space-y-4 p-6">
<h1 className="text-2xl font-bold">Departamentos</h1>
<Suspense fallback={null}>
<Buscador />
</Suspense>
<Suspense fallback={<p>Cargando departamentos…</p>}>
<ListaDepartamentos filtro={filtro} />
</Suspense>
<FormularioAlta />
</main>
)
}
El <Suspense> alrededor del buscador es obligatorio con cacheComponents: useSearchParams lee un dato de la petición, y sin él el build falla (módulo 18).

Figura 3. La página que se prueba en este módulo, servida por next start con la base de datos de E2E.
5.2. Cuatro formas de renderizarlo, comprobadas
Testing Library renderiza con react-dom en jsdom, es decir, con el React del navegador. Y en el navegador, React 19 no admite componentes asíncronos: solo el renderizador de servidor sabe esperar una promesa que devuelve un componente. Se probaron cuatro formas con un componente asíncrono mínimo:
async function Asincrono() {
const datos = await Promise.resolve(['VENTAS', 'I+D'])
return <ul>{datos.map((d) => <li key={d}>{d}</li>)}</ul>
}
| Cómo se renderizó | Resultado real |
|---|---|
render(<Asincrono />) | Aviso <Asincrono> is an async Client Component. Only Server Components can be async at the moment. El DOM se queda vacío: <body><div /></body> y findAllByRole('listitem') falla |
render(<Suspense fallback={<p>Cargando</p>}><Asincrono /></Suspense>) | El mismo aviso, y la página se queda para siempre en “Cargando” |
await act(async () => { render(<Suspense ...><Asincrono /></Suspense>) }) | Pinta los dos elementos, pero con dos avisos: el de “async Client Component” y A component was suspended by an uncached promise. Creating promises inside a Client Component or hook is not yet supported |
render(await Asincrono()) | Funciona, sin avisos |
La tercera fila parece una solución, pero React te está diciendo que no es un uso admitido: funciona por casualidad, porque la promesa se resuelve muy deprisa, y puede dejar de funcionar en cualquier versión. La forma correcta es la cuarta.
5.3. render(await Componente(props))
Un componente de función es solo eso, una función. Uno asíncrono es una función que devuelve una promesa de JSX. Si la llamas tú, la esperas y le das el JSX resultante a render, React del navegador solo ve elementos normales:
// tests/cliente/lista.test.tsx
import { render, screen, within } from '@testing-library/react'
import { describe, expect, test, vi } from 'vitest'
import { ListaDepartamentos } from '@/app/departamentos/lista'
import { listarDepartamentos } from '@/lib/db/departamentos'
// La capa de datos simulada: el Server Component no llega a tocar SQLite
vi.mock('@/lib/db/departamentos', () => ({
listarDepartamentos: vi.fn(async () => [
{ deptId: 10, nombre: 'DIRECCIÓN', localidad: 'Madrid', presupuesto: 500000, empleados: 1 },
{ deptId: 20, nombre: 'VENTAS', localidad: 'Madrid', presupuesto: 350000, empleados: 6 },
{ deptId: 70, nombre: 'INVESTIGACIÓN', localidad: 'Bilbao', presupuesto: null, empleados: 0 },
]),
}))
describe('ListaDepartamentos (Server Component asíncrono)', () => {
test('pinta una fila por departamento, con enlace y presupuesto formateado', async () => {
// Un Server Component asíncrono es una función que devuelve una promesa de JSX:
// la llamamos, esperamos el JSX y lo renderizamos.
render(await ListaDepartamentos({ filtro: Promise.resolve('') }))
const filas = screen.getAllByRole('row').slice(1) // la primera es la cabecera
expect(filas).toHaveLength(3)
expect(within(filas[0]).getByRole('link', { name: 'DIRECCIÓN' })).toHaveAttribute('href', '/departamentos/10')
expect(filas[0]).toHaveTextContent('500.000 €')
expect(filas[2]).toHaveTextContent('Sin presupuesto')
expect(listarDepartamentos).toHaveBeenCalledTimes(1)
})
test('filtra sin distinguir mayúsculas y avisa si no hay resultados', async () => {
const { unmount } = render(await ListaDepartamentos({ filtro: Promise.resolve('ven') }))
expect(screen.getAllByRole('row')).toHaveLength(2)
expect(screen.getByRole('link')).toHaveTextContent('VENTAS')
unmount()
render(await ListaDepartamentos({ filtro: Promise.resolve('xyz') }))
expect(screen.getByText('No hay departamentos que contengan «xyz».')).toBeInTheDocument()
})
})
- La capa de datos se simula. Este fichero está en el proyecto
cliente(jsdom), donde no hay base de datos ni simulaciones de Next. Al sustituir@/lib/db/departamentoscon una fábrica, el módulo real no llega a cargarse, y con él tampocoserver-only,next/cacheni@libsql/client. La prueba se centra en lo que hace el componente con los datos: filtrar, formatear, enlazar. filtro: Promise.resolve('ven')imita lo que hace la página: el componente espera una promesa y se la damos ya resuelta.toHaveTextContent('500.000 €')funciona aunque el texto tenga el espacio duro U+00A0: jest-dom normaliza los espacios en blanco antes de comparar.
5.4. Los límites: hijos asíncronos y todo lo que hace Next
La técnica tiene un límite claro: solo resuelve el componente que llamas. Si su JSX contiene otro componente asíncrono, ese hijo llega a render sin resolver, como en la primera fila de la tabla. Se comprobó con una “página” asíncrona que contiene el Asincrono de antes:
async function Pagina() {
const titulo = await Promise.resolve('Departamentos')
return <main><h1>{titulo}</h1><Asincrono /></main>
}
render(await Pagina())
// Aviso: <Asincrono> is an async Client Component...
// screen.getByRole('heading') falla: Unable to find an accessible element with the role "heading"
No falla solo el hijo: el hijo suspende y, sin un <Suspense> que lo recoja, React no pinta nada del árbol, ni siquiera el <h1> del padre, que no tenía ningún problema.
Por eso la página PaginaDepartamentos no se prueba así: contiene <ListaDepartamentos>, que es asíncrono. Puedes probar cada componente asíncrono por separado, o probar la página entera con Playwright. Y hay cosas que con Vitest no se pueden probar de ninguna manera, porque son trabajo de Next y no de tu componente:
| Qué | Por qué no en Vitest | Dónde se prueba |
|---|---|---|
El streaming de <Suspense> (primero el fallback, luego el contenido) | El componente se resuelve entero antes de render | Playwright |
Que 'use cache' guarde el resultado y updateTag lo renueve | La directiva solo la entiende el compilador de Next; en Vitest es una cadena sin efecto | Playwright |
Que la página sea ○, ◐ o ƒ en el build | Lo decide next build | La tabla de next build |
notFound(), redirect() convertidos en 404 o 307 | En Vitest solo lanzan un error especial (lo verás en los ejercicios 3 y 4) | Playwright |
generateMetadata, layout.tsx, loading.tsx, error.tsx funcionando juntos | Los compone el router de Next | Playwright |
💡 Consejo: Diseña pensando en las pruebas: si un Server Component tiene mucha lógica, sácala a una función pura (como
formatearPresupuesto) o a un Client Component síncrono que reciba los datos por props. El Server Component se queda en “leer datos y pasarlos”, que es lo que menos falla y lo que Playwright comprueba de paso.
6. Simular la capa de datos o usar una base de datos de prueba
En el apartado anterior la capa de datos estaba simulada: la prueba decidía qué devolvía listarDepartamentos. Para probar las Server Actions hay dos caminos, y cada uno comprueba cosas distintas:
Simular lib/db con vi.mock | Base de datos SQLite de prueba | |
|---|---|---|
| Qué prueba | Que la acción llama a la capa con los datos correctos y reacciona bien a lo que devuelve o lanza | Todo lo anterior y además las consultas reales, las restricciones (UNIQUE, claves ajenas, CHECK) y la traducción de errores |
| Velocidad | Instantánea | Milisegundos por prueba (SQLite en memoria) |
| Riesgo | La simulación puede no parecerse a la realidad (un error con otra forma, un campo que falta) | Mínimo, si la base de datos de prueba se crea con las mismas migraciones |
| Cuándo | Para provocar casos difíciles de reproducir (disco lleno, base de datos caída) | Por defecto |
Con SQLite la decisión es fácil: crear una base de datos en memoria cuesta milisegundos, así que la opción por defecto es la base de datos real. Con un servidor PostgreSQL o MySQL también se hace (con un contenedor Docker de pruebas), pero es más lento y la tentación de simularlo todo es mayor.
⚠️ Importante: Lo que no debes hacer nunca es probar contra tu
empresa.dbde desarrollo. Las pruebas borran, insertan y dejan datos raros (“PRUEBA 3-1730543”), y su resultado dependería de lo que hayas hecho tú a mano esa mañana. Cada ejecución de pruebas debe partir de un estado conocido.
7. Server Actions con una base de datos SQLite de prueba
7.1. Una base de datos en memoria con las migraciones reales
libSQL acepta la URL especial :memory:: la base de datos vive en la memoria del proceso y desaparece al terminar. Es perfecta para pruebas: rápida, sin ficheros que limpiar, y nueva en cada ejecución.
Como lib/db/index.ts lee process.env.DATABASE_URL al cargarse, basta con fijar la variable en tests/preparar-servidor.ts, que se ejecuta antes. La capa de datos no cambia nada: no tiene código especial para las pruebas.
Una base de datos en memoria empieza vacía, sin tablas. El módulo de ayuda la prepara con las mismas migraciones que usa la aplicación (la carpeta drizzle/ del módulo 16) y la llena con datos.json:
// tests/servidor/bd-prueba.ts
import { migrate } from 'drizzle-orm/libsql/migrator'
import { db } from '@/lib/db'
import * as schema from '@/db/schema'
import datos from '@/datos.json'
let migrada = false
export async function reiniciarBaseDeDatos() {
if (!migrada) {
await migrate(db, { migrationsFolder: 'drizzle' })
migrada = true
}
// Lo mismo que scripts/cargar-datos.ts: vaciar y volver a llenar en una transacción
await db.batch([
db.delete(schema.lineasPedido),
db.delete(schema.asignaciones),
db.delete(schema.pedidos),
db.delete(schema.proyectos),
db.delete(schema.productos),
db.delete(schema.categorias),
db.delete(schema.clientes),
db.delete(schema.empleados),
db.delete(schema.departamentos),
db.insert(schema.departamentos).values(datos.departamentos),
db.insert(schema.empleados).values(datos.empleados),
db.insert(schema.clientes).values(datos.clientes),
db.insert(schema.categorias).values(datos.categorias),
db.insert(schema.productos).values(datos.productos),
db.insert(schema.pedidos).values(
datos.pedidos.map((p) => ({ ...p, estado: p.estado as schema.EstadoPedido })),
),
db.insert(schema.lineasPedido).values(datos.lineasPedido),
db.insert(schema.proyectos).values(datos.proyectos),
db.insert(schema.asignaciones).values(datos.asignaciones),
])
}
// Construye un FormData como el que envía un <form>
export function formulario(campos: Record<string, string>) {
const fd = new FormData()
for (const [clave, valor] of Object.entries(campos)) fd.append(clave, valor)
return fd
}
migratededrizzle-orm/libsql/migratoraplica desde código los ficheros SQL dedrizzle/, igual quedrizzle-kit migratedesde la consola. Usar las migraciones (y nodrizzle-kit pushni unCREATE TABLEescrito a mano) garantiza que la base de datos de prueba es idéntica a la de producción: mismas restricciones, mismos índices. Si una migración nueva rompe algo, las pruebas lo detectan.dbes el de la aplicación, el mismo objeto que usan las acciones. Por eso la base de datos en memoria es compartida: si el módulo de ayuda creara su propio cliente con:memory:, tendría otra base de datos en memoria, vacía, distinta de la que ven las acciones.- El batch vacía y vuelve a llenar todas las tablas en una transacción. Se llama en un
beforeEach, así que cada prueba empieza con los 7 departamentos y los 24 empleados originales, haga lo que haga la anterior. migradaevita repetir la migración en cada prueba del mismo fichero. Vitest aísla cada fichero de pruebas (cada uno carga sus módulos de cero), así que cada fichero migra una vez.
💡 Consejo: Si quieres ver la base de datos después de una prueba que falla, usa un fichero en lugar de memoria:
process.env.DATABASE_URL = 'file:pruebas.db'y ábrelo connpx drizzle-kit studioo con cualquier visor de SQLite. Si ejecutas las pruebas en paralelo con un fichero, que cada proceso use el suyo: Vitest numera sus procesos en la variableVITEST_POOL_ID, así que`file:pruebas-${process.env.VITEST_POOL_ID}.db`evita que dos procesos se pisen los datos.
7.2. Probar la acción como una función
Una Server Action es una función asíncrona exportada. En una prueba se importa y se llama directamente, como haría React con useActionState: estado anterior y FormData.
// tests/servidor/acciones.test.ts
import { eq } from 'drizzle-orm'
import { updateTag } from 'next/cache'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { altaDepartamento, bajaDepartamento } from '@/app/departamentos/acciones'
import { db } from '@/lib/db'
import { departamentos } from '@/db/schema'
import { formulario, reiniciarBaseDeDatos } from './bd-prueba'
const inicial = { ok: true, mensaje: '' }
beforeEach(async () => {
await reiniciarBaseDeDatos()
vi.spyOn(console, 'error').mockImplementation(() => {}) // las acciones registran los fallos
})
describe('altaDepartamento', () => {
test('crea el departamento, lo guarda en mayúsculas y renueva la caché', async () => {
const estado = await altaDepartamento(
inicial,
formulario({ nombre: 'calidad', localidad: 'Toledo', presupuesto: '90000' }),
)
expect(estado).toEqual({ ok: true, mensaje: 'Creado el departamento 71 (CALIDAD).' })
const fila = await db.query.departamentos.findFirst({ where: eq(departamentos.deptId, 71) })
expect(fila).toEqual({ deptId: 71, nombre: 'CALIDAD', localidad: 'Toledo', presupuesto: 90000 })
expect(updateTag).toHaveBeenCalledExactlyOnceWith('departamentos')
})
test('devuelve los errores por campo y no toca la base de datos', async () => {
const estado = await altaDepartamento(
inicial,
formulario({ nombre: 'X', localidad: '', presupuesto: '-1' }),
)
expect(estado.ok).toBe(false)
expect(estado.errores).toEqual({
nombre: ['Mínimo 2 caracteres'],
localidad: ['Mínimo 2 caracteres'],
presupuesto: ['No puede ser negativo'],
})
expect(await db.$count(departamentos)).toBe(7)
expect(updateTag).not.toHaveBeenCalled()
})
test('traduce el UNIQUE de SQLite a un mensaje para el usuario', async () => {
const estado = await altaDepartamento(
inicial,
formulario({ nombre: 'ventas', localidad: 'Cuenca', presupuesto: '' }),
)
expect(estado).toEqual({ ok: false, mensaje: 'Ya existe un registro con ese valor.' })
expect(console.error).toHaveBeenCalledOnce()
expect(updateTag).not.toHaveBeenCalled()
})
})
describe('bajaDepartamento', () => {
test('borra el departamento 70, que no tiene empleados', async () => {
const estado = await bajaDepartamento(inicial, formulario({ deptId: '70' }))
expect(estado).toEqual({ ok: true, mensaje: 'Eliminado el departamento 70.' })
expect(await db.$count(departamentos)).toBe(6)
expect(vi.mocked(updateTag).mock.calls).toEqual([['departamentos'], ['departamento-70']])
})
test('la clave ajena impide borrar VENTAS, que tiene empleados', async () => {
const estado = await bajaDepartamento(inicial, formulario({ deptId: '20' }))
expect(estado).toEqual({
ok: false,
mensaje: 'No se puede: hay otros datos que dependen de este registro.',
})
expect(await db.$count(departamentos)).toBe(7)
})
test('avisa si el departamento ya no existe', async () => {
const estado = await bajaDepartamento(inicial, formulario({ deptId: '99' }))
expect(estado).toEqual({ ok: false, mensaje: 'El departamento 99 ya no existe.' })
})
})
Qué comprueba cada prueba y por qué importa:
- El efecto en la base de datos, no solo la respuesta. La primera prueba no se fía del mensaje “Creado”: lee la fila 71 y comprueba que el nombre se guardó en mayúsculas y el presupuesto como número.
db.$count(tabla)cuenta filas sin escribir una consulta. - El identificador 71 lo asigna SQLite: el mayor
dept_ides 70 y la clave primaria entera se autoincrementa. Como la base de datos se reinicia antes de cada prueba, siempre es 71. Sin reinicio, dependería del orden de las pruebas. - Los errores de validación no llegan a la base de datos. Siguen siendo 7 departamentos y
updateTagno se ha llamado. - Las restricciones reales. El
UNIQUEdedepartamentos.nombrey la clave ajena deempleados.dept_idson de SQLite, no del código. Con la capa de datos simulada, estas dos pruebas no demostrarían nada; aquí demuestran quemensajeDeErrorreconoce el error tal como lo lanza Drizzle 0.45 con libSQL 0.18. Si una actualización cambiara la forma del error, fallarían. updateTages unvi.fn()(lo pusopreparar-servidor.ts), así que se puede preguntar con qué se llamó.toHaveBeenCalledExactlyOnceWithcomprueba a la vez “una sola vez” y “con estos argumentos”. En la baja,mock.callscomprueba las dos etiquetas y su orden.vi.spyOn(console, 'error')silencia elconsole.errorque las acciones hacen al fallar (sin él, la salida de las pruebas se llena de trazas deDrizzleQueryError) y, de paso, permite comprobar que el error se registró.
⚠️ Importante: Lo que no demuestra esta prueba es que la página se renueve al crear un departamento.
updateTagestá simulado: comprobamos que la acción lo pide, no que Next lo haga. Lo segundo lo comprueba la prueba E2E del apartado 10.
7.3. Si usas Next.js 15: revalidateTag en lugar de updateTag 🆕 16.0
🆕 Desde Next.js 16.0.
updateTagno existe en Next.js 15. Si usas Next.js 15: la acción llama arevalidateTag('departamentos')(con un solo argumento) y la prueba compruebaexpect(revalidateTag).toHaveBeenCalledWith('departamentos'); la simulación denext/cachedel apartado 2.3 ya incluyerevalidateTag.
En Next.js 15, revalidateTag también lanza un error si se llama fuera de Next (comprobado en 15.5.28: Error: Invariant: static generation store missing in revalidateTag departamentos), así que la simulación es igual de necesaria. Lo único que cambia es el nombre de la función que compruebas.
8. La capa de datos y la caché
Las funciones de lib/db se prueban igual que las acciones, contra la base de datos de prueba:
// lib/db/departamentos.ts (la función nueva; el resto es la del módulo 16)
// Sin caché: dato del momento. connection() evita que el prerender lo congele.
export async function contarEmpleados(deptId: number) {
await connection()
const [fila] = await db
.select({ total: count() })
.from(empleados)
.where(eq(empleados.deptId, deptId))
return fila.total
}
// tests/servidor/departamentos-db.test.ts
import { cacheLife, cacheTag } from 'next/cache'
import { connection } from 'next/server'
import { beforeAll, describe, expect, test } from 'vitest'
import {
contarEmpleados,
listarDepartamentos,
obtenerDepartamento,
} from '@/lib/db/departamentos'
import { reiniciarBaseDeDatos } from './bd-prueba'
beforeAll(async () => {
await reiniciarBaseDeDatos() // solo leemos: basta con preparar una vez
})
describe('capa de datos de departamentos', () => {
test('listarDepartamentos cuenta los empleados, también los 0 del departamento 70', async () => {
const lista = await listarDepartamentos()
expect(lista.map((d) => [d.deptId, d.empleados])).toEqual([
[10, 1], [20, 6], [30, 6], [40, 3], [50, 3], [60, 4], [70, 0],
])
expect(lista.at(-1)).toMatchObject({ nombre: 'INVESTIGACIÓN', presupuesto: null })
})
test("declara su etiqueta y su vida en caché ('use cache' no se ejecuta en Vitest)", async () => {
await listarDepartamentos()
expect(cacheTag).toHaveBeenCalledWith('departamentos')
expect(cacheLife).toHaveBeenCalledWith('days')
})
test('obtenerDepartamento trae sus empleados ordenados por apellidos', async () => {
const dept = await obtenerDepartamento(40)
expect(dept?.nombre).toBe('CONTABILIDAD')
const apellidos = dept!.empleados.map((e) => e.apellidos)
expect(apellidos).toEqual([...apellidos].sort((a, b) => a.localeCompare(b)))
expect(await obtenerDepartamento(99)).toBeNull()
})
test('contarEmpleados espera a connection() antes de consultar', async () => {
expect(await contarEmpleados(20)).toBe(6)
expect(connection).toHaveBeenCalledOnce()
})
})
beforeAllen lugar debeforeEach: estas pruebas solo leen, así que basta con preparar la base de datos una vez por fichero.- El
countconleftJoindevuelve 0 para el departamento 70 (sin empleados), como se explicó en el módulo 16. Esta prueba es la que te avisaría si alguien cambiacount(empleados.empId)porcount(): el 70 saldría con 1. - El empleado sin departamento (
deptIdnull en los datos) no aparece en ningún recuento. La suma de la lista es 23, no 24.
8.1. 'use cache' en Vitest: una cadena sin efecto
Dentro de Next, 'use cache' hace que el compilador envuelva la función: la primera llamada consulta SQLite y las siguientes devuelven el resultado guardado. En Vitest no hay compilador de Next: la directiva es una cadena suelta al principio de la función, como 'use strict' mal escrito, y no hace nada. Cada llamada a listarDepartamentos consulta la base de datos.
Para las pruebas es justo lo que quieres: si la función guardara su resultado, la prueba del alta vería la lista vieja. Pero ten claro el alcance de la segunda prueba: comprueba que la función declara la etiqueta departamentos (la que la acción renueva con updateTag), no que la caché funcione. Si alguien escribe cacheTag('departamento') en singular, esta prueba falla, y eso evita un fallo muy difícil de ver: la caché nunca se renovaría y la página mostraría datos viejos hasta que caducasen.
8.2. Si usas Next.js 15: unstable_cache 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 sin
cacheComponents, la capa de datos guarda en caché conunstable_cache(función, claves, { tags })en lugar de'use cache'ycacheTag. Si usas Next.js 15: simulanext/cacheconunstable_cache: (fn) => fn, que devuelve la función sin caché, y comprueba las etiquetas en las opciones del tercer argumento si lo necesitas.
Sin simularla, unstable_cache falla de otra forma que cacheTag. Comprobado en Next.js 15.5.28 fuera de Next: crear la función envuelta (unstable_cache(async () => 1, ['k'], { tags: ['t'] })) funciona, pero llamarla lanza Error: Invariant: incrementalCache missing in unstable_cache. Es decir, el fichero se importa sin problemas y la prueba falla al llamar a la función de la capa de datos.
9. Route Handlers y proxy
9.1. Un Route Handler es una función que recibe un Request
Los Route Handlers (módulo 12) son las funciones más fáciles de probar de Next: reciben un Request estándar de la Web y devuelven un Response. Node 18+ tiene ambas clases, así que se construyen en la prueba sin ningún servidor.
// app/api/departamentos/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { z } from 'zod'
import { crearDepartamento, listarDepartamentos } from '@/lib/db/departamentos'
import { codigoSqlite } from '@/lib/db/errores'
import { esquemaDepartamento } from '@/lib/validacion'
export async function GET(request: NextRequest) {
const q = request.nextUrl.searchParams.get('q')?.toLowerCase() ?? ''
const lista = await listarDepartamentos()
return NextResponse.json(q ? lista.filter((d) => d.nombre.toLowerCase().includes(q)) : lista)
}
export async function POST(request: Request) {
const cuerpo = await request.json().catch(() => null)
if (cuerpo === null) {
return NextResponse.json({ error: 'El cuerpo debe ser JSON' }, { status: 400 })
}
// El esquema espera texto, como en un formulario: convertimos el presupuesto
const resultado = esquemaDepartamento.safeParse({
...cuerpo,
presupuesto: cuerpo.presupuesto == null ? '' : String(cuerpo.presupuesto),
})
if (!resultado.success) {
return NextResponse.json(
{ error: 'Datos no válidos', campos: z.flattenError(resultado.error).fieldErrors },
{ status: 400 },
)
}
try {
const nuevo = await crearDepartamento(resultado.data)
return NextResponse.json(nuevo, {
status: 201,
headers: { Location: `/api/departamentos/${nuevo.deptId}` },
})
} catch (error) {
if (codigoSqlite(error) === 'SQLITE_CONSTRAINT_UNIQUE') {
return NextResponse.json({ error: 'Ya existe un departamento con ese nombre' }, { status: 409 })
}
throw error
}
}
// app/api/departamentos/[id]/route.ts
import { NextResponse } from 'next/server'
import { obtenerDepartamento } from '@/lib/db/departamentos'
export async function GET(_request: Request, ctx: RouteContext<'/api/departamentos/[id]'>) {
const { id } = await ctx.params
const deptId = Number(id)
if (!Number.isInteger(deptId)) {
return NextResponse.json({ error: 'Identificador no válido' }, { status: 400 })
}
const dept = await obtenerDepartamento(deptId)
if (!dept) return NextResponse.json({ error: 'No existe' }, { status: 404 })
return NextResponse.json(dept)
}
Las pruebas, en el proyecto servidor y con la base de datos de prueba:
// tests/servidor/rutas-api.test.ts
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, test } from 'vitest'
import { GET, POST } from '@/app/api/departamentos/route'
import { GET as GET_UNO } from '@/app/api/departamentos/[id]/route'
import { reiniciarBaseDeDatos } from './bd-prueba'
const BASE = 'http://localhost/api/departamentos'
function peticionJson(cuerpo: unknown) {
return new Request(BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: typeof cuerpo === 'string' ? cuerpo : JSON.stringify(cuerpo),
})
}
beforeEach(async () => {
await reiniciarBaseDeDatos()
})
describe('GET /api/departamentos', () => {
test('devuelve los 7 departamentos en JSON', async () => {
const respuesta = await GET(new NextRequest(BASE))
expect(respuesta.status).toBe(200)
expect(respuesta.headers.get('content-type')).toBe('application/json')
const cuerpo = await respuesta.json()
expect(cuerpo).toHaveLength(7)
})
test('filtra con ?q=', async () => {
const respuesta = await GET(new NextRequest(`${BASE}?q=ing`))
const nombres = (await respuesta.json()).map((d: { nombre: string }) => d.nombre)
expect(nombres).toEqual(['MARKETING'])
})
})
describe('POST /api/departamentos', () => {
test('201 con la cabecera Location', async () => {
const respuesta = await POST(peticionJson({ nombre: 'Calidad', localidad: 'Toledo', presupuesto: 90000 }))
expect(respuesta.status).toBe(201)
expect(respuesta.headers.get('location')).toBe('/api/departamentos/71')
expect(await respuesta.json()).toEqual({ deptId: 71, nombre: 'CALIDAD', localidad: 'Toledo', presupuesto: 90000 })
})
test('400 si el cuerpo no es JSON', async () => {
const respuesta = await POST(peticionJson('esto no es json'))
expect(respuesta.status).toBe(400)
expect(await respuesta.json()).toEqual({ error: 'El cuerpo debe ser JSON' })
})
test('400 con los errores por campo', async () => {
const respuesta = await POST(peticionJson({ nombre: 'Calidad' }))
expect(respuesta.status).toBe(400)
expect((await respuesta.json()).campos).toHaveProperty('localidad')
})
test('409 si el nombre ya existe', async () => {
const respuesta = await POST(peticionJson({ nombre: 'ventas', localidad: 'Cuenca' }))
expect(respuesta.status).toBe(409)
})
})
describe('GET /api/departamentos/[id]', () => {
// params es una promesa, como la que construye Next
const contexto = (id: string) => ({ params: Promise.resolve({ id }) })
test('200 con el departamento y sus empleados', async () => {
const respuesta = await GET_UNO(new Request(`${BASE}/40`), contexto('40'))
expect(respuesta.status).toBe(200)
const cuerpo = await respuesta.json()
expect(cuerpo.nombre).toBe('CONTABILIDAD')
expect(cuerpo.empleados).toHaveLength(3)
})
test.each([
['99', 404],
['abc', 400],
])('%s -> %i', async (id, estado) => {
const respuesta = await GET_UNO(new Request(`${BASE}/${id}`), contexto(id))
expect(respuesta.status).toBe(estado)
})
})
GETespera unNextRequest(usarequest.nextUrl), así que la prueba construye uno:new NextRequest(url).POSTsolo necesita unRequestestándar. Si le pasas unRequesta una función que esperaNextRequest, TypeScript se queja y, si lo fuerzas,nextUrlesundefined.- La URL necesita un dominio (
http://localhost/...):Requestno acepta rutas relativas. El dominio no importa, porque no se hace ninguna petición de red. paramses una promesa desde Next.js 15 (módulo 04). La prueba construye el segundo argumento igual que lo haría Next:{ params: Promise.resolve({ id }) }.?q=ingdevuelve solo MARKETING: es el único nombre que contiene “ing”. INVESTIGACIÓN empieza por “in” pero no contiene “ing”. Una prueba con un valor elegido con cuidado detecta más errores que una con “a”.- El 409 demuestra que el handler distingue un error de la base de datos esperado (nombre duplicado) de uno inesperado, que se relanza para que Next responda 500.
💡 Consejo: Probar el handler llamando a la función no comprueba que Next lo publique en la ruta correcta (
app/api/departamentos/route.ts→/api/departamentos), ni sus cabeceras de caché. Una sola prueba E2E conrequest.get('/api/departamentos')de Playwright lo cubre.
9.2. proxy.ts: el matcher y la función 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 el fichero se llama
middleware.tsy la función exportadamiddleware. Si usas Next.js 15: importa{ config, middleware }desde@/middlewarey usa exactamente las mismas utilidades de prueba (unstable_doesMiddlewareMatch,getRedirectUrl,isRewrite), que existen desde Next.js 15.1.
El proxy del módulo 13 protege las rutas de gestión: si no hay cookie de sesión, redirige a /login.
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
if (!request.cookies.has('sesion')) {
const login = new URL('/login', request.url)
login.searchParams.set('desde', request.nextUrl.pathname)
return NextResponse.redirect(login)
}
return NextResponse.next()
}
export const config = {
matcher: ['/gestion/:path*'],
}
Hay dos cosas que probar: cuándo se ejecuta (el matcher) y qué hace (la función). Para lo primero, Next trae utilidades experimentales en next/experimental/testing/server:
// tests/servidor/proxy.test.ts
import { NextRequest } from 'next/server'
import {
getRedirectUrl,
unstable_doesMiddlewareMatch,
} from 'next/experimental/testing/server'
import { describe, expect, test } from 'vitest'
import { config, proxy } from '@/proxy'
describe('proxy.ts', () => {
test.each([
['/gestion', true],
['/gestion/empleados/105', true],
['/departamentos', false],
['/', false],
])('el matcher %s -> %s', (url, esperado) => {
expect(unstable_doesMiddlewareMatch({ config, url })).toBe(esperado)
})
test('sin cookie de sesión redirige a /login recordando de dónde venía', () => {
const respuesta = proxy(new NextRequest('http://localhost/gestion/empleados'))
expect(respuesta.status).toBe(307)
expect(getRedirectUrl(respuesta)).toBe('http://localhost/login?desde=%2Fgestion%2Fempleados')
})
test('con cookie de sesión deja pasar', () => {
const peticion = new NextRequest('http://localhost/gestion', {
headers: { cookie: 'sesion=abc' },
})
const respuesta = proxy(peticion)
expect(respuesta.headers.get('x-middleware-next')).toBe('1')
expect(getRedirectUrl(respuesta)).toBeNull()
})
})
unstable_doesMiddlewareMatch({ config, url })aplica elmatcherigual que Next y dice si elproxyse ejecutaría para esa URL. Admite tambiénheaders,cookiesynextConfig, para matchers con condicioneshas/missingo conbasePath.getRedirectUrl(respuesta)devuelve la URL de destino de una redirección, onull. Hay tambiénisRewriteygetRewrittenUrlpara las reescrituras.NextResponse.next()no tiene nada visible: Next lo reconoce por la cabecera internax-middleware-next: 1. Es un detalle de implementación, pero es la forma más directa de comprobar “ha dejado pasar”./gestionsin barra final también entra::path*admite cero segmentos. Es justo el tipo de duda que resuelve una prueba en un segundo.
⚠️ Importante: La documentación de Next 16.4 (
api-reference/03-file-conventions/proxy.md, apartado “Unit testing”) llama a la funciónunstable_doesProxyMatch, pero en Next.js 16.4.3 no existe: el paquete solo exportaunstable_doesMiddlewareMatch, y el import deunstable_doesProxyMatchesundefined. Usaunstable_doesMiddlewareMatch, que funciona igual con unproxy.ts. Como todo lo que empieza porunstable_, puede cambiar de nombre en cualquier versión menor: si una actualización rompe este import, revisa qué exportanext/experimental/testing/server.
10. Pruebas E2E con Playwright sobre next build y next start
10.1. Por qué contra el build de producción
Playwright puede probar contra next dev, y es lo que hace la guía oficial en su ejemplo más sencillo. Pero next dev no es lo que despliegas:
next dev | next build + next start | |
|---|---|---|
| Compilación | Bajo demanda: la primera visita a cada página tarda segundos | Todo compilado antes: respuestas en milisegundos |
Prerender (○, ◐) | No: todo se renderiza en cada petición | Sí: lo que verá el usuario real |
'use cache' | Activo, pero sin prerender: no ves qué queda congelado en el build | Como en producción, con las páginas regeneradas guardadas en disco |
| Errores | Superposición de errores de desarrollo | Páginas de error de producción |
| Comprobación de tipos | No | Sí: si no compila, no hay pruebas |
Probar contra next dev da falsos positivos (una página que en producción sería estática con datos congelados aquí se ve bien) y es lento y poco estable (las primeras visitas compilan y superan los tiempos de espera). Por eso la configuración de este módulo construye y arranca el servidor de producción.
10.2. playwright.config.ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
const puerto = Number(process.env.PUERTO_E2E ?? 3001)
const urlBase = `http://127.0.0.1:${puerto}`
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: urlBase,
trace: 'on-first-retry',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
// Base de datos propia de E2E, build de producción y servidor de producción
command: `npm run db:preparar && npm run build && npm run start -- -p ${puerto}`,
url: `${urlBase}/departamentos`,
env: { DATABASE_URL: 'file:e2e.db' },
reuseExistingServer: !process.env.CI,
timeout: 300_000, // db:preparar + next build + next start
stdout: 'pipe',
},
})
La clave está en webServer:
commandencadena tres pasos: preparar la base de datos de E2E, construir y arrancar.npm run start -- -p 3001pasa-p 3001anext start(los--separan los argumentos denpmde los del script).env: { DATABASE_URL: 'file:e2e.db' }se aplica a los tres pasos: las migraciones y la carga escriben ene2e.db,next buildprerenderiza la lista de departamentos leyendoe2e.db, ynext startla usa en tiempo de ejecución. Tuempresa.dbno se toca. Además funciona igual en Windows, macOS y Linux: escribirDATABASE_URL=file:e2e.db npm run builden elcommandfallaría en la consola de Windows.urles la dirección que Playwright consulta repetidamente hasta que responde: entonces empiezan las pruebas. Usa una página real; si usas la raíz y la raíz redirige, también vale, pero una página que dependa de la base de datos comprueba además que esta funciona.timeout: 300_000(5 minutos): el valor por defecto es 60 segundos y no basta para un build. En el equipo de pruebas de este módulo, la primera ejecución con 180 segundos falló conError: Timed out waiting 180000ms from config.webServer.: el build terminó, pero el servidor arrancó demasiado tarde.reuseExistingServer: !process.env.CI: en tu equipo, si ya hay algo respondiendo en laurl(por ejemplo, unnext startque dejaste abierto), Playwright lo usa y se salta elcommand. En integración continua (CI=true) siempre arranca uno nuevo.127.0.0.1en lugar delocalhost: en Windows,localhostpuede resolverse primero a la dirección IPv6::1, y si el servidor solo escucha en IPv4 cada petición pierde tiempo o falla.- El puerto 3001 evita chocar con tu
next devdel puerto 3000. La variablePUERTO_E2Epermite cambiarlo sin tocar el fichero (en el laboratorio de este módulo se usó otro puerto).
Figura 4. Lo que hace npx playwright test con la opción webServer de este módulo.
10.3. next dev y next build a la vez 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15,
next devynext buildescriben en la misma carpeta.next, así que lanzar las E2E (que hacennext build) connext devabierto en el mismo proyecto puede corromper la compilación de ambos. Si usas Next.js 15: paranext devantes de ejecutarnpm run test:e2e, y vuelve a arrancarlo cuando terminen.
En Next.js 16, next dev escribe en .next/dev (módulo 01), así que puedes tener el servidor de desarrollo abierto mientras las E2E construyen y arrancan el de producción en otro puerto.
10.4. Las pruebas
// e2e/departamentos.spec.ts
import { expect, test } from '@playwright/test'
test.describe('Departamentos', () => {
test('lista los 7 departamentos del build', async ({ page }) => {
await page.goto('/departamentos')
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Departamentos')
const filas = page.getByRole('row').filter({ has: page.getByRole('cell') })
await expect(filas).toHaveCount(7)
await expect(filas.filter({ hasText: 'INVESTIGACIÓN' })).toContainText('Sin presupuesto')
})
test('el buscador cambia la URL y filtra en el servidor', async ({ page }) => {
await page.goto('/departamentos')
await page.getByRole('textbox', { name: 'Buscar departamento' }).fill('ven')
await page.getByRole('button', { name: 'Buscar' }).click()
await expect(page).toHaveURL(/\/departamentos\?q=ven$/)
await expect(page.getByRole('row').filter({ has: page.getByRole('cell') })).toHaveCount(1)
await expect(page.getByRole('link', { name: 'VENTAS' })).toBeVisible()
})
test('navega a la ficha de un departamento', async ({ page }) => {
await page.goto('/departamentos')
await page.getByRole('link', { name: 'VENTAS' }).click()
await expect(page).toHaveURL('/departamentos/20')
await expect(page.getByRole('heading', { name: 'Empleados (6)' })).toBeVisible()
})
test('muestra los errores de validación de la Server Action', async ({ page }) => {
await page.goto('/departamentos')
await page.getByRole('button', { name: 'Crear' }).click()
await expect(page.getByRole('status')).toHaveText('Revisa los campos marcados.')
await expect(page.getByText('Mínimo 2 caracteres')).toHaveCount(2)
})
test('da de alta un departamento y aparece en la tabla', async ({ page }, info) => {
// Nombre único: las pruebas corren en paralelo y la base de datos no se reinicia entre ellas
const nombre = `PRUEBA ${info.workerIndex}-${Date.now()}`
await page.goto('/departamentos')
const formulario = page.getByRole('form', { name: 'Nuevo departamento' })
await formulario.getByRole('textbox', { name: 'nombre' }).fill(nombre)
await formulario.getByRole('textbox', { name: 'localidad' }).fill('Toledo')
await formulario.getByRole('button', { name: 'Crear' }).click()
await expect(page.getByRole('status')).toContainText(`(${nombre})`)
// updateTag('departamentos') ha renovado la lista sin recargar la página
await expect(page.getByRole('link', { name: nombre })).toBeVisible()
})
})
Cada prueba cubre algo que Vitest no podía ver:
- La lista sale del prerender de
next build(la página es◐) con los datos dee2e.db: comprueba la caché, el<Suspense>y la capa de datos juntos. - El buscador de verdad:
router.pushcambia la URL, Next pide al servidor el nuevo contenido,ListaDepartamentosfiltra con elsearchParamsreal. En Vitest solo comprobamos que se llamaba apush. - La navegación con
<Link>a una página congenerateStaticParams. - La Server Action real, con su viaje de ida y vuelta por la red, Zod incluido.
- La renovación de la caché. Tras el alta, el nuevo departamento aparece en la tabla sin recargar:
updateTagha caducado la etiqueta y Next ha devuelto la página renovada en la misma respuesta de la acción. Es la prueba de lo que en el apartado 7 estaba simulado.
page.getByRole('row').filter({ has: page.getByRole('cell') }) cuenta las filas que tienen celdas <td>, es decir, todas menos la de cabecera, que tiene <th>. getByRole('form', { name: 'Nuevo departamento' }) encuentra el formulario por su aria-label: un <form> solo tiene el rol form si tiene nombre accesible.
10.5. Datos en E2E: nombres únicos y la caché
Las pruebas E2E comparten un servidor y una base de datos, y con fullyParallel: true se ejecutan a la vez en varios procesos (workers). Dos reglas:
- Las pruebas que escriben usan datos únicos. Si dos pruebas crearan “CALIDAD”, la segunda chocaría con el
UNIQUE.info.workerIndexyDate.now()dan un nombre que no se repite. - Las pruebas que leen no deberían depender de un total que otras pruebas cambian. “Lista los 7 departamentos” se ejecuta a la vez que el alta: si el alta termina antes de que la lista cargue la página, verá 8 filas. En la ejecución de este módulo pasó, pero es una carrera que puede perderse algún día. Las soluciones son comprobar algo que no dependa del total (que estén los siete nombres originales), o ejecutar en serie las pruebas que escriben (
test.describe.configure({ mode: 'serial' })) o en un proyecto de Playwright aparte que se ejecute después (opcióndependencies). - No reinicies la base de datos “por detrás” de Next. Es tentador vaciar
e2e.dben unbeforeEachcon el script de carga. Pero la lista de departamentos está en la caché de Next ('use cache'con vida de días): la base de datos volvería a tener 7 filas y la página seguiría mostrando 8. Lo comprobarás en el apartado 10.7. La base de datos se prepara antes del build (en elcommand), y las pruebas modifican datos solo a través de la aplicación, que es quien renueva su caché.
10.6. La ejecución real
npx playwright test (o npm run test:e2e) muestra primero la salida del command, con el prefijo [WebServer] porque la configuración tiene stdout: 'pipe'. Este es un extracto real, con la tabla de rutas de next build:
[WebServer] > lab-next@0.1.0 db:preparar
[WebServer] > drizzle-kit migrate && tsx scripts/cargar-datos.ts
[WebServer] [✓] migrations applied successfully![WebServer] Cargando datos en file:e2e.db
[WebServer] departamentos 7 filas
[WebServer] empleados 24 filas
...
[WebServer] > lab-next@0.1.0 build
[WebServer] > next build
[WebServer] ▲ Next.js 16.4.3 (Turbopack)
[WebServer] - Cache Components enabled
[WebServer] - Partial Prefetching enabled
[WebServer] ✓ Compiled successfully in 23.2s
[WebServer] Running TypeScript ...
[WebServer] Finished TypeScript in 41s ...
[WebServer] ✓ Generating static pages using 9 workers (14/14) in 26.7s
[WebServer] Route (app) Revalidate Expire
[WebServer] ┌ ○ /
[WebServer] ├ ○ /_not-found
[WebServer] ├ ƒ /api/departamentos
[WebServer] ├ ƒ /api/departamentos/[id]
[WebServer] ├ ◐ /departamentos 1d 1w
[WebServer] └ /departamentos/[id]
[WebServer] ├ ◐ /departamentos/[id]
[WebServer] ├ ○ /departamentos/10 1d 1w
[WebServer] ├ ○ /departamentos/20 1d 1w
[WebServer] └ ◐ [+5 more paths]
[WebServer] ƒ Proxy (Middleware)
[WebServer] > lab-next@0.1.0 start
[WebServer] > next start -p 5521
Y después, las pruebas (las cinco del apartado 10.4 y las tres de los ejercicios 6 y 7):
Running 8 tests using 6 workers
ok 1 [chromium] › e2e\departamentos.spec.ts:4:7 › Departamentos › lista los 7 departamentos del build (4.3s)
ok 6 [chromium] › e2e\departamentos.spec.ts:13:7 › Departamentos › el buscador cambia la URL y filtra en el servidor (5.3s)
ok 5 [chromium] › e2e\departamentos.spec.ts:40:7 › Departamentos › da de alta un departamento y aparece en la tabla (5.4s)
ok 2 [chromium] › e2e\departamentos.spec.ts:32:7 › Departamentos › muestra los errores de validación de la Server Action (5.7s)
ok 4 [chromium] › e2e\ejercicios.spec.ts:3:5 › buscar sin resultados y limpiar (5.6s)
ok 7 [chromium] › e2e\ejercicios.spec.ts:15:7 › proxy de /gestion › sin sesión redirige a /login (2.3s)
ok 8 [chromium] › e2e\ejercicios.spec.ts:20:7 › proxy de /gestion › con la cookie de sesión deja pasar (2.2s)
ok 3 [chromium] › e2e\departamentos.spec.ts:24:7 › Departamentos › navega a la ficha de un departamento (8.4s)
8 passed (1.9m)
Fíjate en varias cosas:
- La tabla del build es parte de la prueba.
/departamentossale◐: el armazón es estático y la lista llega en streaming. Si un cambio la convirtiera enƒo rompiera el build, lo verías aquí antes que en producción. - Los números de la izquierda (
ok 1,ok 6…) no van en orden: es el orden en que terminaron las pruebas, que se ejecutaron en 6 workers a la vez. Por eso los datos únicos del apartado anterior no son opcionales. - Cada prueba tarda segundos, frente a los milisegundos de las de Vitest: abrir un contexto de navegador, cargar la página e hidratarla tiene un coste fijo. Es la razón para tener pocas E2E, una por flujo importante.
10.7. La caché de Next sobrevive al reinicio del servidor
Después de esta ejecución se hizo una comprobación que conviene conocer. Con el build de las pruebas ya hecho:
- Se volvió a ejecutar
npm run db:prepararconDATABASE_URL=file:e2e.db: la tabla volvió a tener 7 departamentos (comprobado con una consulta directa ae2e.db). - Se arrancó de nuevo
next start -p 5521(un proceso nuevo, sin nada en memoria). GET /api/departamentosdevolvía 7 departamentos, pero la página/departamentosmostraba 8: el departamento “PRUEBA 4-…” que había creado la prueba del alta.
La explicación: cuando la Server Action llamó a updateTag('departamentos'), Next regeneró la página y guardó el resultado en disco, en .next/server/route-cache/, junto con los datos de caché que usó. Al arrancar de nuevo, next start sirve esa versión guardada. Al borrar la carpeta de esa página dentro de route-cache y reiniciar, la página volvió a mostrar los 7 departamentos.
Consecuencias para tus pruebas:
- Con la configuración de este módulo no hay problema, porque cada ejecución hace
next build, que vuelve a crear.nextdesde cero. - Con
reuseExistingServer: truey unnext startque dejaste abierto, las pruebas se ejecutan contra un servidor con la caché de ejecuciones anteriores. Si una prueba E2E falla de forma rara en tu equipo y pasa en CI, para ese servidor y deja que Playwright arranque uno nuevo. - Reiniciar la base de datos no reinicia la caché, ni siquiera reiniciando el servidor. La base de datos y la caché de Next se preparan juntas, antes del build.
10.8. El informe HTML y las trazas
Con reporter: [['list'], ['html', { open: 'never' }]], Playwright escribe en la consola y además genera un informe en playwright-report/. Se abre con:
npx playwright show-report

Figura 5. El informe HTML de Playwright tras la ejecución de este módulo.
Con trace: 'on-first-retry', cuando una prueba falla en CI y se reintenta, Playwright graba una traza: cada acción con una captura del DOM antes y después, la red, la consola y el código fuente. Se abre desde el informe o con npx playwright show-trace ruta/trace.zip. El módulo 22 del Curso de React explica el visor de trazas en detalle. En tu equipo, para depurar una prueba concreta, npx playwright test --ui abre el modo interactivo, y npx playwright test -g "alta" --headed la ejecuta con el navegador visible.
11. La suite completa
11.1. Ejecución de Vitest
Esta es la salida real de npx vitest run --reporter=verbose (con --pool=threads --maxWorkers=1) para las pruebas de los apartados 3 a 9:
RUN v5.0.5 C:/proyectos/empresa
✓ |cliente| tests/cliente/buscador.test.tsx > Buscador > navega a ?q= con el texto escrito, sin espacios sobrantes 1357ms
✓ |cliente| tests/cliente/buscador.test.tsx > Buscador > arranca con el valor que ya tiene la URL y conserva los demás parámetros 188ms
✓ |cliente| tests/cliente/buscador.test.tsx > Buscador > Limpiar quita q de la URL y vacía la caja 107ms
✓ |cliente| tests/cliente/formulario-alta.test.tsx > FormularioAlta > envía los campos a la acción y muestra su mensaje 3000ms
✓ |cliente| tests/cliente/formulario-alta.test.tsx > FormularioAlta > marca los campos con error que devuelve la acción 551ms
✓ |cliente| tests/cliente/formulario-alta.test.tsx > FormularioAlta > desactiva el botón mientras la acción está pendiente 422ms
✓ |cliente| tests/cliente/lista.test.tsx > ListaDepartamentos (Server Component asíncrono) > pinta una fila por departamento, con enlace y presupuesto formateado 1582ms
✓ |cliente| tests/cliente/lista.test.tsx > ListaDepartamentos (Server Component asíncrono) > filtra sin distinguir mayúsculas y avisa si no hay resultados 263ms
✓ |servidor| tests/servidor/rutas-api.test.ts > GET /api/departamentos > devuelve los 7 departamentos en JSON 158ms
✓ |servidor| tests/servidor/rutas-api.test.ts > GET /api/departamentos > filtra con ?q= 99ms
✓ |servidor| tests/servidor/rutas-api.test.ts > POST /api/departamentos > 201 con la cabecera Location 65ms
✓ |servidor| tests/servidor/rutas-api.test.ts > POST /api/departamentos > 400 si el cuerpo no es JSON 46ms
✓ |servidor| tests/servidor/rutas-api.test.ts > POST /api/departamentos > 400 con los errores por campo 42ms
✓ |servidor| tests/servidor/rutas-api.test.ts > POST /api/departamentos > 409 si el nombre ya existe 52ms
✓ |servidor| tests/servidor/rutas-api.test.ts > GET /api/departamentos/[id] > 200 con el departamento y sus empleados 56ms
✓ |servidor| tests/servidor/rutas-api.test.ts > GET /api/departamentos/[id] > 99 -> 404 43ms
✓ |servidor| tests/servidor/rutas-api.test.ts > GET /api/departamentos/[id] > abc -> 400 49ms
✓ |servidor| tests/servidor/acciones.test.ts > altaDepartamento > crea el departamento, lo guarda en mayúsculas y renueva la caché 128ms
✓ |servidor| tests/servidor/acciones.test.ts > altaDepartamento > devuelve los errores por campo y no toca la base de datos 64ms
✓ |servidor| tests/servidor/acciones.test.ts > altaDepartamento > traduce el UNIQUE de SQLite a un mensaje para el usuario 45ms
✓ |servidor| tests/servidor/acciones.test.ts > bajaDepartamento > borra el departamento 70, que no tiene empleados 40ms
✓ |servidor| tests/servidor/acciones.test.ts > bajaDepartamento > la clave ajena impide borrar VENTAS, que tiene empleados 47ms
✓ |servidor| tests/servidor/acciones.test.ts > bajaDepartamento > avisa si el departamento ya no existe 39ms
✓ |servidor| tests/servidor/departamentos-db.test.ts > capa de datos de departamentos > listarDepartamentos cuenta los empleados, también los 0 del departamento 70 165ms
✓ |servidor| tests/servidor/departamentos-db.test.ts > capa de datos de departamentos > declara su etiqueta y su vida en caché ('use cache' no se ejecuta en Vitest) 69ms
✓ |servidor| tests/servidor/departamentos-db.test.ts > capa de datos de departamentos > obtenerDepartamento trae sus empleados ordenados por apellidos 29ms
✓ |servidor| tests/servidor/departamentos-db.test.ts > capa de datos de departamentos > contarEmpleados espera a connection() antes de consultar 56ms
(se omiten 12 líneas: validacion.test.ts, ya vistas en el apartado 3, y cinco de proxy.test.ts)
✓ |servidor| tests/servidor/proxy.test.ts > proxy.ts > con cookie de sesión deja pasar 10ms
Test Files 8 passed (8)
Tests 40 passed (40)
Start at 14:38:20
Duration 211.82s (environment 41%, setup 25%, import 24%, tests 5%, transform 5%)
Fíjate en el reparto del tiempo: las pruebas en sí son el 5 %. El resto es preparar entornos (jsdom pesa), cargar módulos (next/server y next/cache son grandes) y transformar código. Con el paralelismo por defecto, la misma suite tardó 60 s. Las pruebas de servidor contra SQLite van en decenas de milisegundos: la base de datos real no es lo que hace lenta una suite.
11.2. Qué va en cada nivel
| Nivel | Cuántas | Qué | En este módulo |
|---|---|---|---|
| Lógica pura | Muchas | Validación, formatos, cálculos | validacion.test.ts |
| Servidor (Node + SQLite en memoria) | Bastantes | Capa de datos, acciones, handlers, proxy | acciones, departamentos-db, rutas-api, proxy |
| Componentes (jsdom) | Bastantes | Interacción de Client Components; Server Components asíncronos hoja | buscador, formulario-alta, lista |
E2E (Playwright + next start) | Pocas, por flujo importante | Lo que solo existe dentro de Next: caché, streaming, navegación, acciones por la red, proxy | departamentos.spec.ts |
En integración continua, el orden habitual es: eslint → vitest run → playwright test (que incluye next build). Si fallan las pruebas rápidas, no merece la pena gastar minutos en el build.
12. Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
npm install -D vitest falla con ERESOLVE | @types/node@^20 de la plantilla, incompatible con Vitest 5 | npm install -D @types/node@24 antes |
Los imports con @/ no se resuelven | Vitest no conoce los alias del tsconfig.json | resolve: { tsconfigPaths: true } (Vite 8) y extends: true en cada proyecto |
[vitest-pool-runner]: Timeout waiting for worker to respond (Windows) | Algún proceso de Vitest no arranca a tiempo | --pool=threads --maxWorkers=1 |
invariant expected app router to be mounted | Client Component con useRouter fuera de Next | vi.mock('next/navigation', ...) |
This module cannot be imported from a Client Component module en una prueba de servidor | server-only fuera de Next | vi.mock('server-only', () => ({})) en la preparación |
`cacheTag()` is only available with the `cacheComponents` config | next/cache real en Vitest | Simular next/cache |
updateTag can only be called from within a Server Action | Ídem | Simular next/cache y comprobar la llamada |
`connection` / `cookies` was called outside a request scope | API de petición en Vitest | Simular next/server (parcial) o next/headers |
<X> is an async Client Component y DOM vacío | render(<ComponenteAsync />) | render(await ComponenteAsync(props)) |
render(await Pagina()) vacío | La página tiene hijos asíncronos | Probar cada hijo por separado, o la página con Playwright |
[vitest] There was an error when mocking a module. If you are using "vi.mock" factory, make sure there are no top level variables inside | Variable usada en la fábrica antes de crearse (el vi.mock se eleva) | vi.hoisted(() => ...) |
no such table: departamentos | Base de datos de prueba sin migrar, o un cliente distinto del de la aplicación | migrate(db, ...) con el db de @/lib/db |
| Una prueba pasa sola y falla con las demás | Datos que deja otra prueba | beforeEach(reiniciarBaseDeDatos) |
La prueba del alta da deptId 72 en lugar de 71 | Sin reinicio entre pruebas | Ídem |
Error: Timed out waiting 180000ms from config.webServer. | El build tarda más que timeout | Subir timeout (300 000) |
toBe('500.000 €') falla con dos cadenas idénticas | Espacio duro U+00A0 de toLocaleString | Normalizar con replace(/\u00a0/g, ' ') o toHaveTextContent |
La E2E ve datos viejos tras reiniciar e2e.db | 'use cache' de Next guarda la consulta | Preparar la BD antes del build; cambiar datos solo a través de la aplicación |
El import de unstable_doesProxyMatch no existe (error de tipos, o undefined al ejecutar) | La documentación de 16.4 usa un nombre que 16.4.3 no exporta | unstable_doesMiddlewareMatch |
13. Ejercicios
Las soluciones de los ejercicios 1 a 7 se han ejecutado con la misma configuración del módulo: las pruebas de Vitest (14 pruebas en 5 ficheros) y las de Playwright, junto con las del apartado 10.
Ejercicio 1: Traducción de errores con errores reales
Escribe tests/servidor/ej-errores.test.ts para codigoSqlite y mensajeDeError de lib/db/errores.ts, provocando contra la base de datos de prueba un error UNIQUE, uno de clave ajena y uno CHECK (precio negativo en productos). Añade un caso con un error que no venga de SQLite.
Ver solución
// tests/servidor/ej-errores.test.ts
import { eq } from 'drizzle-orm'
import { beforeEach, describe, expect, test } from 'vitest'
import { db } from '@/lib/db'
import { codigoSqlite, mensajeDeError } from '@/lib/db/errores'
import { departamentos, productos } from '@/db/schema'
import { reiniciarBaseDeDatos } from './bd-prueba'
// Ejecuta la operación y devuelve el error que lanza
async function errorDe(operacion: () => Promise<unknown>) {
try {
await operacion()
} catch (error) {
return error
}
throw new Error('La operación no ha fallado')
}
beforeEach(async () => {
await reiniciarBaseDeDatos()
})
describe('mensajeDeError con errores reales de SQLite', () => {
test('UNIQUE', async () => {
const error = await errorDe(() =>
db.insert(departamentos).values({ nombre: 'VENTAS', localidad: 'Cuenca' }),
)
expect(codigoSqlite(error)).toBe('SQLITE_CONSTRAINT_UNIQUE')
expect(mensajeDeError(error)).toBe('Ya existe un registro con ese valor.')
})
test('FOREIGN KEY', async () => {
const error = await errorDe(() => db.delete(departamentos).where(eq(departamentos.deptId, 20)))
expect(codigoSqlite(error)).toBe('SQLITE_CONSTRAINT_FOREIGNKEY')
expect(mensajeDeError(error)).toBe('No se puede: hay otros datos que dependen de este registro.')
})
test('CHECK', async () => {
const error = await errorDe(() =>
db.update(productos).set({ precio: -5 }).where(eq(productos.productoId, 1)),
)
expect(codigoSqlite(error)).toBe('SQLITE_CONSTRAINT_CHECK')
expect(mensajeDeError(error)).toBe('Algún valor no cumple las reglas de la tabla.')
})
test('un error que no viene de SQLite', () => {
expect(codigoSqlite(new Error('otra cosa'))).toBeUndefined()
expect(mensajeDeError('ni siquiera es un Error')).toBe('Error inesperado en la base de datos.')
})
})
errorDe es una pequeña ayuda: devuelve el error para poder inspeccionarlo con varias aserciones, y si la operación no falla, lanza su propio error para que la prueba no pase por accidente. Con await expect(promesa).rejects... solo podrías hacer una comprobación por promesa.
La base de datos en memoria tiene las claves ajenas activadas (libSQL lo hace por defecto, como se vio en el módulo 16), y por eso el borrado de VENTAS falla igual que en empresa.db.
Ejercicio 2: El buscador conserva los demás parámetros
Con la URL ?q=ventas&orden=nombre, comprueba que “Limpiar” navega a /departamentos?orden=nombre, y que buscar un texto que solo tiene espacios hace lo mismo.
Ver solución
// tests/cliente/ej-buscador.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { Buscador } from '@/app/departamentos/buscador'
const navegacion = vi.hoisted(() => ({ push: vi.fn() }))
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: navegacion.push }),
usePathname: () => '/departamentos',
useSearchParams: () => new URLSearchParams('q=ventas&orden=nombre'),
}))
test('Limpiar quita q pero conserva orden', async () => {
const usuario = userEvent.setup()
render(<Buscador />)
await usuario.click(screen.getByRole('button', { name: 'Limpiar' }))
expect(navegacion.push).toHaveBeenCalledExactlyOnceWith('/departamentos?orden=nombre')
})
test('buscar solo espacios equivale a limpiar', async () => {
const usuario = userEvent.setup()
render(<Buscador />)
const caja = screen.getByRole('textbox', { name: 'Buscar departamento' })
await usuario.clear(caja)
await usuario.type(caja, ' {Enter}')
expect(navegacion.push).toHaveBeenCalledExactlyOnceWith('/departamentos?orden=nombre')
})
Esta vez el router simulado solo tiene push, porque es lo único que usa el componente. Es más corto, pero si el componente llamara a otro método fallaría con ... is not a function. Elige según lo que prefieras leer cuando algo se rompa.
La segunda prueba demuestra el trim(): tres espacios se convierten en una cadena vacía y irA('') quita q. Si se quitara el trim, la URL sería ?q=+++&orden=nombre.
Ejercicio 3: La ficha del departamento y notFound()
Prueba la página app/departamentos/[id]/page.tsx (un Server Component asíncrono con generateStaticParams que llama a notFound() si el departamento no existe), simulando la capa de datos. Comprueba la cabecera y la lista de empleados, qué pasa con un identificador que no existe y qué devuelve generateStaticParams.
// app/departamentos/[id]/page.tsx
import { notFound } from 'next/navigation'
import { listarDepartamentos, obtenerDepartamento } from '@/lib/db/departamentos'
export async function generateStaticParams() {
const lista = await listarDepartamentos()
return lista.map((d) => ({ id: String(d.deptId) }))
}
export default async function PaginaDepartamento({ params }: PageProps<'/departamentos/[id]'>) {
const { id } = await params
const dept = await obtenerDepartamento(Number(id))
if (!dept) notFound()
return (
<main className="space-y-3 p-6">
<h1 className="text-2xl font-bold">{dept.nombre}</h1>
<p>{dept.localidad}</p>
<h2 className="font-semibold">Empleados ({dept.empleados.length})</h2>
<ul className="list-disc pl-6">
{dept.empleados.map((e) => (
<li key={e.empId}>
{e.apellidos}, {e.nombre} ({e.oficio})
</li>
))}
</ul>
</main>
)
}
Ver solución
// tests/cliente/ej-ficha.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, expect, test, vi } from 'vitest'
import PaginaDepartamento, { generateStaticParams } from '@/app/departamentos/[id]/page'
import { obtenerDepartamento } from '@/lib/db/departamentos'
vi.mock('@/lib/db/departamentos', () => ({
listarDepartamentos: vi.fn(async () => [{ deptId: 10 }, { deptId: 20 }]),
obtenerDepartamento: vi.fn(async (deptId: number) =>
deptId === 40
? {
deptId: 40,
nombre: 'CONTABILIDAD',
localidad: 'Sevilla',
presupuesto: 150000,
empleados: [
{ empId: 120, nombre: 'Ana', apellidos: 'Gil Pardo', oficio: 'CONTABLE' },
{ empId: 121, nombre: 'Luis', apellidos: 'Ruiz Mora', oficio: 'CONTABLE' },
],
}
: null,
),
}))
// La página recibe params como promesa, igual que en Next 16
const props = (id: string) => ({ params: Promise.resolve({ id }), searchParams: Promise.resolve({}) })
describe('PaginaDepartamento', () => {
test('pinta el nombre y la lista de empleados', async () => {
render(await PaginaDepartamento(props('40')))
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('CONTABILIDAD')
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Empleados (2)')
expect(screen.getAllByRole('listitem')[0]).toHaveTextContent('Gil Pardo, Ana (CONTABLE)')
expect(obtenerDepartamento).toHaveBeenCalledWith(40)
})
test('un departamento que no existe lanza notFound()', async () => {
await expect(PaginaDepartamento(props('99'))).rejects.toMatchObject({
digest: 'NEXT_HTTP_ERROR_FALLBACK;404',
})
})
test('generateStaticParams devuelve los id como texto', async () => {
expect(await generateStaticParams()).toEqual([{ id: '10' }, { id: '20' }])
})
})
notFound()no devuelve nada: lanza un error. Next lo reconoce por su propiedaddigest, que valeNEXT_HTTP_ERROR_FALLBACK;404, lo captura y pintanot-found.tsxcon estado 404. En la prueba no hay nadie que lo capture, así que la promesa de la página se rechaza con ese error, yrejects.toMatchObjectlo comprueba. Que la respuesta HTTP sea 404 de verdad solo se ve con Playwright.propsincluyesearchParamsporquePageProps<'/departamentos/[id]'>lo declara obligatorio, aunque la página no lo use.- Los datos simulados son inventados (Ana Gil, Luis Ruiz): la prueba trata de cómo pinta la página, no de quién trabaja en Contabilidad. Lo segundo lo comprueba
departamentos-db.test.tscontra la base de datos de prueba.
Ejercicio 4: Una Server Action con cookies() y redirect()
La acción guardarFavorito guarda en una cookie el departamento favorito y redirige a su ficha. Pruébala simulando next/headers: comprueba la cookie, la redirección y que con un identificador no válido no hace nada.
// app/departamentos/favoritos.ts
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function guardarFavorito(formData: FormData) {
const deptId = Number(formData.get('deptId'))
if (!Number.isInteger(deptId) || deptId <= 0) return
const almacen = await cookies()
almacen.set('favorito', String(deptId), {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 30,
})
redirect(`/departamentos/${deptId}`)
}
Ver solución
// tests/servidor/ej-favorito.test.ts
import { cookies } from 'next/headers'
import { describe, expect, test, vi } from 'vitest'
import { guardarFavorito } from '@/app/departamentos/favoritos'
import { formulario } from './bd-prueba'
// Un almacén de cookies falso: solo necesitamos set
const almacen = vi.hoisted(() => ({ set: vi.fn(), get: vi.fn(), has: vi.fn() }))
vi.mock('next/headers', () => ({ cookies: vi.fn(async () => almacen) }))
describe('guardarFavorito', () => {
test('guarda la cookie y redirige a la ficha', async () => {
const error = await guardarFavorito(formulario({ deptId: '20' })).catch((e) => e)
expect(almacen.set).toHaveBeenCalledWith('favorito', '20', expect.objectContaining({ httpOnly: true }))
// redirect() no devuelve: lanza un error especial que Next intercepta
expect(error).toBeInstanceOf(Error)
expect(error.message).toBe('NEXT_REDIRECT')
console.log('digest:', error.digest)
expect(error.digest).toMatch(/^NEXT_REDIRECT;\w+;\/departamentos\/20;307;/)
})
test('con un id no válido no toca las cookies ni redirige', async () => {
await expect(guardarFavorito(formulario({ deptId: 'abc' }))).resolves.toBeUndefined()
expect(cookies).not.toHaveBeenCalled()
})
})
La salida real del console.log:
digest: NEXT_REDIRECT;replace;/departamentos/20;307;
cookies()se simula devolviendo un objeto con los métodos que use la acción.vi.hoistedes necesario porque la fábrica devi.mockusaalmacen.redirect()no se simula: el de verdad funciona fuera de Next, porque solo construye y lanza un error con undigestque codifica el tipo, la URL y el estado (307). La prueba lo captura con.catch((e) => e)y lo examina. Fuera de una petición, el tipo esreplace; dentro de una Server Action real, Next usapush. Por eso la expresión regular acepta cualquier palabra en esa posición.- El orden importa: la cookie se guarda antes de
redirect. Si alguien movieraredirectarriba, la primera aserción fallaría. Recuerda del módulo 10 queredirectdentro de untry/catchse “traga”: esta prueba también lo detectaría.
Ejercicio 5: La acción de baja con la capa de datos simulada
Prueba bajaDepartamento sin base de datos, simulando @/lib/db/departamentos. Cubre tres casos: borrado correcto (dos llamadas a updateTag), error de clave ajena (fabrica tú el error con la misma forma que el de Drizzle) y un error desconocido.
Ver solución
// tests/servidor/ej-baja-simulada.test.ts
import { LibsqlError } from '@libsql/client'
import { updateTag } from 'next/cache'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { bajaDepartamento } from '@/app/departamentos/acciones'
import { eliminarDepartamento } from '@/lib/db/departamentos'
import { formulario } from './bd-prueba'
// Esta vez NO usamos SQLite: la capa de datos entera es un doble
vi.mock('@/lib/db/departamentos', () => ({
crearDepartamento: vi.fn(),
eliminarDepartamento: vi.fn(),
}))
const eliminar = vi.mocked(eliminarDepartamento)
const inicial = { ok: true, mensaje: '' }
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
describe('bajaDepartamento con la capa de datos simulada', () => {
test('si la capa dice que lo ha borrado, renueva las dos etiquetas', async () => {
eliminar.mockResolvedValue(true)
const estado = await bajaDepartamento(inicial, formulario({ deptId: '30' }))
expect(estado.ok).toBe(true)
expect(eliminar).toHaveBeenCalledWith(30)
expect(updateTag).toHaveBeenCalledTimes(2)
})
test('un fallo de clave ajena se traduce, aunque no haya base de datos', async () => {
// Fabricamos el mismo error que lanza Drizzle: el LibsqlError va en cause
const causa = new LibsqlError('FOREIGN KEY constraint failed', 'SQLITE_CONSTRAINT', 'SQLITE_CONSTRAINT_FOREIGNKEY', 787)
eliminar.mockRejectedValue(new Error('Failed query', { cause: causa }))
const estado = await bajaDepartamento(inicial, formulario({ deptId: '20' }))
expect(estado).toEqual({ ok: false, mensaje: 'No se puede: hay otros datos que dependen de este registro.' })
expect(updateTag).not.toHaveBeenCalled()
})
test('un error desconocido da el mensaje genérico', async () => {
eliminar.mockRejectedValue(new Error('disco lleno'))
const estado = await bajaDepartamento(inicial, formulario({ deptId: '20' }))
expect(estado.mensaje).toBe('Error inesperado en la base de datos.')
})
})
- La simulación tiene que parecerse a la realidad.
mensajeDeErrorbusca unLibsqlErrorenerror.cause; si fabricarasnew Error('FOREIGN KEY constraint failed')sincause, la acción respondería “Error inesperado” y la prueba fallaría (o, peor, ajustarías la prueba a una simulación equivocada). El código 787 es elrawCodereal deSQLITE_CONSTRAINT_FOREIGNKEY. - El tercer caso es el que justifica simular: un “disco lleno” es muy difícil de provocar con SQLite real, y con un doble es una línea.
./bd-pruebase sigue importando solo porformulario. Como@/lib/db/departamentosestá simulado,reiniciarBaseDeDatosno se llama y la base de datos en memoria no llega a crearse.
Compara este fichero con acciones.test.ts: aquel demuestra que la acción funciona con la base de datos real; este, que reacciona bien a lo que la capa le diga. Los dos son útiles; si solo pudieras tener uno, el primero detecta más fallos.
Ejercicio 6: E2E de búsqueda sin resultados
Escribe una prueba E2E que entre directamente en /departamentos?q=xyz, compruebe el mensaje de “no hay departamentos”, que la caja de búsqueda muestra xyz, y que “Limpiar” vuelve a la lista completa con la URL limpia.
Ver solución
// e2e/ejercicios.spec.ts
import { expect, test } from '@playwright/test'
test('buscar sin resultados y limpiar', async ({ page }) => {
await page.goto('/departamentos?q=xyz')
await expect(page.getByText('No hay departamentos que contengan «xyz».')).toBeVisible()
await expect(page.getByRole('textbox', { name: 'Buscar departamento' })).toHaveValue('xyz')
await page.getByRole('button', { name: 'Limpiar' }).click()
await expect(page).toHaveURL('/departamentos')
await expect(page.getByRole('row').filter({ has: page.getByRole('cell') })).toHaveCount(7)
})
- Entrar con
?q=xyzen la URL comprueba la carga inicial: el servidor filtra consearchParamsy el buscador arranca conuseSearchParams().get('q'). Son dos caminos distintos (servidor y cliente) que deben coincidir. toHaveURL('/departamentos')se resuelve contrabaseURL, así que compara con la URL completahttp://127.0.0.1:3001/departamentos, sin?.toHaveCount(7)espera: tras pulsar “Limpiar”, Next pide la página nueva al servidor, y elexpectde Playwright reintenta hasta que la tabla tiene 7 filas (o pasan 5 segundos). Una comprobación inmediata, sin espera, fallaría de vez en cuando.
Si ejecutas este fichero junto con el alta del apartado 10.4, puede haber más de 7 departamentos en la tabla, porque el alta añade uno (es la carrera explicada en el apartado 10.5). En la ejecución real del módulo pasó, pero no está garantizado. Si te falla por eso, cambia toHaveCount(7) por una comprobación que no dependa del total, como await expect(page.getByRole('link', { name: 'VENTAS' })).toBeVisible().
Ejercicio 7: E2E del proxy
Comprueba con Playwright que /gestion/empleados sin sesión acaba en /login?desde=%2Fgestion%2Fempleados, y que con la cookie sesion el proxy deja pasar (la página /gestion no existe, así que la respuesta es 404, pero la URL no cambia).
Ver solución
// e2e/ejercicios.spec.ts (continuación)
test.describe('proxy de /gestion', () => {
test('sin sesión redirige a /login', async ({ page }) => {
await page.goto('/gestion/empleados')
await expect(page).toHaveURL('/login?desde=%2Fgestion%2Fempleados')
})
test('con la cookie de sesión deja pasar', async ({ page, context, baseURL }) => {
await context.addCookies([{ name: 'sesion', value: 'prueba', url: baseURL! }])
const respuesta = await page.goto('/gestion')
await expect(page).toHaveURL('/gestion')
expect(respuesta?.status()).toBe(404) // no hay página /gestion: el proxy la ha dejado pasar
})
})
context.addCookiesañade la cookie al contexto del navegador (cada prueba tiene uno nuevo, sin cookies). La opciónurldice a qué dominio pertenece;baseURLllega como fixture.- El 404 es la prueba de que el
proxydejó pasar: si hubiera redirigido, la URL sería/login.... En la aplicación completa del módulo 17, la cookie la pone el login y la ruta existe. - Esta prueba y la de Vitest del apartado 9.2 se complementan: la de Vitest comprueba en milisegundos muchos casos del
matcher; esta comprueba una vez que Next ejecuta de verdad elproxyantes de la página.
Ejercicio 8 (propuesto): El formulario de baja
Escribe un Client Component FormularioBaja con un <select> de departamentos (recibidos por props) y un botón “Eliminar” que use useActionState(bajaDepartamento, ...). Pruébalo en el proyecto cliente simulando @/app/departamentos/acciones: que envía el deptId elegido, que muestra el mensaje de error de clave ajena y que el botón se desactiva mientras la acción está pendiente.
Ejercicio 9 (propuesto): Integración continua con GitHub Actions
Crea .github/workflows/pruebas.yml que, en cada push, instale dependencias con npm ci, ejecute npx eslint, npx vitest run, instale Chromium con npx playwright install --with-deps chromium y ejecute npx playwright test. Sube playwright-report/ como artefacto cuando falle. Comprueba que en CI reuseExistingServer es false y que retries vale 2.
Ejercicio 10 (propuesto): Una base de datos de E2E por worker
Con fullyParallel, todas las pruebas E2E comparten e2e.db. Investiga cómo usar los fixtures de Playwright para que cada worker cree sus propios datos (por ejemplo, un departamento propio en un beforeAll a través del Route Handler POST /api/departamentos) y los borre al terminar, de forma que ninguna prueba dependa del número total de filas.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Dos entornos de Vitest | test.projects con environment: 'jsdom' y 'node' | Client Components con DOM; código de servidor sin él |
Alias @/ | resolve: { tsconfigPaths: true } | Vite 8 lee el tsconfig.json; no hace falta vite-tsconfig-paths |
| Windows lento | --pool=threads --maxWorkers=1 | Evita Timeout waiting for worker, a costa de tiempo |
| APIs de Next fuera de Next | vi.mock('server-only'), vi.mock('next/cache'), connection | Lanzan errores fuera de una petición; se simulan en la preparación |
next/navigation | vi.mock + vi.hoisted | Sin simular: invariant expected app router to be mounted |
| Formularios con acciones | vi.mock('./acciones') + useActionState real | Se prueba el contrato: FormData que sale, estado que se pinta |
| Server Component asíncrono | render(await Componente(props)) | Solo si sus hijos no son asíncronos; lo demás, con E2E |
'use cache' en Vitest | Cadena sin efecto | Se comprueba que se declara cacheTag, no que la caché funcione |
| BD de prueba | DATABASE_URL=':memory:', migrate() de drizzle-orm/libsql/migrator | Mismas migraciones, mismo db que la aplicación, reinicio en beforeEach |
| Server Actions | Llamar a la función con (estado, FormData) | Se comprueba la respuesta, la base de datos y la llamada a updateTag |
| Route Handlers | GET(new NextRequest(url)), { params: Promise.resolve(...) } | Son funciones Request → Response; no hace falta servidor |
proxy.ts | unstable_doesMiddlewareMatch, getRedirectUrl | La documentación dice unstable_doesProxyMatch, que no existe en 16.4.3 |
notFound / redirect | rejects.toMatchObject({ digest }) | En Vitest son errores con digest; el 404/307 real, con Playwright |
| E2E sobre producción | webServer.command: db:preparar && build && start | Se prueba lo que se despliega; env fija e2e.db en los tres pasos |
| Datos en E2E | Nombres únicos, BD preparada antes del build | No reinicies la BD por detrás de la caché de Next |