В архітектурі Next.js 15 App Router маршрутизатори API представлені Route Handlers. На відміну від застарілих Pages Router API Routes (req: NextApiRequest, res: NextApiResponse), нові обробники повністю відповідають специфікації WHATWG Fetch Standard.
Перехід на стандартизовані Web API відкриває значні можливості для архітектури тестування:
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. Додавання заголовків: new 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)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 або 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"}');
});
});На відміну від старого 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().
Сконструювати синтетичний веб-запит із повним URL, методом, заголовками та тілом.
Огорнути динамічні сегменти маршруту в Promise.resolve(params) для Next.js 15.
Ізолювати виклики бази даних та сторонніх API за допомогою vi.mock().
Викликати експортовану функцію GET/POST/DELETE напряму як асинхронну функцію.
Верифікувати res.status, заголовок Content-Type, політики CORS та директиви кешування.
Розібрати JSON через await res.json() або прочитати потік через reader.read().