Dlaczego Server Actions wymagają testów
Anatomia testowalnego Server Action
mają trzy warstwy. Testuj je osobno:
Code
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