Testowanie Server Actions — walidacja, autoryzacja i efekty uboczne pod kontrolą

Opublikowano
10 kwietnia 2026
Aktualizacja
4 czerwca 2026
Czas czytania
3 min czytania

Dlaczego Server Actions wymagają testów

Anatomia testowalnego Server Action

mają trzy warstwy. Testuj je osobno:

Code
1. Autoryzacja     → Czy użytkownik ma prawo?
2. Walidacja       → Czy dane są poprawne?
3. Logika/Efekty   → Co się dzieje z danymi?

Dobrze zorganizowany Server Action

Code
// actions/create-post.ts
'use server';
 
import { auth } from '@/auth';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { z } from 'zod';
 
const createPostSchema = z.object({
  title: z.string().min(3).max(200),
  content: z.string().min(10).max(50000),
  categoryId: z.string().uuid(),
  published: z.coerce.boolean().default(false),
});
 
export type CreatePostResult =
  | { success: true; postId: string }
  | { success: false; error: string; details?: Record<string, string[]> };
 
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'),
    published: formData.get('published'),
  });
 
  if (!parsed.success) {
    return {
      success: false,
      error: 'Nieprawidłowe dane',
      details: parsed.error.flatten().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) {
    return { success: false, error: 'Nie udało się utworzyć posta' };
  }
}

Testy

Setup — globalne mocki

Code
// __tests__/helpers/action-mocks.ts
import { vi } from 'vitest';
 
// Mock auth
vi.mock('@/auth', () => ({
  auth: vi.fn(),
}));
 
// Mock database
vi.mock('@/lib/db', () => ({
  db: {
    post: {
      create: vi.fn(),
      update: vi.fn(),
      delete: vi.fn(),
      findUnique: vi.fn(),
    },
    user: {
      findUnique: vi.fn(),
    },
  },
}));
 
// Mock next/cache
vi.mock('next/cache', () => ({
  revalidatePath: vi.fn(),
  revalidateTag: vi.fn(),
}));

Helper — tworzenie FormData w testach

Code
// __tests__/helpers/form-data.ts
export function createFormData(data: Record<string, string | boolean>): FormData {
  const formData = new FormData();
  for (const [key, value] of Object.entries(data)) {
    formData.set(key, String(value));
  }
  return formData;
}

Testy autoryzacji

Code
// __tests__/actions/create-post.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import './helpers/action-mocks';
import { createFormData } from './helpers/form-data';
import { createPost } from '@/actions/create-post';
import { auth } from '@/auth';
import { db } from '@/lib/db';
 
describe('createPost', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });
 
  describe('autoryzacja', () => {
    it('should reject unauthenticated user', async () => {
      vi.mocked(auth).mockResolvedValue(null);
 
      const formData = createFormData({
        title: 'Test post',
        content: 'Treść posta testowego',
        categoryId: '550e8400-e29b-41d4-a716-446655440000',
      });
 
      const result = await createPost(formData);
 
      expect(result.success).toBe(false);
      expect(result.error).toContain('zalogowany');
    });
 
    it('should accept authenticated user', async () => {
      vi.mocked(auth).mockResolvedValue({
        user: { id: 'user-1', name: 'Jan', email: 'jan@test.pl', role: 'USER' },
      } as any);
 
      vi.mocked(db.post.create).mockResolvedValue({
        id: 'post-1',
        title: 'Test post',
        slug: 'test-post',
      } as any);
 
      const formData = createFormData({
        title: 'Test post',
        content: 'Treść posta testowego, wystarczająco długa.',
        categoryId: '550e8400-e29b-41d4-a716-446655440000',
      });
 
      const result = await createPost(formData);
 
      expect(result.success).toBe(true);
    });
  });

Testy walidacji

Code
  describe('walidacja', () => {
    beforeEach(() => {
      vi.mocked(auth).mockResolvedValue({
        user: { id: 'user-1', name: 'Jan', email: 'jan@test.pl' },
      } as any);
    });
 
    it('should reject empty title', async () => {
      const formData = createFormData({
        title: '',
        content: 'Treść posta testowego',
        categoryId: '550e8400-e29b-41d4-a716-446655440000',
      });
 
      const result = await createPost(formData);
 
      expect(result.success).toBe(false);
      expect(result.details?.title).toBeDefined();
    });
 
    it('should reject title over 200 characters', async () => {
      const formData = createFormData({
        title: 'A'.repeat(201),
        content: 'Treść posta testowego',
        categoryId: '550e8400-e29b-41d4-a716-446655440000',
      });
 
      const result = await createPost(formData);
 
      expect(result.success).toBe(false);
    });
 
    it('should reject invalid UUID for categoryId', async () => {
      const formData = createFormData({
        title: 'Poprawny tytuł',
        content: 'Treść posta testowego, wystarczająco długa.',
        categoryId: 'not-a-uuid',
      });
 
      const result = await createPost(formData);
 
      expect(result.success).toBe(false);
      expect(result.details?.categoryId).toBeDefined();
    });
 
    it('should accept valid data', async () => {
      vi.mocked(db.post.create).mockResolvedValue({ id: 'post-1' } as any);
 
      const formData = createFormData({
        title: 'Poprawny tytuł',
        content: 'Treść posta testowego, wystarczająco długa.',
        categoryId: '550e8400-e29b-41d4-a716-446655440000',
      });
 
      const result = await createPost(formData);
 
      expect(result.success).toBe(true);
    });
  });

Testy efektów ubocznych

Code
  describe('efekty uboczne', () => {
    beforeEach(() => {
      vi.mocked(auth).mockResolvedValue({
        user: { id: 'user-1', name: 'Jan', email: 'jan@test.pl' },
      } as any);
    });
 
    it('should create post with correct authorId', async () => {
      vi.mocked(db.post.create).mockResolvedValue({ id: 'post-1' } as any);
 
      const formData = createFormData({
        title: 'Mój post',
        content: 'Treść posta testowego, wystarczająco długa.',
        categoryId: '550e8400-e29b-41d4-a716-446655440000',
      });
 
      await createPost(formData);
 
      expect(db.post.create).toHaveBeenCalledWith({
        data: expect.objectContaining({
          authorId: 'user-1',
          title: 'Mój post',
        }),
      });
    });
 
    it('should revalidate paths after success', async () => {
      const { revalidatePath } = await import('next/cache');
      vi.mocked(db.post.create).mockResolvedValue({ id: 'post-1' } as any);
 
      const formData = createFormData({
        title: 'Mój post',
        content: 'Treść posta testowego, wystarczająco długa.',
        categoryId: '550e8400-e29b-41d4-a716-446655440000',
      });
 
      await createPost(formData);
 
      expect(revalidatePath).toHaveBeenCalledWith('/blog');
      expect(revalidatePath).toHaveBeenCalledWith('/dashboard/posts');
    });
 
    it('should handle database errors gracefully', async () => {
      vi.mocked(db.post.create).mockRejectedValue(new Error('DB connection failed'));
 
      const formData = createFormData({
        title: 'Mój post',
        content: 'Treść posta testowego, wystarczająco długa.',
        categoryId: '550e8400-e29b-41d4-a716-446655440000',
      });
 
      const result = await createPost(formData);
 
      expect(result.success).toBe(false);
      expect(result.error).toContain('Nie udało się');
    });
 
    it('should NOT revalidate paths on failure', async () => {
      const { revalidatePath } = await import('next/cache');
      vi.mocked(db.post.create).mockRejectedValue(new Error('DB error'));
 
      const formData = createFormData({
        title: 'Mój post',
        content: 'Treść posta testowego, wystarczająco długa.',
        categoryId: '550e8400-e29b-41d4-a716-446655440000',
      });
 
      await createPost(formData);
 
      expect(revalidatePath).not.toHaveBeenCalled();
    });
  });
});

Wzorzec: testowanie delete z ownership check

— sprawdzenie, czy użytkownik jest właścicielem zasobu albo administratorem — to jedno z najważniejszych zabezpieczeń mutacji. Pokryj je testami szczególnie dokładnie:

Code
// actions/delete-post.ts
'use server';
 
export async function deletePost(postId: string): Promise<ActionResult> {
  const session = await auth();
  if (!session?.user) return { success: false, error: 'Unauthorized' };
 
  const post = await db.post.findUnique({ where: { id: postId } });
  if (!post) return { success: false, error: 'Post nie istnieje' };
 
  // Tylko autor lub admin może usunąć
  if (post.authorId !== session.user.id && session.user.role !== 'ADMIN') {
    return { success: false, error: 'Brak uprawnień' };
  }
 
  await db.post.delete({ where: { id: postId } });
  revalidatePath('/blog');
  return { success: true };
}
Code
// Testy ownership
describe('deletePost', () => {
  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.findUnique).mockResolvedValue({ id: 'post-1', authorId: 'user-1' } as any);
 
    const result = await deletePost('post-1');
    expect(result.success).toBe(true);
    expect(db.post.delete).toHaveBeenCalled();
  });
 
  it('should reject non-author non-admin', async () => {
    vi.mocked(auth).mockResolvedValue({ user: { id: 'user-2', role: 'USER' } } as any);
    vi.mocked(db.post.findUnique).mockResolvedValue({ id: 'post-1', authorId: 'user-1' } as any);
 
    const result = await deletePost('post-1');
    expect(result.success).toBe(false);
    expect(result.error).toContain('uprawnień');
    expect(db.post.delete).not.toHaveBeenCalled();
  });
 
  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.findUnique).mockResolvedValue({ id: 'post-1', authorId: 'user-1' } as any);
 
    const result = await deletePost('post-1');
    expect(result.success).toBe(true);
  });
});
Testy automatyczne komponentów i E2E w Cypress.
QA & Automation

Często zadawane pytania

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 (test containers), 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.

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. Daje to zwykle pięć do ośmiu testów na akcję. Przy akcjach z kontrolą własności (ownership) dochodzą jeszcze warianty: autor, obcy użytkownik i administrator. Nie chodzi o liczbę dla samej liczby, lecz o pokrycie każdej ścieżki decyzyjnej w kodzie.

Nie. 'use server' to instrukcja dla bundlera Next.js, która oznacza funkcję jako Server Action eksponowaną przez sieć — nie ma znaczenia dla runtime'u testowego. Vitest po prostu importuje i wywołuje funkcję jak każdą inną asynchroniczną funkcję, ignorując dyrektywę. Dlatego Server Action testujesz dokładnie tak samo jak zwykłą funkcję: podajesz wejście, mockujesz zależności i sprawdzasz wynik oraz wywołane efekty uboczne.

Bo to chroni przed subtelnym, ale realnym błędem: rewalidacją cache po operacji, która się nie powiodła. Gdyby akcja unieważniała cache mimo nieudanego zapisu, użytkownik mógłby zobaczyć niespójny stan albo niepotrzebne przebudowanie strony bez żadnej zmiany danych. Asercja expect(revalidatePath).not.toHaveBeenCalled() w teście ścieżki błędu pilnuje, że efekty uboczne dzieją się wyłącznie po faktycznym sukcesie — a to jedna z rzeczy najłatwiejszych do zepsucia przy refaktorze.

Ż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.

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 rozwija własne projekty side-projecty.

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

Skontaktuj się ze mną

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów