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, usePathname y useSearchParams simulando next/navigation
  • Probar formularios con useActionState sustituyendo 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.ts llamando a sus funciones con objetos Request construidos a mano
  • Escribir pruebas end-to-end con Playwright contra el resultado de next build, arrancado con next start por la opción webServer
  • 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-orm 0.45.4 y @libsql/client 0.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, expect con 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, que updateTag renueve la página, que <Suspense> haga streaming, que el proxy se ejecute antes que la página) se comprueba en las pruebas E2E.

Tres columnas: Vitest en Node para lógica, capa de datos, Server Actions, Route Handlers y proxy; Vitest con jsdom para Client Components y Server Components asíncronos sin hijos asíncronos; Playwright para páginas completas, caché, navegación y flujos; abajo, un eje que va de rápido y muchas pruebas a lento y pocas pruebas

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 async Server 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 for async components.

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 de create-next-app 16.4 trae "@types/node": "^20", y con ella npm install -D vitest falla con ERESOLVE (conflicto de dependencias). Actualiza antes los tipos de Node: npm install -D @types/node@24. No uses --force ni --legacy-peer-deps para 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 .mts le dice a Node que el fichero es un módulo ES, aunque package.json no tenga "type": "module". Es lo que recomienda la guía de Next.
  • extends: true hace 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.
  • include separa 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 build comprueba los tipos de todo el proyecto (también de tests/ y de vitest.config.mts), porque el tsconfig.json de la plantilla incluye **/*.ts y **/*.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 plantilla with-vitest de Next. Todo lo que verás sobre qué simular es igual en Jest, cambiando vi por jest.

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/vitest añade los comparadores de DOM al expect de 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 activamos globals: true en Vitest. Testing Library solo se limpia sola cuando encuentra un afterEach global. 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 cada vi.fn(), para que toHaveBeenCalledTimes(1) no cuente las llamadas de la prueba anterior. No borra las implementaciones definidas en los vi.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:

  1. DATABASE_URL se fija en el fichero de preparación porque se ejecuta antes que el fichero de pruebas, y por tanto antes de que lib/db/index.ts lea la variable para crear el cliente. Lo verás a fondo en el apartado 7.
  2. server-only existe 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.
  3. next/cache se sustituye entero por funciones vacías (vi.fn()). Así listarDepartamentos se ejecuta como una función normal y, además, puedes comprobar que la acción llamó a updateTag('departamentos').
  4. next/server se sustituye solo en parte: importOriginal carga el módulo real y se reemplaza únicamente connection. NextRequest y NextResponse siguen siendo los de verdad, y los necesitamos para los Route Handlers.

💡 Consejo: Un vi.mock escrito en un fichero de setupFiles se 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 test deja Vitest vigilando: cada vez que guardas un fichero, repite las pruebas afectadas. Es el modo para trabajar.
  • npm run test:run ejecuta 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 servidor ejecuta 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, en vitest.config.mts, pool: 'threads' y maxWorkers: 1 dentro de test). 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 abc es el de Zod 4 por defecto, en inglés: Invalid input: expected number, received NaN. Number('abc') da NaN, y z.number() rechaza NaN. 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 con replace.

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 de next/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: cuando buscador.tsx importa useRouter, recibe la función de la fábrica. Vitest mueve las llamadas a vi.mock al principio del fichero, antes de los import, para que la sustitución esté lista cuando se cargue el componente.
  • vi.hoisted existe 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ó con const router = { push: vi.fn() } arriba y useRouter: vi.fn().mockReturnValue(router) en la fábrica: el fichero entero falla con Error: [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 un const push normal funciona, porque la variable solo se lee al renderizar, cuando ya existe. No te fíes de esa casualidad.) vi.hoisted eleva también su contenido, y lo que devuelve está disponible en la fábrica y en las pruebas.
  • useSearchParams devuelve un URLSearchParams normal. El de Next es un ReadonlyURLSearchParams, que tiene los mismos métodos de lectura (get, has, toString…) pero lanza error en set o delete. 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 con new URLSearchParams(searchParams).
  • El objeto de useRouter tiene todos los métodos del router real, aunque este componente solo use push. Si mañana alguien cambia push por replace, la prueba falla con un mensaje claro (“expected push to have been called”) en lugar de un replace is not a function.
  • {Enter} en usuario.type pulsa 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 el vi.mock a tests/preparar-cliente.ts y exporta desde un módulo de ayuda el objeto navegacion para 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.

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:

  1. El contrato con la acción. La primera prueba comprueba lo que el formulario envía (FormData con los tres campos, el presupuesto vacío como '') y que pinta lo que la acción devuelve. Si alguien cambia el name de un <input>, falla aquí, no en producción.
  2. 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.
  3. El estado pendiente. Una promesa que no se resuelve hasta que tú llamas a terminar deja 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: useActionState y <form action={función}> son de React 19, no de Next. Por eso funcionan en jsdom: React intercepta el envío del formulario, construye el FormData y 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.

Dos columnas. A la izquierda, el proyecto servidor: acciones.test.ts llama a altaDepartamento real, que usa Zod y lib/db reales contra SQLite en memoria; debajo, lo simulado en preparar-servidor.ts: server-only, next/cache y connection. A la derecha, el proyecto cliente: formulario-alta.test.tsx renderiza FormularioAlta real con useActionState real, y altaDepartamento es vi.fn(); debajo, lo simulado con vi.mock en cada fichero

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).

La página de departamentos en el navegador: barra de navegación, título, buscador con el botón Buscar, la tabla con los siete departamentos, sus localidades, presupuestos (INVESTIGACIÓN sin presupuesto) y número de empleados, y debajo el formulario Nuevo departamento

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/departamentos con una fábrica, el módulo real no llega a cargarse, y con él tampoco server-only, next/cache ni @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.db de 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
}
  • migrate de drizzle-orm/libsql/migrator aplica desde código los ficheros SQL de drizzle/, igual que drizzle-kit migrate desde la consola. Usar las migraciones (y no drizzle-kit push ni un CREATE TABLE escrito 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.
  • db es 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.
  • migrada evita 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 con npx drizzle-kit studio o 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 variable VITEST_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_id es 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 updateTag no se ha llamado.
  • Las restricciones reales. El UNIQUE de departamentos.nombre y la clave ajena de empleados.dept_id son de SQLite, no del código. Con la capa de datos simulada, estas dos pruebas no demostrarían nada; aquí demuestran que mensajeDeError reconoce el error tal como lo lanza Drizzle 0.45 con libSQL 0.18. Si una actualización cambiara la forma del error, fallarían.
  • updateTag es un vi.fn() (lo puso preparar-servidor.ts), así que se puede preguntar con qué se llamó. toHaveBeenCalledExactlyOnceWith comprueba a la vez “una sola vez” y “con estos argumentos”. En la baja, mock.calls comprueba las dos etiquetas y su orden.
  • vi.spyOn(console, 'error') silencia el console.error que las acciones hacen al fallar (sin él, la salida de las pruebas se llena de trazas de DrizzleQueryError) 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. updateTag está 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. updateTag no existe en Next.js 15. Si usas Next.js 15: la acción llama a revalidateTag('departamentos') (con un solo argumento) y la prueba comprueba expect(revalidateTag).toHaveBeenCalledWith('departamentos'); la simulación de next/cache del apartado 2.3 ya incluye revalidateTag.

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()
  })
})
  • beforeAll en lugar de beforeEach: estas pruebas solo leen, así que basta con preparar la base de datos una vez por fichero.
  • El count con leftJoin devuelve 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 cambia count(empleados.empId) por count(): el 70 saldría con 1.
  • El empleado sin departamento (deptId null 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é con unstable_cache(función, claves, { tags }) en lugar de 'use cache' y cacheTag. Si usas Next.js 15: simula next/cache con unstable_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)
  })
})
  • GET espera un NextRequest (usa request.nextUrl), así que la prueba construye uno: new NextRequest(url). POST solo necesita un Request estándar. Si le pasas un Request a una función que espera NextRequest, TypeScript se queja y, si lo fuerzas, nextUrl es undefined.
  • La URL necesita un dominio (http://localhost/...): Request no acepta rutas relativas. El dominio no importa, porque no se hace ninguna petición de red.
  • params es 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=ing devuelve 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 con request.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.ts y la función exportada middleware. Si usas Next.js 15: importa { config, middleware } desde @/middleware y 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 el matcher igual que Next y dice si el proxy se ejecutaría para esa URL. Admite también headers, cookies y nextConfig, para matchers con condiciones has/missing o con basePath.
  • getRedirectUrl(respuesta) devuelve la URL de destino de una redirección, o null. Hay también isRewrite y getRewrittenUrl para las reescrituras.
  • NextResponse.next() no tiene nada visible: Next lo reconoce por la cabecera interna x-middleware-next: 1. Es un detalle de implementación, pero es la forma más directa de comprobar “ha dejado pasar”.
  • /gestion sin 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ón unstable_doesProxyMatch, pero en Next.js 16.4.3 no existe: el paquete solo exporta unstable_doesMiddlewareMatch, y el import de unstable_doesProxyMatch es undefined. Usa unstable_doesMiddlewareMatch, que funciona igual con un proxy.ts. Como todo lo que empieza por unstable_, puede cambiar de nombre en cualquier versión menor: si una actualización rompe este import, revisa qué exporta next/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:

  • command encadena tres pasos: preparar la base de datos de E2E, construir y arrancar. npm run start -- -p 3001 pasa -p 3001 a next start (los -- separan los argumentos de npm de los del script).
  • env: { DATABASE_URL: 'file:e2e.db' } se aplica a los tres pasos: las migraciones y la carga escriben en e2e.db, next build prerenderiza la lista de departamentos leyendo e2e.db, y next start la usa en tiempo de ejecución. Tu empresa.db no se toca. Además funciona igual en Windows, macOS y Linux: escribir DATABASE_URL=file:e2e.db npm run build en el command fallaría en la consola de Windows.
  • url es 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ó con Error: 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 la url (por ejemplo, un next start que dejaste abierto), Playwright lo usa y se salta el command. En integración continua (CI=true) siempre arranca uno nuevo.
  • 127.0.0.1 en lugar de localhost: en Windows, localhost puede 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 dev del puerto 3000. La variable PUERTO_E2E permite cambiarlo sin tocar el fichero (en el laboratorio de este módulo se usó otro puerto).

Flujo de webServer: Playwright lee la configuración, ejecuta db:preparar sobre e2e.db, next build prerenderiza con esos datos, next start arranca el servidor, Playwright sondea la url hasta que responde, ejecuta las pruebas en paralelo y al final detiene el servidor y genera el informe; con reuseExistingServer se salta los tres pasos si la url ya responde

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 dev y next build escriben en la misma carpeta .next, así que lanzar las E2E (que hacen next build) con next dev abierto en el mismo proyecto puede corromper la compilación de ambos. Si usas Next.js 15: para next dev antes de ejecutar npm 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:

  1. La lista sale del prerender de next build (la página es ◐) con los datos de e2e.db: comprueba la caché, el <Suspense> y la capa de datos juntos.
  2. El buscador de verdad: router.push cambia la URL, Next pide al servidor el nuevo contenido, ListaDepartamentos filtra con el searchParams real. En Vitest solo comprobamos que se llamaba a push.
  3. La navegación con <Link> a una página con generateStaticParams.
  4. La Server Action real, con su viaje de ida y vuelta por la red, Zod incluido.
  5. La renovación de la caché. Tras el alta, el nuevo departamento aparece en la tabla sin recargar: updateTag ha 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.workerIndex y Date.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ón dependencies).
  • No reinicies la base de datos “por detrás” de Next. Es tentador vaciar e2e.db en un beforeEach con 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 el command), 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. /departamentos sale ◐: 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:

  1. Se volvió a ejecutar npm run db:preparar con DATABASE_URL=file:e2e.db: la tabla volvió a tener 7 departamentos (comprobado con una consulta directa a e2e.db).
  2. Se arrancó de nuevo next start -p 5521 (un proceso nuevo, sin nada en memoria).
  3. GET /api/departamentos devolvía 7 departamentos, pero la página /departamentos mostraba 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 .next desde cero.
  • Con reuseExistingServer: true y un next start que 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

Informe HTML de Playwright: filtros por estado arriba, y la lista de las pruebas de departamentos.spec.ts y ejercicios.spec.ts, todas en verde, con su duración

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 propiedad digest, que vale NEXT_HTTP_ERROR_FALLBACK;404, lo captura y pinta not-found.tsx con estado 404. En la prueba no hay nadie que lo capture, así que la promesa de la página se rechaza con ese error, y rejects.toMatchObject lo comprueba. Que la respuesta HTTP sea 404 de verdad solo se ve con Playwright.
  • props incluye searchParams porque PageProps<'/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.ts contra 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.hoisted es necesario porque la fábrica de vi.mock usa almacen.
  • redirect() no se simula: el de verdad funciona fuera de Next, porque solo construye y lanza un error con un digest que 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 es replace; dentro de una Server Action real, Next usa push. 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 moviera redirect arriba, la primera aserción fallaría. Recuerda del módulo 10 que redirect dentro de un try/catch se “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. mensajeDeError busca un LibsqlError en error.cause; si fabricaras new Error('FOREIGN KEY constraint failed') sin cause, 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 el rawCode real de SQLITE_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-prueba se sigue importando solo por formulario. Como @/lib/db/departamentos está simulado, reiniciarBaseDeDatos no 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=xyz en la URL comprueba la carga inicial: el servidor filtra con searchParams y el buscador arranca con useSearchParams().get('q'). Son dos caminos distintos (servidor y cliente) que deben coincidir.
  • toHaveURL('/departamentos') se resuelve contra baseURL, así que compara con la URL completa http://127.0.0.1:3001/departamentos, sin ?.
  • toHaveCount(7) espera: tras pulsar “Limpiar”, Next pide la página nueva al servidor, y el expect de 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.addCookies añade la cookie al contexto del navegador (cada prueba tiene uno nuevo, sin cookies). La opción url dice a qué dominio pertenece; baseURL llega como fixture.
  • El 404 es la prueba de que el proxy dejó 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 el proxy antes 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