Przejdź do treści

Jak poprawnie testować Server Actions w Next.js

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.

Jak pisać testy Server Actions w Vitest

Setup Vitest: globalne mocki dla Server Actions

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: {
    $transaction: vi.fn(),
    post: {
      create: vi.fn(),
      update: vi.fn(),
      delete: vi.fn(),
      deleteMany: vi.fn(),
      findUnique: vi.fn(),
    },
    user: {
      findUnique: vi.fn(),
    },
  },
}))
 
vi.mock('@/lib/db-errors', () => ({
  isExpectedDatabaseError: vi.fn(),
}))
 
// Mock next/cache
vi.mock('next/cache', () => ({
  revalidatePath: vi.fn(),
  revalidateTag: vi.fn(),
}))

Helper do tworzenia 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)) {
    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().

Testy autoryzacji w Server Actions

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';
import { isExpectedDatabaseError } from '@/lib/db-errors';
 
describe('createPost', () => {
  beforeEach(() => {
    vi.resetAllMocks();
  });
 
  describe('autoryzacja', () => {
    it('should reject unauthenticated user', async () => {
      vi.mocked(auth).mockResolvedValue(null);
      const { revalidatePath } = await import('next/cache');
 
      const formData = createFormData({
        title: 'Test post',
        content: 'Treść posta testowego',
        categoryId: '550e8400-e29b-41d4-a716-446655440000',
      });
 
      const result = await createPost(formData);
 
      expect(result).toMatchObject({
        success: false,
        error: expect.stringContaining('zalogowany'),
      });
      expect(db.post.create).not.toHaveBeenCalled();
      expect(revalidatePath).not.toHaveBeenCalled();
    });
 
    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 danych w Server Actions

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).toMatchObject({
      success: false,
      details: { title: expect.any(Array) },
    })
    expect(db.post.create).not.toHaveBeenCalled()
  })
 
  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)
    expect(db.post.create).not.toHaveBeenCalled()
  })
 
  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).toMatchObject({
      success: false,
      details: { categoryId: expect.any(Array) },
    })
    expect(db.post.create).not.toHaveBeenCalled()
  })
 
  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)
    expect(db.post.create).toHaveBeenCalledTimes(1)
    expect(db.post.create).toHaveBeenCalledWith({
      data: expect.objectContaining({ published: false }),
    })
  })
})

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.

Testy efektów ubocznych w Server Actions

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 rethrow unexpected database errors', async () => {
      const { revalidatePath } = await import('next/cache');
      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',
      });
 
      await expect(createPost(formData)).rejects.toThrow('DB connection failed');
 
      expect(revalidatePath).not.toHaveBeenCalled();
    });
 
    it('should return an expected database conflict without revalidation', async () => {
      const { revalidatePath } = await import('next/cache');
      const conflict = new Error('Unique constraint');
      vi.mocked(isExpectedDatabaseError).mockReturnValue(true);
      vi.mocked(db.post.create).mockRejectedValue(conflict);
 
      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).toEqual({
        success: false,
        error: 'Nie udało się utworzyć posta',
      });
      expect(revalidatePath).not.toHaveBeenCalled();
    });
  });
});

Efekty uboczne poza cache w Server Actions

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

Code
// actions/approve-comment.ts
'use server'
 
import { auth } from '@/auth'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
 
const commentIdSchema = z.uuid()
type ActionResult = { success: true } | { success: false; error: string }
 
export async function approveComment(commentId: string): Promise<ActionResult> {
  const session = await auth()
  if (!session?.user || session.user.role !== 'ADMIN') {
    return { success: false, error: 'Brak uprawnień' }
  }
 
  const parsedId = commentIdSchema.safeParse(commentId)
  if (!parsedId.success) {
    return { success: false, error: 'Nieprawidłowy identyfikator' }
  }
 
  const result = await db.$transaction(async (tx) => {
    const comment = await tx.comment.findUnique({
      where: { id: parsedId.data },
    })
    if (!comment) return null
 
    await tx.comment.update({
      where: { id: parsedId.data },
      data: { status: 'APPROVED' },
    })
 
    await tx.auditLog.create({
      data: {
        actorId: session.user.id,
        action: 'COMMENT_APPROVED',
        resourceId: parsedId.data,
      },
    })
 
    await tx.outbox.create({
      data: {
        type: 'COMMENT_APPROVED_EMAIL',
        deduplicationKey: `comment-approved:${parsedId.data}`,
        payload: { to: comment.authorEmail, commentId: parsedId.data },
      },
    })
 
    return { commentId: parsedId.data }
  })
 
  if (!result) {
    return { success: false, error: 'Komentarz nie istnieje' }
  }
 
  revalidatePath('/blog')
  return { success: true }
}

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 ownership
describe('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':

Code
// services/posts-service.ts
import { z } from 'zod'
import { createPostSchema, type CreatePostResult } from '@/domain/posts'
 
type PostsRepository = {
  create(input: {
    title: string
    content: string
    categoryId: string
    authorId: string
    published: boolean
  }): Promise<{ id: string }>
}
 
type PostsServiceDeps = {
  postsRepository: PostsRepository
  cache: { revalidatePostList(): void }
}
 
export function createPostsService({
  postsRepository,
  cache,
}: PostsServiceDeps) {
  return {
    async createPost(
      input: unknown,
      userId: string,
    ): Promise<CreatePostResult> {
      const parsed = createPostSchema.safeParse(input)
      if (!parsed.success) {
        return {
          success: false,
          error: 'Nieprawidłowe dane',
          details: z.flattenError(parsed.error).fieldErrors,
        }
      }
 
      const post = await postsRepository.create({
        ...parsed.data,
        authorId: userId,
      })
 
      cache.revalidatePostList()
      return { success: true, postId: post.id }
    },
  }
}

Server Action zostaje wtedy adapterem:

Code
// actions/create-post.ts
'use server'
 
import { auth } from '@/auth'
import { postsService } from '@/services/posts-service-instance'
import type { CreatePostResult } from '@/domain/posts'
 
export async function createPost(
  formData: FormData,
): Promise<CreatePostResult> {
  const session = await auth()
  if (!session?.user) {
    return { success: false, error: 'Musisz być zalogowany' }
  }
 
  return postsService.createPost(
    {
      title: formData.get('title'),
      content: formData.get('content'),
      categoryId: formData.get('categoryId'),
      published: formData.has('published'),
    },
    session.user.id,
  )
}

Test serwisu nie potrzebuje Next.js, FormData, auth() ani next/cache:

Code
it('should validate input before repository call', async () => {
  const postsRepository = { create: vi.fn() }
  const cache = { revalidatePostList: vi.fn() }
  const service = createPostsService({ postsRepository, cache })
 
  const result = await service.createPost(
    {
      title: '',
      content: 'Za krótko',
      categoryId: 'not-a-uuid',
      published: false,
    },
    'user-1',
  )
 
  expect(result.success).toBe(false)
  expect(postsRepository.create).not.toHaveBeenCalled()
  expect(cache.revalidatePostList).not.toHaveBeenCalled()
})
 
it('should revalidate cache after successful repository write', async () => {
  const postsRepository = {
    create: vi.fn().mockResolvedValue({ id: 'post-1' }),
  }
  const cache = { revalidatePostList: vi.fn() }
  const service = createPostsService({ postsRepository, cache })
 
  const result = await service.createPost(
    {
      title: 'Poprawny tytuł',
      content: 'Treść posta testowego, wystarczająco długa.',
      categoryId: '550e8400-e29b-41d4-a716-446655440000',
      published: false,
    },
    'user-1',
  )
 
  expect(result).toEqual({ success: true, postId: 'post-1' })
  expect(postsRepository.create).toHaveBeenCalledWith(
    expect.objectContaining({ authorId: 'user-1' }),
  )
  expect(cache.revalidatePostList).toHaveBeenCalledTimes(1)
})

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:

PoziomCo testujeCzego nie udaje
Unit testdecyzje w funkcji, walidację, auth, ownership, brak efektów ubocznych po błędzieprawdziwej bazy, constraintów, formularza w przeglądarce
Integration testrepository, transakcje, constrainty, mapowanie danych, prawdziwy adapter bazyzachowania użytkownika i renderu formularza
E2E / Playwrightpełną ścieżkę: wpisanie danych, submit, komunikat, odświeżony widokprecyzyjnej 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.

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

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

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

Testy integracyjne Route Handlers w Next.js

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.

Maciej Sala

Maciej Sala

Founder StriveLab