Przejdź do treści

Testy integracyjne Route Handlers w Next.js

Testowanie API w Next.js. Dowiedz się, jak połączyć NextRequest, proxy, testową bazę PostgreSQL w Dockerze i izolować testy.

Maciej Sala

Founder StriveLab

4 min czytaniaOpublikowano 10 kwietnia 2026 (Aktualizacja 14 lipca 2026)

Czym testy integracyjne API różnią się od testów jednostkowych?

Dla API w Next.js testy integracyjne odpowiadają na pytanie: „czy endpoint zwraca poprawne dane z prawdziwą bazą?" To jeden z poziomów piramidy testów — niżej leży testowanie pojedynczych funkcji (Server Actions, Server Components), wyżej pełne scenariusze użytkownika w testach E2E z Playwright.

Testowa baza PostgreSQL w Dockerze dla Next.js

Code
# docker-compose.test.yml
services:
  test-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: testdb
    ports:
      - '5433:5432' # Port 5433, żeby nie kolidować z dev bazą
    tmpfs:
      - /var/lib/postgresql/data # Dane w pamięci RAM — mniej operacji dyskowych
Code
# .env.test
DATABASE_URL=postgresql://test:test@localhost:5433/testdb

Przygotowanie, sprzątanie i izolacja testowej bazy danych

Code
// __tests__/helpers/test-db.ts
import { db } from '@/lib/db'
import { products, users } from '@/db/schema'
import { sql } from 'drizzle-orm'
import { migrate } from 'drizzle-orm/node-postgres/migrator'
 
export async function setupTestDB() {
  // Uruchom migracje na testowej bazie
  await migrate(db, { migrationsFolder: './drizzle' })
}
 
export async function cleanupTestDB() {
  // Czyść tylko tabele należące do testowanego obszaru.
  await db.execute(sql`TRUNCATE TABLE ${products}, ${users} CASCADE`)
}
 
export async function seedTestData() {
  // Wstaw znany zestaw danych przed każdym testem.
  await db.insert(users).values([
    {
      id: 'user-1',
      email: 'jan@test.pl',
      name: 'Jan',
      role: 'USER',
      passwordHash: 'hashed',
    },
    {
      id: 'admin-1',
      email: 'admin@test.pl',
      name: 'Admin',
      role: 'ADMIN',
      passwordHash: 'hashed',
    },
  ])
 
  await db.insert(products).values([
    {
      id: 'product-1',
      name: 'Wiertarka',
      price: 299.99,
      category: 'tools',
    },
    {
      id: 'product-2',
      name: 'Słuchawki',
      price: 499,
      category: 'electronics',
    },
  ])
}

Konfiguracja Vitest: osobny plik i koniec z losowymi błędami

Testy integracyjne dostają własny plik konfiguracyjny, w którym jest jedna linijka, od której zależy stabilność całego zestawu:

Code
// vitest.integration.config.ts
import { defineConfig } from 'vitest/config'
import tsconfigPaths from 'vite-tsconfig-paths'
 
export default defineConfig({
  plugins: [tsconfigPaths()],
  test: {
    environment: 'node',
    include: ['__tests__/integration/**/*.test.ts'],
    // KLUCZOWE: pliki testowe sekwencyjnie, nie równolegle.
    // Wszystkie łączą się z jedną bazą — równoległy TRUNCATE z jednego
    // pliku kasuje dane, na których właśnie pracuje inny.
    fileParallelism: false,
    env: {
      DATABASE_URL: 'postgresql://test:test@localhost:5433/testdb',
    },
  },
})

Bez fileParallelism: false każdy plik przechodzi uruchomiony pojedynczo, a pełny zestaw losowo kończy się błędem, który pojawia się za każdym razem w innym miejscu. Vitest domyślnie uruchamia pliki równolegle, więc TRUNCATE wykonywany przez jeden z nich może usunąć dane używane przez drugi. W wypadku większego projektu lepszym rozwiązaniem jest użycie osobnego schematu albo baza dla każdego procesu roboczego, np. testdb_${process.env.VITEST_POOL_ID}. W ten sposób, zachowasz równoległość bez współdzielenia stanu.

Testowanie Route Handlerów w Next.js: GET, POST i ścieżki negatywne

w Next.js to funkcja, która przyjmuje Request i zwraca Response — można ją wywołać bezpośrednio w teście, bez stawiania serwera HTTP.

Taki test obejmuje kod handlera i jego zależności, ale nie sprawdza routingu ani całego środowiska uruchomieniowego Next.js. Jeśli handler bezpośrednio korzysta z funkcji zależnych od kontekstu żądania, np. cookies(), zastąp tę granicę kontrolowaną atrapą albo dodaj osobny test uruchamiany na działającym serwerze:

Code
// __tests__/integration/products.test.ts
import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'
import { NextRequest } from 'next/server'
import { GET, POST } from '@/app/api/products/route'
import { setupTestDB, cleanupTestDB, seedTestData } from '../helpers/test-db'
 
const { authMock } = vi.hoisted(() => ({ authMock: vi.fn() }))
vi.mock('@/auth', () => ({ auth: authMock }))
 
const endpoint = 'http://localhost:3000/api/products'
 
function postRequest(body: unknown) {
  return new NextRequest(endpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
}
 
describe('/api/products', () => {
  beforeAll(async () => {
    await setupTestDB()
  })
 
  beforeEach(async () => {
    await cleanupTestDB()
    await seedTestData()
    authMock.mockReset()
    authMock.mockResolvedValue({
      user: { id: 'admin-1', email: 'admin@test.pl', role: 'ADMIN' },
    })
  })
 
  it('returns products', async () => {
    const request = new NextRequest(endpoint)
    const response = await GET(request)
    const data = (await response.json()) as Array<{ name: string }>
 
    expect(response.status).toBe(200)
    expect(data).toHaveLength(2)
    expect(data[0].name).toBe('Wiertarka')
  })
 
  it('filters products by category', async () => {
    const request = new NextRequest(`${endpoint}?category=electronics`)
    const response = await GET(request)
    const data = (await response.json()) as Array<{ category: string }>
 
    expect(response.status).toBe(200)
    expect(data).toHaveLength(1)
    expect(data[0].category).toBe('electronics')
  })
 
  it('creates a valid product', async () => {
    const request = postRequest({
      name: 'Młotek',
      price: 99.99,
      category: 'tools',
    })
 
    const response = await POST(request)
    const data = (await response.json()) as { id: string; name: string }
 
    expect(response.status).toBe(201)
    expect(data.name).toBe('Młotek')
    expect(data.id).toBeDefined()
  })
 
  it('rejects invalid data', async () => {
    const request = postRequest({ name: '' })
    const response = await POST(request)
 
    expect(response.status).toBe(400)
  })
 
  it('rejects an unauthenticated request', async () => {
    authMock.mockResolvedValueOnce(null)
    const request = postRequest({
      name: 'Młotek',
      price: 99.99,
      category: 'tools',
    })
 
    const response = await POST(request)
 
    expect(response.status).toBe(401)
  })
})

Skąd handler bierze sesję: atrapa autoryzacji

Samo dodanie nagłówka Authorization: Bearer test-admin-token nic Ci nie da, jeśli handler korzysta z auth() i ciasteczka sesyjnego. Dlatego przykład zastępuje tylko auth() kontrolowaną atrapą, a walidację i bazę pozostawia prawdziwymi. W teście odpowiedzi 401 jednorazowo ustawia brak sesji przez mockResolvedValueOnce(null).

Bardziej wiarygodny wariant polega na utworzeniu prawdziwej sesji w testowej bazie danych i przekazaniu jej ciasteczka w żądaniu. Obejmuje to również odczyt sesji, lecz wymaga więcej przygotowań. Kompromisowo można zastosować atrapę auth() w testach endpointów oraz przeprowadzenie jednego pełnego scenariusza (od ciasteczka, przez sesję, aż po użytkownika) w teście integracyjnym uwierzytelniania lub E2E.

Handlery tras dynamicznych: drugi argument z params

Handler z segmentem dynamicznym (app/api/products/[id]/route.ts) dostaje oprócz żądania obiekt kontekstu. Od Next.js 15 params jest obietnicą (Promise), co w teście odtwarzasz przez Promise.resolve:

Code
import { NextRequest } from 'next/server'
import { GET } from '@/app/api/products/[id]/route'
 
it('returns product by id', async () => {
  const request = new NextRequest(
    'http://localhost:3000/api/products/product-1',
  )
  const response = await GET(request, {
    params: Promise.resolve({ id: 'product-1' }),
  })
 
  expect(response.status).toBe(200)
})
 
it('returns 404 for missing product', async () => {
  const request = new NextRequest('http://localhost:3000/api/products/nope')
  const response = await GET(request, {
    params: Promise.resolve({ id: 'nope' }),
  })
 
  expect(response.status).toBe(404)
})

Testowanie proxy.ts w Next.js 16, czyli dawnego middleware

W Next.js 16 plik middleware.ts został przemianowany na . Jeśli trafisz na starsze przykłady z Next.js, to musisz wiedzieć, że idea testu się nie zmieniła, ale aktualna konwencja nazewnicza to właśnie Proxy. Samą logikę, którą tu testujemy (ochronę ścieżek i role) rozwijam w artykule o RBAC i proxy w Next.js.

Code
// __tests__/integration/proxy.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextRequest } from 'next/server'
import {
  getRedirectUrl,
  // Mimo rename middleware -> proxy funkcja zachowała starą nazwę
  unstable_doesMiddlewareMatch,
} from 'next/experimental/testing/server'
import { config, proxy } from '@/proxy'
 
const { verifySessionMock } = vi.hoisted(() => ({ verifySessionMock: vi.fn() }))
vi.mock('@/lib/session', () => ({ verifySession: verifySessionMock }))
 
describe('proxy', () => {
  beforeEach(() => {
    verifySessionMock.mockReset()
    verifySessionMock.mockResolvedValue(null)
  })
 
  it('does not match public paths', () => {
    expect(
      unstable_doesMiddlewareMatch({ config, nextConfig: {}, url: '/' }),
    ).toBe(false)
  })
 
  it('redirects an unauthenticated user to /login', async () => {
    const request = new NextRequest('http://localhost:3000/dashboard')
    const response = await proxy(request)
 
    expect(response.status).toBe(307)
    expect(getRedirectUrl(response)).toBe('http://localhost:3000/login')
  })
 
  it('allows a user with a valid session', async () => {
    verifySessionMock.mockResolvedValueOnce({
      userId: 'user-1',
      role: 'USER',
    })
    const request = new NextRequest('http://localhost:3000/dashboard', {
      headers: { Cookie: 'session=valid-test-token' },
    })
 
    const response = await proxy(request)
 
    expect(response.status).toBe(200)
  })
 
  it('adds security headers', async () => {
    const request = new NextRequest('http://localhost:3000/dashboard')
    const response = await proxy(request)
 
    expect(response.headers.get('X-Frame-Options')).toBe('DENY')
    expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
  })
})

Ten przykład zakłada, że proxy.ts deleguje sprawdzenie sesji do funkcji verifySession i dzięki temu test kontroluje stan uwierzytelnienia bez akceptowania zmyślonego tokenu jako prawidłowego. unstable_doesMiddlewareMatch sprawdza osobno, czy konfiguracja matcher w ogóle uruchomi Proxy dla danej ścieżki — funkcja zachowała nazwę z czasów middleware, mimo że sam plik nazywa się już proxy.ts.

Skrypty testowe i testy integracyjne API w CI

Code
// package.json
{
  "scripts": {
    "test": "vitest",
    "test:unit": "vitest run --config vitest.config.ts",
    "test:integration": "docker compose -f docker-compose.test.yml up -d --wait && vitest run --config vitest.integration.config.ts; status=$?; docker compose -f docker-compose.test.yml down; exit $status",
    "test:e2e": "playwright test",
    "test:all": "npm run test:unit && npm run test:integration && npm run test:e2e"
  }
}

W CI nie musisz uruchamiać bazy przez docker compose, ponieważ GitHub Actions udostępnia services, które uruchamiają kontener obok zadania. Test łączy się z nim przez localhost:

Code
# .github/workflows/test.yml
jobs:
  integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports:
          - '5433:5432'
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: vitest run --config vitest.integration.config.ts
        env:
          DATABASE_URL: postgresql://test:test@localhost:5433/testdb

Różnica względem lokalnego skryptu: kontener uruchamia się raz na całe zadanie, a health-cmd pg_isready sprawia, że testy ruszą dopiero wtedy, gdy baza przyjmuje połączenia. Lokalny skrypt zachowuje kod wyjścia Vitesta i sprząta kontener także po nieudanym teście.

Testy automatyczne komponentów i E2E w Cypress.
QA & Automation

Często zadawane pytania

Czy testy integracyjne z prawdziwą bazą nie są zbyt wolne?

Niekoniecznie. PostgreSQL uruchamia się tylko raz dla całego zestawu testów, a tmpfs skutecznie ogranicza operacje dyskowe. Rzeczywisty czas wykonania zależy od takich rzeczy jak złożoność migracji, liczba zapytań i sposób czyszczenia danych. Ostatecznie zawsze warto zmierzyć go w ramach własnego projektu, zamiast z góry zakładać stały czas pojedynczego testu.

Czy mogę użyć SQLite zamiast PostgreSQL do testów?

Technicznie tak, ale różnice w dialekcie SQL potrafią dać fałszywie zielone testy — zapytanie działa na SQLite, a kończy się błędem na produkcyjnym PostgreSQL. Jeśli produkcja stoi na PostgreSQL, testuj na PostgreSQL. Koszt uruchomienia kontenera jest niewspółmiernie mniejszy niż koszt błędu, który przeszedł przez testy.

Jak izolować testy od siebie, żeby dane z jednego nie wyciekały do drugiego?

Czyść używane tabele w beforeEach przez TRUNCATE ... CASCADE, a potem wstaw świeże dane początkowe. Dzięki temu każdy test zaczyna od identycznego, znanego stanu i kolejność testów przestaje mieć znaczenie. To tańsze i pewniejsze niż próby cofania transakcji czy ręcznego sprzątania po każdym przypadku.

Jak zastąpić autoryzację atrapą w testach integracyjnych?

Masz dwie drogi. Prostsza to zastąpienie funkcji auth() przez vi.mock i zwracanie ustalonej sesji. Bardziej realistyczna to utworzenie prawdziwej sesji w testowej bazie i przekazanie odpowiedniego ciasteczka w żądaniu. Pierwsza jest szybsza, a druga pokrywa też logikę odczytu sesji. Co wybrać zależy od tego, co w praktyce chcesz przetestować.

Dlaczego testy integracyjne losowo kończą się błędem przy pełnym przebiegu?

Niemal zawsze to równoległość. Vitest domyślnie uruchamia pliki testowe równolegle, a wszystkie łączą się z tą samą bazą — TRUNCATE z jednego pliku kasuje dane, na których właśnie pracuje inny. Dla testów integracyjnych wyłącz równoległość plików (fileParallelism: false w konfiguracji) albo daj każdemu procesowi roboczemu osobną bazę lub schemat. Klasycznym objawem tego problemu, jest gdy pojedynczo testy przechodzą, a całość losowo kończy się błędem.

Jak testować proxy.ts w Next.js 16?

proxy.ts to nowa nazwa dawnego middleware.ts. Eksportowaną funkcję wywołujesz w teście bezpośrednio, podając jej NextRequest zbudowany ręcznie i sprawdzasz status oraz nagłówki odpowiedzi. Przykładowo, przekierowanie 307 dla niezalogowanego użytkownika albo obecność nagłówków bezpieczeństwa. Ciasteczka ustawiasz na obiekcie żądania przed wywołaniem.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwijam własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Middleware w Next.js — 7 zastosowań i typowych pułapek

Większość deweloperów może kojarzyć middleware w Next.js ze sprawdzaniem, czy użytkownik jest zalogowany, podczas gdy dla żądań objętych matcherem może ono podjąć decyzję jeszcze przed routingiem: przypisać wariant eksperymentu, zastosować limit, wykonać rewrite albo dodać zależny od requestu nagłówek. W artykule chciałem omówić siedem optymalnych i bezpiecznych zastosowań Proxy.

Maciej Sala

Maciej Sala

Founder StriveLab

Route Handlers czy Server Actions? Kiedy co wybrać

App Router daje Ci dwa sposoby na uruchomienie kodu po stronie serwera, poprzez Route Handlers i Server Actions . Może wyglądają podobnie, ponieważ oba działają na serwerze, sięgają do bazy i do zmiennych środowiskowych, ale każdy z nich rozwiązuje zupełnie inny problem. Użycie jednego tam, gdzie pasuje drugi, nie jest odpowiednim rozwiązaniem, ponieważ zły wybór odbija się potem na architekturze.

Maciej Sala

Maciej Sala

Founder StriveLab

Repository i Service Layer w Next.js dla Server Actions

W Next.js traktuj Server Actions i Route Handlers jako cienkie adaptery wejścia: pobierają sesję, walidują payload i mapują odpowiedź. Reguły biznesowe trzymaj w Service Layer, zapytania do bazy w Repository, a transakcje spinaj przez Unit of Work. Taki podział nie jest potrzebny w każdym CRUD-zie, ale zaczyna się zwracać, gdy jedna operacja dotyka kilku tabel, kilku wejść albo kilku reguł.

Maciej Sala

Maciej Sala

Founder StriveLab