Server Actions to logika serwerowa, która wymaga solidnych testów. Zobacz, jak testować walidację, autoryzację i efekty uboczne w środowisku Vitest.
Maciej Sala
Founder StriveLab
8 min czytaniaOpublikowano 10 kwietnia 2026 (Aktualizacja 17 lipca 2026)
Dlaczego Server Actions w Next.js wymagają testów?
Struktura testowalnego Server Action: trzy warstwy
Next.js nadaje używanym akcjom szyfrowane, niedeterministyczne identyfikatory i usuwa nieużywane akcje podczas buildu. To utrudnia przypadkowe wywołanie, ale nie jest mechanizmem autoryzacji, ponieważ dokumentacja nadal wymaga traktować utworzone endpointy jak publiczne API.
mają trzy warstwy. Testuj je
osobno, bo awaria każdej z nich ma inny koszt:
Code
1. Autoryzacja → Czy użytkownik ma prawo?
2. Walidacja → Czy dane są poprawne?
3. Logika/Efekty → Co się dzieje z danymi?
Ten podział jest ważniejszy niż sam framework testowy. Gdy test autoryzacji pada, masz problem bezpieczeństwa, taki sam jak klasyczne luki CSRF, XSS czy injection, tylko schowany głębiej w logice. Gdy test walidacji pada, do bazy mogą wejść dane, których reszta systemu nie umie obsłużyć. Z kolei gdy test efektów ubocznych pada, użytkownik widzi niespójny cache, dostaje maila po nieudanej operacji albo traci dane przez częściowo wykonaną mutację.
Dobrze zorganizowany Server Action w Next.js
Code
// actions/create-post.ts'use server'import { auth } from '@/auth'import { revalidatePath } from 'next/cache'import { db } from '@/lib/db'import { isExpectedDatabaseError } from '@/lib/db-errors'import { slugify } from '@/lib/slugify'import { z } from 'zod'const createPostSchema = z.object({ title: z.string().min(3).max(200), content: z.string().min(10).max(50000), categoryId: z.uuid(), // Zod 4; na Zod 3 było z.string().uuid() published: z.boolean().default(false),})export type CreatePostResult = | { success: true; postId: string } | { success: false error: string details?: Record<string, string[] | undefined> }export async function createPost( formData: FormData,): Promise<CreatePostResult> { // 1. Autoryzacja const session = await auth() if (!session?.user) { return { success: false, error: 'Musisz być zalogowany' } } // 2. Walidacja const parsed = createPostSchema.safeParse({ title: formData.get('title'), content: formData.get('content'), categoryId: formData.get('categoryId'), // Standardowy checkbox nie wysyła pola, gdy nie jest zaznaczony. published: formData.has('published'), }) if (!parsed.success) { return { success: false, error: 'Nieprawidłowe dane', details: z.flattenError(parsed.error).fieldErrors, } } // 3. Logika try { const post = await db.post.create({ data: { ...parsed.data, authorId: session.user.id, slug: slugify(parsed.data.title), }, }) revalidatePath('/blog') revalidatePath('/dashboard/posts') return { success: true, postId: post.id } } catch (error) { if (isExpectedDatabaseError(error)) { return { success: false, error: 'Nie udało się utworzyć posta' } } throw error }}
Szeroki catch, który zamienia każdy wyjątek na zwykły wynik formularza, ukrywa awarie i błędy programistyczne. Zwracaj przewidywalne problemy, np. konflikt unikalności, jako wynik akcji. Nieoczekiwany błąd połączenia lub wyjątek w kodzie zarejestruj w monitoringu i pozwól obsłużyć przez error.tsx, gdy akcja jest wywołana z formularza albo w startTransition. Przy ręcznym wywołaniu z event handlera bez transition obsłuż błąd po stronie klienta. Klient nadal nie powinien dostać szczegółów zapytania ani stosu błędu.
// __tests__/helpers/form-data.tsexport function createFormData( data: Record<string, string | boolean>,): FormData { const formData = new FormData() for (const [key, value] of Object.entries(data)) { if (typeof value === 'boolean') { if (value) formData.set(key, 'on') continue } formData.set(key, value) } return formData}
To rozróżnienie ma znaczenie. z.coerce.boolean() używa reguł JavaScriptowego Boolean(), więc niepusty tekst 'false' również daje true. Dla standardowego checkboxa najczytelniej zamienić obecność pola na boolean przez formData.has('published'), a dopiero potem walidować z.boolean().
Te negatywne asercje są ważniejsze niż wyglądają. Test, który sprawdza tylko result.success === false, mówi jedynie, że funkcja zwróciła błąd. Nie mówi, czy po drodze nie zapisała części danych, nie odpaliła rewalidacji albo nie wysłała maila. W Server Actions testujesz nie tylko odpowiedź, ale też brak skutków ubocznych.
revalidatePath() jest najczęściej pokazywanym efektem ubocznym, ale w prawdziwej aplikacji Server Action zwykle robi więcej: wysyła maila, dodaje wpis do audit logu, publikuje zdarzenie do kolejki, tworzy rekord płatności albo odpala webhook. To zresztą jedna z różnic między Server Actions a route handlerami, o których łatwo zapomnieć przy testach. Każdy z tych efektów musi mieć tę samą zasadę: najpierw autoryzacja i walidacja, potem zapis, dopiero na końcu komunikacja ze światem zewnętrznym.
Nie da się objąć jedną transakcją zapisu w PostgreSQL i wysłania maila przez zewnętrzne API. Gdy aktualizacja rekordu się uda, a mailer zwróci błąd, cofnięcie odpowiedzi nie cofnie danych. Rozwiązaniem jest : w jednej transakcji zapisujesz zmianę biznesową, audit log i komunikat do wysłania. Osobny worker pobiera outbox i ponawia wysyłkę.
Test akcji nie ma na celu udowadniania, że zewnętrzna biblioteka mailingowa działa poprawnie. Jego zadaniem jest pilnowanie, aby transakcja nie uruchomiła się bez odpowiednich uprawnień, a rewalidacja następowała wyłącznie po jej zatwierdzeniu. Z kolei działanie workera testuje się osobno, weryfikując pobranie rekordu z outboxu, wywołanie mailera, mechanizm ponownych prób oraz oznaczenie komunikatu jako obsłużonego.
Code
describe('approveComment', () => { beforeEach(() => vi.resetAllMocks()) it('should not start a transaction when user is not admin', async () => { vi.mocked(auth).mockResolvedValue({ user: { id: 'user-1', role: 'USER' }, } as any) const result = await approveComment('550e8400-e29b-41d4-a716-446655440000') expect(result.success).toBe(false) expect(db.$transaction).not.toHaveBeenCalled() }) it('should revalidate only after committed transaction', async () => { const { revalidatePath } = await import('next/cache') vi.mocked(auth).mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' }, } as any) vi.mocked(db.$transaction).mockImplementation(async (callback) => callback({ comment: { findUnique: vi.fn().mockResolvedValue({ authorEmail: 'author@example.com', }), update: vi.fn().mockResolvedValue({}), }, auditLog: { create: vi.fn().mockResolvedValue({}) }, outbox: { create: vi.fn().mockResolvedValue({}) }, } as any), ) const result = await approveComment('550e8400-e29b-41d4-a716-446655440000') expect(result.success).toBe(true) expect(db.$transaction).toHaveBeenCalledTimes(1) expect(revalidatePath).toHaveBeenCalledWith('/blog') }) it('should not revalidate when transaction rolls back', async () => { const { revalidatePath } = await import('next/cache') vi.mocked(auth).mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' }, } as any) vi.mocked(db.$transaction).mockRejectedValue( new Error('Transaction failed'), ) await expect( approveComment('550e8400-e29b-41d4-a716-446655440000'), ).rejects.toThrow('Transaction failed') expect(revalidatePath).not.toHaveBeenCalled() })})
Przy płatnościach, webhookach i kolejkach ta zasada nabiera szczególnego znaczenia. Pole deduplicationKey musi posiadać unikalny indeks w bazie danych, a worker ma obowiązek atomowo oznaczać komunikat jako przetwarzany lub obsłużony. Powyższy mock wykonuje callback, ale nie dowodzi faktycznego zatwierdzenia transakcji. Testuj dwukrotne wywołanie z tym samym kluczem, aby upewnić się, że powstaje najwyżej jeden efekt zewnętrzny. Sam test jednostkowy kolejności wywołań nie zastąpi testu integracyjnego transakcji, mechanizmu wycofywania zmian oraz ograniczenia unikalności.
Minimalny test idempotencji uruchom na prawdziwej bazie, najlepiej współbieżnie: dwa wywołania z tym samym kluczem komendy nie mogą utworzyć dwóch płatności ani dwóch rekordów outbox. Sprawdź też retry workera po błędzie dostawcy i zachowanie po restarcie procesu. Mock, który zwraca ten sam wynik dwa razy, nie dowodzi działania indeksu unikalnego ani blokad transakcyjnych.
Testowanie delete action z ownership check
sprawdza, czy użytkownik jest
właścicielem zasobu albo administratorem. To jedno z najważniejszych
zabezpieczeń mutacji, więc pokryj je testami szczególnie dokładnie:
Code
// actions/delete-post.ts'use server'import { auth } from '@/auth'import { db } from '@/lib/db'import { revalidatePath } from 'next/cache'import { z } from 'zod'const postIdSchema = z.string().min(1).max(100)type ActionResult = { success: true } | { success: false; error: string }export async function deletePost(postId: string): Promise<ActionResult> { const session = await auth() if (!session?.user) return { success: false, error: 'Unauthorized' } const parsedId = postIdSchema.safeParse(postId) if (!parsedId.success) { return { success: false, error: 'Nieprawidłowy identyfikator' } } // Własność jest częścią warunku mutacji, a nie osobnym odczytem przed nią. const deleted = await db.post.deleteMany({ where: { id: parsedId.data, ...(session.user.role === 'ADMIN' ? {} : { authorId: session.user.id }), }, }) if (deleted.count === 0) { return { success: false, error: 'Post nie istnieje lub brak uprawnień' } } revalidatePath('/blog') return { success: true }}
Code
// Testy ownershipdescribe('deletePost', () => { beforeEach(() => vi.resetAllMocks()) it('should allow author to delete own post', async () => { vi.mocked(auth).mockResolvedValue({ user: { id: 'user-1', role: 'USER' }, } as any) vi.mocked(db.post.deleteMany).mockResolvedValue({ count: 1 } as any) const result = await deletePost('post-1') expect(result.success).toBe(true) expect(db.post.deleteMany).toHaveBeenCalledWith({ where: { id: 'post-1', authorId: 'user-1' }, }) }) it('should reject non-author non-admin', async () => { vi.mocked(auth).mockResolvedValue({ user: { id: 'user-2', role: 'USER' }, } as any) vi.mocked(db.post.deleteMany).mockResolvedValue({ count: 0 } as any) const result = await deletePost('post-1') expect(result).toMatchObject({ success: false, error: expect.stringContaining('uprawnień'), }) expect(db.post.deleteMany).toHaveBeenCalledWith({ where: { id: 'post-1', authorId: 'user-2' }, }) }) it('should allow admin to delete any post', async () => { vi.mocked(auth).mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' }, } as any) vi.mocked(db.post.deleteMany).mockResolvedValue({ count: 1 } as any) const result = await deletePost('post-1') expect(result.success).toBe(true) expect(db.post.deleteMany).toHaveBeenCalledWith({ where: { id: 'post-1' } }) }) it('should return the same response for a missing or inaccessible post', async () => { vi.mocked(auth).mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' }, } as any) vi.mocked(db.post.deleteMany).mockResolvedValue({ count: 0 } as any) const result = await deletePost('missing-post') expect(result).toMatchObject({ success: false, error: expect.stringContaining('nie istnieje lub brak uprawnień'), }) expect(db.post.deleteMany).toHaveBeenCalledTimes(1) })})
Warunek własności w samym DELETE zamyka okno między odczytem a mutacją. W publicznym API wspólny komunikat dla braku rekordu i braku uprawnień ogranicza też możliwość odgadywania, które identyfikatory istnieją.
Service layer przy testowaniu Server Actions
Bezpośrednie mockowanie bazy danych sprawdza się w prostych przykładach, ale w większym projekcie szybko staje się problematyczne, ponieważ każdy test musi znać strukturę ORM, nazwy modeli oraz układ zapytań. W takiej sytuacji lepiej sprawdzi się architektura, w której Server Action działa jako cienka warstwa obsługująca zapytania HTTP i formularze, a cała logika biznesowa trafia do niezależnego serwisu z wstrzykiwanymi zależnościami. Ten podział opisałem dokładniej we wzorcu repository i service layer, tutaj skupiam się na tym, jak ułatwia testowanie.
Schemat Zod i typ wyniku przenieś wtedy z pliku Action do neutralnego modułu domenowego, żeby serwis nie importował pliku oznaczonego 'use server':
Ten wzorzec nie zastępuje testu samego Server Action. Nadal potrzebujesz jednego testu adaptera, który sprawdza, czy funkcja auth() blokuje niezalogowanego użytkownika oraz czy obiekt FormData poprawnie mapuje się na dane wejściowe serwisu. Całą resztę logiki przenosisz jednak do miejsca, które testuje się znacznie szybciej i bez konieczności mockowania modułów Next.js.
Czego unit test Server Action nie obejmuje
Bezpośrednie createPost(formData) nie przechodzi przez warstwę sieciową Next.js, więc nie może sprawdzić, czy argumenty i wynik są serializowalne przez Reacta, czy formularz działa bez JavaScriptu, ani czy framework odrzuca obcy origin. Next.js domyślnie porównuje Origin z Host lub X-Forwarded-Host. Dodatkowe zaufane domeny dodajesz przez serverActions.allowedOrigins zamiast wyłączać kontrolę. Domyślny limit body Server Action wynosi 1 MB, więc dla większych plików zaplanuj bezpośredni upload do storage zamiast zwiększać limit bez analizy ryzyka.
Te właściwości pokrywa test uruchomionej aplikacji. W Playwright wyślij prawdziwy formularz i sprawdź przynajmniej scenariusz sukcesu, błąd walidacji oraz odmowę po wygaśnięciu sesji. Przy niestandardowym proxy dodaj test środowiskowy potwierdzający poprawne nagłówki hosta i originu. Unit test nadal jest właściwym miejscem dla większości kombinacji danych, ale nie jest testem endpointu wygenerowanego przez Next.js.
Jeśli Action kończy się przez redirect(), wywołaj go po zapisie i rewalidacji, poza blokiem try/catch. redirect() realizuje sterowanie przez wyjątek obsługiwany przez Next.js; szeroki catch zamieniłby poprawne przekierowanie w błąd aplikacji. W unit teście zamockuj redirect i sprawdź argument, a faktyczny status 303 po żądaniu POST potwierdź w teście integracyjnym lub E2E.
Testy unit, integration czy E2E dla Server Actions?
Nie każdy błąd łapie ten sam typ testu. Dobra strategia dla Server Actions wygląda tak:
Poziom
Co testuje
Czego nie udaje
Unit test
decyzje w funkcji, walidację, auth, ownership, brak efektów ubocznych po błędzie
prawdziwej bazy, constraintów, formularza w przeglądarce
Integration test
repository, transakcje, constrainty, mapowanie danych, prawdziwy adapter bazy
zachowania użytkownika i renderu formularza
E2E / Playwright
pełną ścieżkę: wpisanie danych, submit, komunikat, odświeżony widok
precyzyjnej diagnozy, która gałąź logiki padła
W praktyce dla krytycznej mutacji piszesz kilka szybkich unit testów, jeden lub dwa testy integracyjne na prawdziwej bazie i jeden E2E na najważniejszy scenariusz użytkownika. Nie testujesz wszystkiego na każdym poziomie, bo wtedy dostajesz wolny zestaw testów, który powtarza te same asercje i nadal zostawia luki w miejscach, gdzie zależności zachowują się inaczej niż mocki.
Musisz szczególnie pilnować transakcji. Jeśli Server Action tworzy zamówienie, jego pozycje oraz płatność, test integracyjny musi sprawdzić mechanizm wycofywania zmian, ponieważ błąd przy drugim zapisie nie może zostawić osieroconego rekordu po pierwszym. Dopiero test na prawdziwej bazie danych potwierdzi, że transakcja działa prawidłowo.
Czy Server Actions można testować bez mockowania bazy?
Tak, jeśli stosujesz wzorzec repository / service layer. Wtedy mockujesz cienką warstwę repository zamiast całego ORM, co jest prostsze i czytelniejsze. Alternatywą jest test z prawdziwą, ale izolowaną bazą: testowy PostgreSQL uruchamiany w kontenerze (np. przez Testcontainers), czyszczony między testami. Pierwsze podejście jest szybsze i nadaje się do testów jednostkowych logiki, drugie daje większą pewność co do faktycznych zapytań. W praktyce łącz oba poziomy.
Ile testów pisać na jeden Server Action?
Rozsądne minimum to jeden test autoryzacji (niezalogowany jest odrzucony), dwa-trzy testy walidacji (poprawne dane plus przypadki brzegowe), jeden test udanej operacji i jeden test błędu bazy. Ostatecznie daje to zwykle pięć do ośmiu testów na akcję. Przy akcjach z kontrolą własności (ownership) dochodzą jeszcze warianty, czyli autor, obcy użytkownik i administrator.
Czy dyrektywa use server przeszkadza w testach?
Nie. 'use server' to instrukcja dla bundlera Next.js, która oznacza funkcję jako Server Action eksponowaną przez sieć. Nie ma znaczenia dla bezpośredniego wywołania w runtime testowym. Vitest importuje funkcję jak każdą inną funkcję async. Taki unit test sprawdza jej logikę, ale omija generowany przez Next.js endpoint, serializację argumentów, kontrolę originu i limit rozmiaru żądania. Te elementy wymagają testu przez uruchomioną aplikację.
Czego nie sprawdza bezpośredni unit test Server Action?
Nie przechodzi przez protokół Server Actions generowany przez Next.js, więc nie testuje identyfikatora akcji, serializacji Reacta, ochrony CSRF opartej na porównaniu Origin z Host, konfiguracji allowedOrigins ani domyślnego limitu body wynoszącego 1 MB. Unit test pokrywa decyzje i kolejność efektów w funkcji. Co najmniej jeden test E2E powinien wysłać formularz przez prawdziwą aplikację.
Po co testować, że revalidatePath NIE został wywołany przy błędzie?
To chroni przed drobnym błędem polegającym na rewalidacji cache po operacji, która zakończyła się niepowodzeniem. Gdyby akcja unieważniała cache pomimo błędu zapisu, użytkownik mógłby zobaczyć niespójny stan lub wywołać niepotrzebne przebudowanie strony bez żadnej zmiany danych. Asercja expect(revalidatePath).not.toHaveBeenCalled() w teście ścieżki błędu pilnuje, aby efekty uboczne następowały wyłącznie po faktycznym sukcesie. To jeden z tych elementów, które bardzo łatwo psuje się podczas refaktoryzacji.
Co dokładnie powinien sprawdzać test autoryzacji Server Action?
Że niezalogowany użytkownik dostaje odmowę, zanim akcja w ogóle dotknie danych. W teście mockujesz auth() tak, by zwróciło null, wywołujesz akcję i sprawdzasz dwie rzeczy: że wynik to porażka z komunikatem o konieczności zalogowania oraz że żadna metoda bazy nie została wywołana. Druga asercja pilnuje odmowy na samym wejściu, a nie dopiero po częściowym wykonaniu logiki, co mogłoby zostawić skutki uboczne.
Kiedy wystarczy unit test, a kiedy trzeba testu integracyjnego?
Unit test wystarczy, gdy sprawdzasz decyzje w kodzie: walidację Zod, odmowę dla niezalogowanego użytkownika, ownership check, kolejność wywołań i brak efektów ubocznych po błędzie. Test integracyjny dodaj tam, gdzie chcesz zaufać prawdziwej bazie, transakcji, constraintom, uprawnieniom i serializacji danych. E2E zostaw dla ścieżki formularza: użytkownik klika, wysyła dane, widzi komunikat i odświeżony widok. Te trzy poziomy nie konkurują ze sobą, tylko łapią inne klasy błędów.
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.
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
Founder StriveLab
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
Founder StriveLab
Test jednostkowy mówi, że pojedyncza funkcja działa w izolacji, tyle że na produkcji nic nie funkcjonuje w oderwaniu. Test integracyjny przepuszcza prawdziwe zależności lub ich lekkie zamienniki przez cały łańcuch. Poczynając od Route Handlera, przez walidację i bazę danych, aż po odpowiedź; dopiero taki test wychwytuje błędy, które powstają na styku tych warstw.