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

6 min czytaniaAktualizacja

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, o których piszę niżej, i 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.

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.

Server Actions: co testować jednostkowo, a co na prawdziwej bazie

Server Actions testuję inaczej niż Route Handlery, bo funkcję akcji można wywołać bezpośrednio, jak zwykłą funkcję async. Każda akcja ma trzy warstwy i każdą pokrywam osobno, bo ich awaria kosztuje co innego: autoryzację (czy użytkownik ma prawo), walidację (czy dane są poprawne, zwykle według schematu Zod) i efekty uboczne (zapis, revalidatePath, kolejka zdarzeń). W teście jednostkowym mockujesz auth(), warstwę bazy i next/cache, podajesz FormData i sprawdzasz wynik oraz to, czego akcja nie zrobiła. Najwięcej wartości dają ścieżki negatywne: niepoprawne dane nie dotykają bazy, warunek mutacji obejmuje właściciela zasobu, a błąd zapisu nie uruchamia rewalidacji cache.

Bezpośrednie wywołanie omija jednak warstwę sieciową Next.js. Nie sprawdzi serializacji argumentów, działania formularza bez JavaScriptu ani kontroli nagłówka Origin względem Host, którą Next.js stosuje domyślnie. Dodatkowe zaufane domeny dopuszczasz przez serverActions.allowedOrigins, a nie wyłączając kontrolę. Domyślny limit body akcji wynosi 1 MB. redirect() działa przez wyjątek, więc wywołuj go po zapisie i rewalidacji, poza blokiem try/catch, a w teście jednostkowym zamockuj go i sprawdź argument.

PoziomCo sprawdzaCzego nie zastąpi
Test jednostkowyautoryzację, walidację, własność zasobu, brak efektów po błędzieprawdziwej bazy i formularza w przeglądarce
Test integracyjny na bazie w Dockerzerepository, transakcje, constrainty, mapowanie danychzachowania użytkownika
E2E w Playwrightwysłanie formularza, komunikat błędu, odmowę po wygaśnięciu sesjiprecyzyjnej diagnozy, która gałąź logiki padła

Dla krytycznej mutacji wystarczy kilka szybkich testów jednostkowych, jeden lub dwa testy na prawdziwej bazie i jeden scenariusz E2E. Test na bazie jest tu niezastąpiony przy transakcjach. Jeśli akcja tworzy zamówienie, jego pozycje i płatność, tylko on potwierdzi, że błąd przy drugim zapisie nie zostawi osieroconego rekordu po pierwszym.

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 — konsultant technologiczny produktów cyfrowych i web 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 rozwija własne projekty.

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

Skontaktuj się ze mną
LH.pl – Hosting Mango

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Next.js jako backend: architektura aplikacji full-stack

Next.js może obsługiwać interfejs, logikę biznesową i dostęp do bazy w jednym projekcie. Ten przewodnik pokazuje, jak połączyć komponenty serwerowe, Server Actions i Route Handlers z autoryzacją, cache i zadaniami w tle. Na przykładzie panelu zamówień przejdziemy od odczytu danych do zapisu i utrzymania na produkcji, wskazując momenty, w których warto wydzielić osobną usługę.

Maciej Sala

Maciej Sala

Founder StriveLab

Co wybrać: Route Handlers czy Server Actions?

App Router daje kilka punktów wejścia do kodu serwerowego. Route Handlers wystawiają jawny kontrakt HTTP, natomiast Server Actions łączą mutację z interfejsem Reacta. Oba mechanizmy mogą korzystać z bazy i sekretów, ale rozwiązują inne problemy, a decyzja, którego użyć, powinna wynikać z tego, kto wywołuje operację i jakiego kontraktu potrzebuje .

Maciej Sala

Maciej Sala

Founder StriveLab

Jak testować Server Components w Next.js?

React Testing Library renderuje komponenty w środowisku JSDOM, podczas gdy Next.js uruchamia Server Components w warstwie serwerowej. Wynika to z różnic architektonicznych. JSDOM nie obsługuje środowiska serwerowego, na którym opierają się asynchroniczne komponenty korzystające z baz danych, plików cookies, nagłówków czy operacji sieciowych. Zobacz, jak ominąć te ograniczenia i spokojnie testować nowoczesny kod.

Maciej Sala

Maciej Sala

Founder StriveLab

LH.pl – Cloud Server 1C4G