В архитектуре Next.js 15 App Router маршрутизаторы API представлены Route Handlers. В отличие от устаревших Pages Router API Routes (req: NextApiRequest, res: NextApiResponse), новые обработчики полностью соответствуют спецификации WHATWG Fetch Standard.
Переход на веб-стандарты открывает широкие возможности для архитектуры тестирования:
export async function GET(request: NextRequest, context: RouteContext): Promise<Response>. Ее можно вызывать напрямую в тестах Vitest в среде Node.js, передавая синтетические экземпляры Request или NextRequest.ReadableStream.context.params стали асинхронными (Promise<{ [key: string]: string | string[] }>), что унифицирует работу с асинхронным вводом-выводом.Интеграционное тестирование Route Handlers позволяет с микросекундной скоростью верифицировать контрактные спецификации OpenAPI, коды ответов HTTP, политики CORS, установку сессионных кук и корректность стриминга.
В ментальной модели тестирования Route Handler рассматривается как чистая функция преобразования стандартного веб-запроса в веб-ответ:
+-------------------------------------------------------------------------------+
| Тестовый запуск в Vitest (Node.js) |
+-------------------------------------------------------------------------------+
│
├─► 1. Создание new NextRequest('https://api.domain/endpoint?filter=active')
├─► 2. Добавление заголовков: Headers({ 'Content-Type': 'application/json' })
├─► 3. Передача динамических параметров context: { params: Promise.resolve(...) }
│
▼
+-------------------------------------------------------------------------------+
| Route Handler (app/api/.../route.ts) |
+-------------------------------------------------------------------------------+
│
├─► Аутентификация токена / Bearer Authorization
├─► Валидация тела запроса: await req.json() (Zod Schema)
├─► Обращение к Data Access Layer (DAL / Drizzle)
│
▼
+-------------------------------------------------------------------------------+
| Результат: 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() (для ReadableStream / SSE)Сконструировать синтетический веб-запрос с полным URL, методом, заголовками и телом.
Обернуть динамические сегменты маршрута в Promise.resolve(params) для Next.js 15.
Изолировать вызовы базы данных и сторонних API с помощью vi.mock().
Вызвать экспортированную функцию GET/POST/DELETE напрямую как асинхронную функцию.
Верифицировать res.status, заголовок Content-Type, политики CORS и директивы кэширования.
Распарсить JSON через await res.json() либо прочитать поток через reader.read().
Каким образом в Next.js 15 передаются и обрабатываются динамические параметры маршрута context.params в Route Handlers при модульном тестировании в Vitest?
OPTIONS и заголовков Access-Control-Allow-Origin, Access-Control-Allow-Headers для открытых или партнерских API.Secure, SameSite=Strict и Path=/.ReadableStream.Рассмотрим Route Handler для получения аналитики проекта по динамическому ID маршрута app/api/projects/[id]/analytics/route.ts. В Next.js 15 параметр context.params возвращает 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> {
// В Next.js 15 params является асинхронным
const { id } = await context.params;
if (!id || id === 'invalid') {
return NextResponse.json({ error: 'Некорректный идентификатор проекта' }, { status: 400 });
}
const { searchParams } = request.nextUrl;
const period = searchParams.get('period') || '30d';
const analytics = await getProjectAnalytics(id, period);
if (!analytics) {
return NextResponse.json({ error: 'Проект не найден' }, { 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('возвращает 200 OK и данные аналитики при валидных параметрах', async () => {
vi.mocked(dal.getProjectAnalytics).mockResolvedValue({
views: 12500,
conversions: 450,
});
// Конструируем NextRequest с query-параметром
const request = new NextRequest('https://agenticlab.dev/api/projects/proj-101/analytics?period=7d');
// Формируем асинхронный контекст 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('возвращает 404 Not Found, если проект не существует', 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('Проект не найден');
});
it('возвращает 400 Bad Request при невалидном идентификаторе', 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();
});
});Эндпоинт аутентификации принимает учетные данные, проверяет пароль и возвращает ответ с установкой защищенной сессионной куки.
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { verifyCredentials } from '@/dal/auth';
const LoginSchema = z.object({
email: z.string().email('Некорректный формат email'),
password: z.string().min(8, 'Пароль минимум 8 символов'),
});
export async function POST(request: NextRequest): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Невалидный JSON в теле запроса' }, { status: 400 });
}
const parsed = LoginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Ошибка валидации', details: parsed.error.flatten().fieldErrors },
{ status: 422 }
);
}
const session = await verifyCredentials(parsed.data.email, parsed.data.password);
if (!session) {
return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 });
}
const response = NextResponse.json(
{ user: { id: session.userId, email: parsed.data.email } },
{ status: 200 }
);
// Установка защищенной HttpOnly куки
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 дней
});
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('возвращает 200 OK и выставляет защищенную сессионную куку при верных данных', 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');
// Проверяем установку cookies в ответе
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('возвращает 422 Unprocessable Entity при невалидном email', 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('Ошибка валидации');
expect(json.details.email).toBeDefined();
expect(authDal.verifyCredentials).not.toHaveBeenCalled();
});
it('возвращает 401 Unauthorized при неверных учетных данных', 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 или пароль');
});
});В современных приложениях Route Handlers часто отдают потоковые данные (LLM Tokens или Server-Sent Events). Для тестирования потока читаем чанки через нативный ReadableStreamDefaultReader и декодируем байты с помощью TextDecoder.
import { describe, it, expect } from 'vitest';
import { NextRequest } from 'next/server';
import { GET } from './route';
describe('GET /api/stream/events (ReadableStream SSE)', () => {
it('отдает корректный потоковый ответ со статусом 200 и чанками 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();
// Читаем потоковые чанки из 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"}');
});
});Что произойдет при попытке повторного вызова await request.json() в тесте или мидлваре без предварительного клонирования запроса, и как правильно решить эту проблему?
В отличие от старого req.body, тело нативного Request представляет собой ReadableStream. Попытка прочитать его дважды (например, сначала в логирующей мидлваре или валидаторе, а затем в теле обработчика) приведет к системному исключению TypeError: Body has already been consumed or is disturbed.
// ❌ ОШИБКА: Повторное чтение тела запроса
const clone = request; // Это не клонирует поток!
await request.json();
await clone.json(); // Падение: body already disturbed
// ✅ ПРАВИЛЬНО: Использовать request.clone() ДО первого чтения
const requestClone = request.clone();
const data1 = await request.json();
const data2 = await requestClone.json();В Next.js 15 объект params внутри второго аргумента context является промисом Promise<{ ... }>. Обращение context.params.id вернет undefined, так как свойство запрашивается от неразрешенного промиса.
// ❌ ОШИБКА: context.params теперь Promise в 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!
}
// ✅ ПРАВИЛЬНО: Обязательный await context.params
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
}Если вы вручную добавляете заголовки CORS в Route Handler без экспорта обработчика export async function OPTIONS(), браузерные preflight-запросы будут падать с кодом 405 Method Not Allowed, блокируя выполнение клиентских вызовов. В тестах необходимо отдельно тестировать метод OPTIONS.
Если серверный стрим не вызывает controller.close(), цикл чтения while (!done) { await reader.read() } в тесте зависнет навсегда и прервется по таймауту Vitest. Всегда удостоверяйтесь, что мок или тестируемый стрим гарантированно завершается либо симулируйте отмену через reader.cancel().