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.ymlservices: 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
Przygotowanie, sprzątanie i izolacja testowej bazy danych
Code
// __tests__/helpers/test-db.tsimport { 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.tsimport { 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:
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.tsimport { 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.
Poziom
Co sprawdza
Czego nie zastąpi
Test jednostkowy
autoryzację, walidację, własność zasobu, brak efektów po błędzie
prawdziwej bazy i formularza w przeglądarce
Test integracyjny na bazie w Dockerze
repository, transakcje, constrainty, mapowanie danych
zachowania użytkownika
E2E w Playwright
wysłanie formularza, komunikat błędu, odmowę po wygaśnięciu sesji
precyzyjnej 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:
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.
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.
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
Founder StriveLab
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
Founder StriveLab
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.