En la arquitectura del App Router de Next.js 15, los endpoints de API están representados por Route Handlers. A diferencia de los obsoletos API Routes del Pages Router (req: NextApiRequest, res: NextApiResponse), los nuevos manejadores se adhieren plenamente al Fetch Standard de WHATWG.
Esta transición hacia estándares Web ofrece ventajas sustanciales para la arquitectura de testing:
export async function GET(request: NextRequest, context: RouteContext): Promise<Response>. Puede ejecutarse directamente en pruebas de Vitest dentro de un entorno Node.js, suministrando instancias sintéticas de Request o NextRequest sin necesidad de levantar un servidor de red.ReadableStream.context.params son asíncronos (Promise<{ [key: string]: string | string[] }>), unificando la gestión de I/O asíncrona en el framework.Las pruebas de integración de Route Handlers permiten verificar a velocidad de microsegundos especificaciones contractuales de OpenAPI, códigos de estado HTTP, políticas CORS, cookies de sesión e integridad del streaming.
En el modelo mental de testing, un Route Handler se concibe como una función pura que transforma una petición Web estándar en una respuesta Web:
+-------------------------------------------------------------------------------+
| Ejecución de Pruebas en Vitest (Node.js) |
+-------------------------------------------------------------------------------+
│
├─► 1. Construir new NextRequest('https://api.domain/endpoint?filter=active')
├─► 2. Añadir encabezados: new Headers({ 'Content-Type': 'application/json' })
├─► 3. Suministrar contexto dinámico: { params: Promise.resolve(...) }
│
▼
+-------------------------------------------------------------------------------+
| Route Handler (app/api/.../route.ts) |
+-------------------------------------------------------------------------------+
│
├─► Autenticación de Token / Bearer Authorization
├─► Validación del cuerpo: await req.json() (Esquema Zod)
├─► Consulta a la Data Access Layer (DAL / Drizzle)
│
▼
+-------------------------------------------------------------------------------+
| Resultado: Response / NextResponse |
+-------------------------------------------------------------------------------+
│
├─► expect(res.status).toBe(200 | 201 | 400 | 401 | 500)
├─► const body = await res.json()
├─► expect(res.headers.get('set-cookie')).toBeDefined()
└─► const reader = res.body.getReader() (para ReadableStream / SSE)OPTIONS y encabezados Access-Control-Allow-Origin, Access-Control-Allow-Headers para APIs públicas o de integración.Secure, SameSite=Strict y Path=/.ReadableStream.Examinamos un Route Handler para obtener métricas analíticas de proyectos mediante la ruta dinámica app/api/projects/[id]/analytics/route.ts. En Next.js 15, context.params retorna una Promise.
import { NextRequest, NextResponse } from 'next/server';
import { getProjectAnalytics } from '@/dal/analytics';
interface RouteContext {
params: Promise<{ id: string }>;
}
export async function GET(request: NextRequest, context: RouteContext): Promise<Response> {
// En Next.js 15, params es una promesa asíncrona
const { id } = await context.params;
if (!id || id === 'invalid') {
return NextResponse.json({ error: 'Identificador de proyecto no válido' }, { status: 400 });
}
const { searchParams } = request.nextUrl;
const period = searchParams.get('period') || '30d';
const analytics = await getProjectAnalytics(id, period);
if (!analytics) {
return NextResponse.json({ error: 'Proyecto no encontrado' }, { status: 404 });
}
return NextResponse.json(
{ data: analytics, meta: { period, generatedAt: new Date().toISOString() } },
{
status: 200,
headers: {
'Cache-Control': 'private, no-cache, no-store',
},
}
);
}import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { GET } from './route';
import * as dal from '@/dal/analytics';
vi.mock('@/dal/analytics', () => ({
getProjectAnalytics: vi.fn(),
}));
describe('GET /api/projects/[id]/analytics', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('retorna 200 OK y datos analíticos ante parámetros válidos', async () => {
vi.mocked(dal.getProjectAnalytics).mockResolvedValue({
views: 12500,
conversions: 450,
});
// Construimos NextRequest con query params
const request = new NextRequest('https://agenticlab.dev/api/projects/proj-101/analytics?period=7d');
// Construimos el contexto asíncrono de Next.js 15
const context = {
params: Promise.resolve({ id: 'proj-101' }),
};
const response = await GET(request, context);
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBe('private, no-cache, no-store');
const json = await response.json();
expect(json.data).toEqual({ views: 12500, conversions: 450 });
expect(json.meta.period).toBe('7d');
expect(dal.getProjectAnalytics).toHaveBeenCalledWith('proj-101', '7d');
});
it('retorna 404 Not Found si el proyecto no existe', async () => {
vi.mocked(dal.getProjectAnalytics).mockResolvedValue(null);
const request = new NextRequest('https://agenticlab.dev/api/projects/not-found/analytics');
const context = {
params: Promise.resolve({ id: 'not-found' }),
};
const response = await GET(request, context);
expect(response.status).toBe(404);
const json = await response.json();
expect(json.error).toBe('Proyecto no encontrado');
});
it('retorna 400 Bad Request ante un identificador no válido', async () => {
const request = new NextRequest('https://agenticlab.dev/api/projects/invalid/analytics');
const context = {
params: Promise.resolve({ id: 'invalid' }),
};
const response = await GET(request, context);
expect(response.status).toBe(400);
expect(dal.getProjectAnalytics).not.toHaveBeenCalled();
});
});El endpoint de autenticación recibe credenciales, verifica la contraseña y devuelve una respuesta configurando una cookie de sesión protegida.
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { verifyCredentials } from '@/dal/auth';
const LoginSchema = z.object({
email: z.string().email('Formato de email no válido'),
password: z.string().min(8, 'La contraseña debe contener al menos 8 caracteres'),
});
export async function POST(request: NextRequest): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Carga JSON malformada en la petición' }, { status: 400 });
}
const parsed = LoginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Error de validación', details: parsed.error.flatten().fieldErrors },
{ status: 422 }
);
}
const session = await verifyCredentials(parsed.data.email, parsed.data.password);
if (!session) {
return NextResponse.json({ error: 'Email o contraseña incorrectos' }, { status: 401 });
}
const response = NextResponse.json(
{ user: { id: session.userId, email: parsed.data.email } },
{ status: 200 }
);
// Asignación de cookie HttpOnly segura
response.cookies.set({
name: 'auth_session',
value: session.token,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 días
});
return response;
}import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { POST } from './route';
import * as authDal from '@/dal/auth';
vi.mock('@/dal/auth');
describe('POST /api/auth/login', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('retorna 200 OK y establece la cookie de sesión ante credenciales correctas', async () => {
vi.mocked(authDal.verifyCredentials).mockResolvedValue({
userId: 'usr-900',
token: 'jwt.enterprise.token',
});
const payload = {
email: '[email protected]',
password: 'StrongSecretPassword123!',
};
const request = new NextRequest('https://agenticlab.dev/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const response = await POST(request);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.user.id).toBe('usr-900');
// Verificamos los atributos de Set-Cookie en la respuesta
const setCookie = response.headers.get('set-cookie');
expect(setCookie).toBeDefined();
expect(setCookie).toContain('auth_session=jwt.enterprise.token');
expect(setCookie).toContain('HttpOnly');
expect(setCookie).toContain('SameSite=Strict');
});
it('retorna 422 Unprocessable Entity ante un email no válido', async () => {
const request = new NextRequest('https://agenticlab.dev/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'not-an-email', password: '123' }),
});
const response = await POST(request);
expect(response.status).toBe(422);
const json = await response.json();
expect(json.error).toBe('Error de validación');
expect(json.details.email).toBeDefined();
expect(authDal.verifyCredentials).not.toHaveBeenCalled();
});
it('retorna 401 Unauthorized ante credenciales erróneas', async () => {
vi.mocked(authDal.verifyCredentials).mockResolvedValue(null);
const request = new NextRequest('https://agenticlab.dev/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: '[email protected]', password: 'WrongPassword123' }),
});
const response = await POST(request);
expect(response.status).toBe(401);
const json = await response.json();
expect(json.error).toBe('Email o contraseña incorrectos');
});
});En arquitecturas actuales, los Route Handlers transmiten frecuentemente flujos de datos en tiempo real (generación de tokens de LLM o Server-Sent Events). Para probar el flujo, leemos fragmentos mediante un ReadableStreamDefaultReader nativo y decodificamos bytes con TextDecoder.
import { describe, it, expect } from 'vitest';
import { NextRequest } from 'next/server';
import { GET } from './route';
describe('GET /api/stream/events (ReadableStream SSE)', () => {
it('emite una respuesta en streaming con estado 200 y fragmentos SSE', async () => {
const request = new NextRequest('https://agenticlab.dev/api/stream/events');
const response = await GET(request);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toContain('text/event-stream');
expect(response.headers.get('Cache-Control')).toBe('no-cache');
expect(response.body).toBeDefined();
// Lectura de fragmentos desde el ReadableStream
const reader = response.body!.getReader();
const decoder = new TextDecoder();
const chunks: string[] = [];
let isDone = false;
while (!isDone) {
const { value, done } = await reader.read();
if (done) {
isDone = true;
} else if (value) {
chunks.push(decoder.decode(value, { stream: true }));
}
}
const fullContent = chunks.join('');
expect(fullContent).toContain('data: {"event":"connected"}');
expect(fullContent).toContain('data: {"event":"ready"}');
});
});A diferencia del antiguo req.body, el cuerpo de una Request nativa es un ReadableStream de consumo único. Intentar leerlo dos veces (por ejemplo, en un middleware de logging y posteriormente en el Route Handler) arroja la excepción TypeError: Body has already been consumed or is disturbed.
// ❌ INCORRECTO: Múltiples lecturas del cuerpo de la petición
const clone = request; // ¡No clona el flujo de bytes subyacente!
await request.json();
await clone.json(); // Lanza: body already disturbed
// ✅ CORRECTO: Usar request.clone() ANTES de la primera lectura
const requestClone = request.clone();
const data1 = await request.json();
const data2 = await requestClone.json();En Next.js 15, la propiedad params en el segundo argumento context es una Promise<{ ... }>. Acceder a context.params.id de forma síncrona evaluará a undefined porque no se pueden leer propiedades de una promesa no resuelta.
// ❌ INCORRECTO: params es una Promesa en Next.js 15
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const id = (params as unknown as Record<string, string>).id; // ¡undefined!
}
// ✅ CORRECTO: Uso imperativo de await context.params
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
}Si añade manualmente encabezados CORS a un Route Handler sin exportar un manejador dedicado export async function OPTIONS(), las peticiones preflight del navegador fallarán con 405 Method Not Allowed, bloqueando las solicitudes cliente. Debe probarse siempre el método OPTIONS.
Si un stream del servidor no ejecuta controller.close(), el bucle de lectura while (!done) { await reader.read() } en la prueba se bloqueará indefinidamente hasta agotar el timeout de Vitest. Asegúrese siempre de que el stream mockeado concluya o simule la cancelación mediante reader.cancel().
Instanciar la petición sintética con URL completa, método HTTP, encabezados y carga de datos.
Envolver los parámetros dinámicos de ruta en Promise.resolve(params) para Next.js 15.
Aislar accesos a la base de datos y microservicios mediante vi.mock().
Llamar a la función GET/POST/DELETE directamente como función asíncrona estándar.
Comprobar res.status, encabezado Content-Type, directivas CORS y políticas de caché.
Procesar el JSON con await res.json() o consumir fragmentos con reader.read().