Next.js: اختبارات الوحدة والتكامل
آخر تحديث: 2026-08-26
الاختبار ليس اختياريا — إنه "الوسادة الهوائية" لتطبيقات مستوى الإنتاج: لا تحتاجها يوميا، لكنها قد تنقذ حياتك عندما تحتاجها حقا.
1. ما ستتعلمه
- إعداد بيئة اختبار Next.js 16 باستخدام Vitest (vitest.config.ts + طبقة توافق React 17M)
- عرض وتأكيد مكونات الخادم والعميل باستخدام @testing-library/react
- كتابة اختبارات تكامل لـ Server Actions (محاكاة Prisma + محاكاة revalidatePath)
- استخدام مطابقات jest-dom المخصصة (toBeInTheDocument / toHaveTextContent)
- استخدام MSW لاعتراض طلبات API الخارجية وعزل الاعتماديات الشبكية
2. القصة الحقيقية لمهندس Full-Stack
(1) نقطة الألم: عشية الإطلاق، تسبب فراغ واحد في تعطيل صفحة الدفع
تعمل Alice كمهندسة Full-Stack في منصة تجارة إلكترونية تخدم سوق الشرق الأوسط. تعالج المنصة أكثر من 50,000 طلب يوميا، ويحافظ الفريق على دورة إصدار مرتين في الأسبوع.
خلال إطلاق الجمعة الماضية، تسبب تغيير يبدو غير ضار — إضافة فراغ إضافي إلى دالة تنسيق السعر في مكون OrderSummary — في عرض مبالغ الدفع للمستخدمين السعوديين بفاصلة عشرية إضافية. ورغم أن البيانات نفسها لم تكن خاطئة فعليا، تلقت خدمة العملاء أكثر من 200 شكوى، وألغى ثلاثة مستخدمين طلباتهم نتيجة لذلك.
والأسوأ من ذلك:
- لم يقم الفريق بأي اختبارات على هذا المكون.
- تمت الموافقة على الاختبار اليدوي بعد نقرتين فقط في Chrome
- لم يتم اكتشاف هذا الخطأ أثناء مراجعة الكود أيضا
كانت Alice مصممة: كان عليها إنشاء نظام اختبار لمنع حدوث هذا النوع من المشاكل مرة أخرى.
(2) حل Vitest + Testing Library
أدخلت Alice مجموعة اختبارات Vitest في المشروع:
npm install -D vitest @testing-library/react @testing-library/jest-dom @vitejs/plugin-react msw
ثم أنشأت أول اختبار:
import { render, screen } from '@testing-library/react'
import { OrderSummary } from './OrderSummary'
it('displays formatted price correctly', () => {
render(<OrderSummary total={99.99} currency="SAR" />)
expect(screen.getByText(/99\.99/)).toBeInTheDocument()
})
(3) النتائج
| البعد | قبل | بعد |
|---|---|---|
| تغطية الكود | < 5% | > 75% |
| اختبار الانحدار قبل الإصدار | لا يوجد | يعمل تلقائيا في 5 دقائق |
| معدل الأخطاء في الإنتاج | 8–12 شهريا | 0–2 شهريا |
| الثقة في تقديم ميزات جديدة | منخفضة | مرتفعة |
3. إعداد بيئة اختبار Vitest
Vitest هو إطار الاختبار الأصلي لنظام Vite البيئي ومتوافق بشكل أصلي مع Next.js 16 (الذي يستخدم Turbopack و Vite في الخلفية).
graph TB
A[vitest.config.ts] --> B[React Plugin<br/>@vitejs/plugin-react]
A --> C[Test Globals<br/>globals:true]
A --> D[Environment<br/>jsdom]
A --> E[Setup Files<br/>setup-test.ts]
E --> F[jest-dom Matcher]
E --> G[MSW Start]
style A fill:#cce5ff
style F fill:#d4edda
style G fill:#d4edda
| ملف الإعداد | الغرض | الخيارات الرئيسية |
|---|---|---|
vitest.config.ts |
الإعداد الرئيسي للاختبار | environment: 'jsdom' محاكاة المتصفح |
setup-test.ts |
التهيئة العامة | استيراد jest-dom، بدء خدمة MSW |
tsconfig.json |
دعم الأنواع | types: ['vitest/globals'] |
(1) إعداد vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/__tests__/setup-test.ts',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
thresholds: {
statements: 70,
branches: 60,
functions: 70,
lines: 70
}
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})
(2) ملف الإعداد العام
// src/__tests__/setup-test.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(() => {
cleanup()
})
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn()
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams()
}))
vi.mock('react-dom', async () => {
const actual = await vi.importActual('react-dom')
return { ...actual, useFormState: vi.fn() }
})
▶ مثال: التحقق من تشغيل Vitest
// src/__tests__/basic.test.ts
import { render, screen } from '@testing-library/react'
function Hello({ name }: { name: string }) {
return <h1>Hello, {name}!</h1>
}
it('renders hello message', () => {
render(<Hello name="Alice" />)
expect(screen.getByText('Hello, Alice!')).toBeInTheDocument()
})
npx vitest run
✓ src/__tests__/basic.test.ts (1 test) 12ms
Test Files 1 passed (1)
Tests 1 passed (1)
المخرجات:
✓ src/__tests__/basic.test.ts (1 test) 12ms
Test Files 1 passed (1)
Tests 1 passed (1)
▶ مثال: إضافة سكريبت اختبار إلى package.json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
المخرجات:
JSON structure defining three npm scripts: "test" (vitest run), "test:watch" (vitest), and "test:coverage" (vitest run --coverage).
4. اختبار المكونات باستخدام @testing-library/react
الفلسفة الأساسية لـ Testing Library: اختبر ما يراه المستخدمون ويتفاعلون معه، بدلا من تفاصيل التنفيذ.
(1) استعلامات "render" و "screen"
graph LR
A[render المكونات] --> B[screen البحث]
B --> C{نوع الاستعلام}
C --> D[getByText النص]
C --> E[getByRole الدلالي]
C --> F[getByTestId معرف الاختبار]
C --> G[getByPlaceholderText العنصر النائب]
D --> H[التأكيد expect]
E --> H
F --> H
G --> H
| طريقة الاستعلام | السيناريوهات المناسبة | مثال |
|---|---|---|
getByText |
المحتوى النصي | getByText('Submit Order') |
getByRole |
العناصر الدلالية | getByRole('button', { name: /Submit/i }) |
getByPlaceholderText |
تلميح حقل الإدخال | getByPlaceholderText('Enter email') |
getByTestId |
عنصر غير دلالي | getByTestId('order-total') |
queryByText |
تأكيد عدم الوجود | expect(queryByText('Error')).not.toBeInTheDocument() |
(2) اختبار مكونات الخادم (العرض المتزامن)
مكونات الخادم لا تتطلب JavaScript من جانب العميل؛ يمكنك تأكيد مخرجات HTML مباشرة أثناء الاختبار:
// src/app/products/page.tsx
async function ProductsPage() {
const res = await fetch('https://api.example.com/products')
const products = await res.json()
return (
<ul>
{products.map((p: { id: number; name: string; price: number }) => (
<li key={p.id} data-testid="product-item">
{p.name} — ${p.price}
</li>
))}
</ul>
)
}
export default ProductsPage
// src/__tests__/products-page.test.tsx
import { render, screen } from '@testing-library/react'
import ProductsPage from '@/app/products/page'
// محاكاة fetch العامة
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve([
{ id: 1, name: 'iPhone 16', price: 999 },
{ id: 2, name: 'Samsung S26', price: 899 }
])
})
it('renders product list from server', async () => {
const page = await ProductsPage()
render(page)
expect(screen.getByText('iPhone 16')).toBeInTheDocument()
expect(screen.getByText('Samsung S26')).toBeInTheDocument()
expect(screen.getAllByTestId('product-item')).toHaveLength(2)
})
▶ مثال: اختبار مكونات العميل (بما في ذلك تفاعلات المستخدم)
المخرجات:
Renders the Component component UI.
// src/components/Counter.tsx
'use client'
import { useState } from 'react'
export function Counter({ initial = 0 }) {
const [count, setCount] = useState(initial)
return (
<div>
<p data-testid="count">Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<button onClick={() => setCount(c => c - 1)}>Decrement</button>
</div>
)
}
المخرجات:
An interactive component with state management.
Visible text: Count: {count}
// src/__tests__/counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Counter } from '@/components/Counter'
describe('Counter', () => {
it('renders with initial value', () => {
render(<Counter initial={5} />)
expect(screen.getByTestId('count')).toHaveTextContent('Count: 5')
})
it('increments on button click', () => {
render(<Counter initial={0} />)
fireEvent.click(screen.getByText('Increment'))
expect(screen.getByTestId('count')).toHaveTextContent('Count: 1')
})
it('decrements on button click', () => {
render(<Counter initial={10} />)
fireEvent.click(screen.getByText('Decrement'))
expect(screen.getByTestId('count')).toHaveTextContent('Count: 9')
})
})
5. المطابقات المخصصة في jest-dom
توفر jest-dom مطابقات تأكيد DOM دلالية، مما يجعل كود الاختبار أشبه باللغة الطبيعية.
| المطابق | الوظيفة | مثال |
|---|---|---|
toBeInTheDocument() |
العناصر موجودة في DOM | expect(el).toBeInTheDocument() |
toHaveTextContent(text) |
مطابقة المحتوى النصي | expect(el).toHaveTextContent('Hello') |
toBeVisible() |
العنصر مرئي | expect(el).toBeVisible() |
toBeDisabled() |
الزر معطل | expect(btn).toBeDisabled() |
toHaveClass(cls) |
مطابقة فئة CSS | expect(el).toHaveClass('active') |
toHaveAttribute(attr) |
مطابقة السمة | expect(input).toHaveAttribute('type', 'email') |
toHaveValue(val) |
مطابقة قيمة النموذج | expect(input).toHaveValue('test@example.com') |
▶ مثال: اختبار التحقق من النموذج
المخرجات:
Renders the Component component UI.
import { render, screen, fireEvent } from '@testing-library/react'
import { LoginForm } from '@/components/LoginForm'
describe('LoginForm validation', () => {
it('shows error on empty email', () => {
render(<LoginForm />)
fireEvent.click(screen.getByRole('button', { name: /Log In/i }))
expect(screen.getByText(/Please enter your email address/i)).toBeInTheDocument()
expect(screen.getByText(/Please enter your email address/i)).toBeVisible()
})
it('disables submit while loading', () => {
render(<LoginForm />)
fireEvent.change(screen.getByPlaceholderText('Enter your email address'), {
target: { value: 'alice@example.com' }
})
fireEvent.click(screen.getByRole('button', { name: /Log In/i }))
expect(screen.getByRole('button', { name: /Logging in.../i })).toBeDisabled()
})
it('clears error after valid input', () => {
render(<LoginForm />)
fireEvent.click(screen.getByRole('button', { name: /Log In/i }))
expect(screen.getByText(/Please enter your email address/i)).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('Enter your email address'), {
target: { value: 'alice@example.com' }
})
expect(screen.queryByText(/Please enter your email address/i)).not.toBeInTheDocument()
})
})
المخرجات:
Renders the ▶ Example: Form Validation Test component UI as described in the section.
6. اختبار Server Actions (محاكاة Prisma + revalidatePath)
تحتاج Server Actions إلى محاكاة عمليات قاعدة البيانات ودوال إبطال ذاكرة التخزين المؤقت.
graph TB
A[اختبار Server Action] --> B[محاكاة Prisma Client]
A --> C[محاكاة revalidatePath]
A --> D[محاكاة redirect]
B --> E[إرجاع بيانات محاكاة]
C --> F[التحقق من عدد مرات الاستدعاء/المعلمات]
D --> G[التحقق من مسار إعادة التوجيه]
style A fill:#cce5ff
style B fill:#d4edda
style C fill:#fff3cd
(1) أداة محاكاة Prisma
// src/__tests__/utils/mock-prisma.ts
import { vi } from 'vitest'
export function createMockPrisma() {
return {
task: {
findMany: vi.fn().mockResolvedValue([
{ id: '1', title: 'Test Task', status: 'TODO', projectId: 'p1' }
]),
create: vi.fn().mockImplementation(({ data }) => Promise.resolve({
id: 'new-id',
...data,
createdAt: new Date()
})),
update: vi.fn().mockImplementation(({ data }) => Promise.resolve(data)),
delete: vi.fn().mockResolvedValue({ id: 'deleted-id' })
},
project: {
findUnique: vi.fn().mockResolvedValue({
id: 'p1',
name: 'Test Project',
tasks: []
})
},
$transaction: vi.fn().mockImplementation((cb) => cb(createMockPrisma()))
} as any
}
(2) مثال على اختبار Server Action
// src/actions/task.ts
'use server'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const taskSchema = z.object({
title: z.string().min(1, 'The title cannot be left blank.'),
projectId: z.string().min(1),
status: z.enum(['TODO', 'IN_PROGRESS', 'DONE'])
})
export async function createTask(formData: FormData) {
const data = Object.fromEntries(formData)
const parsed = taskSchema.safeParse(data)
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors }
}
await prisma.task.create({ data: parsed.data })
revalidatePath(`/projects/${parsed.data.projectId}`)
redirect(`/projects/${parsed.data.projectId}`)
}
▶ مثال: اختبار Server Action كامل
المخرجات:
Validates input with Zod schema before processing.
Writes to the database and refreshes the affected page cache.
Returns error object if validation or processing fails.
// src/__tests__/actions/task.test.ts
import { createTask } from '@/actions/task'
import { createMockPrisma } from '../utils/mock-prisma'
import { vi, describe, it, expect, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({ prisma: createMockPrisma() }))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/navigation', () => ({ redirect: vi.fn() }))
describe('createTask', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates a task successfully', async () => {
const formData = new FormData()
formData.append('title', 'Writing Test Documentation')
formData.append('projectId', 'p1')
formData.append('status', 'TODO')
const result = await createTask(formData)
expect(result).toBeUndefined()
})
it('returns validation error for empty title', async () => {
const formData = new FormData()
formData.append('title', '')
formData.append('projectId', 'p1')
formData.append('status', 'TODO')
const result = await createTask(formData)
expect(result).toHaveProperty('error')
expect(result.error.title).toBeDefined()
})
it('calls revalidatePath after creation', async () => {
const formData = new FormData()
formData.append('title', 'New Task')
formData.append('projectId', 'p1')
formData.append('status', 'TODO')
await createTask(formData)
const { revalidatePath } = await import('next/cache')
expect(revalidatePath).toHaveBeenCalledWith('/projects/p1')
})
})
المخرجات:
Creates a new database record.
7. محاكاة API جانب الخادم باستخدام MSW
يعترض MSW (Mock Service Worker) طلبات الشبكة، مما يتيح لك اختبار منطق استرجاع البيانات دون الحاجة إلى تشغيل الخادم الخلفي الفعلي.
graph LR
A[المكون يبدأ fetch] --> B[MSW Service Worker]
B --> C{مطابقة الطلب}
C -->|مطابقة المعالج| D[إرجاع بيانات محاكاة]
C -->|عدم تطابق| E[التمرير إلى الشبكة الفعلية]
style B fill:#cce5ff
style D fill:#d4edda
| المفهوم | الوصف | مثال |
|---|---|---|
| Handler | معالج الطلب | http.get('/api/products', resolver) |
| Resolver | إرجاع استجابة محاكاة | return HttpResponse.json([...]) |
| Server | خادم محاكاة Node.js | setupServer(...handlers) |
| Browser | MSW في المتصفح | setupWorker(...handlers) |
(1) تعريف معالجات المحاكاة
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
const API_BASE = 'https://api.example.com'
export const handlers = [
// GET /api/products
http.get(`${API_BASE}/products`, () => {
return HttpResponse.json([
{ id: 1, name: 'iPhone 16', price: 999, category: 'Electronics' },
{ id: 2, name: 'Samsung S26', price: 899, category: 'Electronics' }
])
}),
// POST /api/orders
http.post(`${API_BASE}/orders`, async ({ request }) => {
const body = await request.json()
return HttpResponse.json({
id: 'order-123',
...body as any,
status: 'confirmed',
createdAt: new Date().toISOString()
})
}),
// GET /api/users/:id
http.get(`${API_BASE}/users/:id`, ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Alice Wang',
email: 'alice@example.com',
role: 'admin'
})
}),
// خطأ 500 لاختبار حدود الأخطاء
http.get(`${API_BASE}/errors/internal`, () => {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
)
})
]
(2) الدمج في إعداد الاختبار
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// src/__tests__/setup-test.ts (النسخة الكاملة)
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, afterAll, beforeAll, vi } from 'vitest'
import { server } from '@/mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
afterEach(() => { cleanup(); server.resetHandlers() })
afterAll(() => server.close())
// محاكاة next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(), replace: vi.fn(),
refresh: vi.fn(), back: vi.fn(), forward: vi.fn()
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams()
}))
▶ مثال: اختبار اعتراض API باستخدام MSW
المخرجات:
TypeScript module executes successfully.
// src/__tests__/api-integration.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from '@/mocks/server'
import ProductsPage from '@/app/products/page'
describe('ProductsPage with MSW', () => {
it('renders products from mocked API', async () => {
const page = await ProductsPage()
render(page)
expect(screen.getByText('iPhone 16')).toBeInTheDocument()
expect(screen.getByText('Samsung S26')).toBeInTheDocument()
})
it('handles empty product list', async () => {
server.use(
http.get('https://api.example.com/products', () => {
return HttpResponse.json([])
})
)
const page = await ProductsPage()
render(page)
await waitFor(() => {
expect(screen.queryByTestId('product-item')).not.toBeInTheDocument()
})
})
it('shows error on API failure', async () => {
server.use(
http.get('https://api.example.com/products', () => {
return HttpResponse.json(
{ error: 'Service unavailable' },
{ status: 503 }
)
})
)
await expect(ProductsPage()).rejects.toThrow()
})
})
المخرجات:
Renders the ▶ Example: Testing the MSW Interception API component UI as described in the section.
8. مثال كامل: اختبار Full-Stack لتطبيق Todo
// src/__tests__/todo-comprehensive.test.ts
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from '@/mocks/server'
import { createMockPrisma } from './utils/mock-prisma'
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================
// اختبار شامل: مكونات تطبيق Todo + Action + API
// ============================================
// --- 1. محاكاة الاعتماديات ---
vi.mock('@/lib/prisma', () => ({ prisma: createMockPrisma() }))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/navigation', () => ({
redirect: vi.fn(),
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() })
}))
// --- 2. مكونات Todo ---
function TodoItem({ id, title, done, onToggle }: {
id: string; title: string; done: boolean
onToggle: (id: string) => void
}) {
return (
<div data-testid="todo-item">
<span style={{ textDecoration: done ? 'line-through' : 'none' }}>
{title}
</span>
<button onClick={() => onToggle(id)}>
{done ? 'Undo' : 'Done'}
</button>
</div>
)
}
function TodoList({ todos }: { todos: Array<{ id: string; title: string; done: boolean }> }) {
return (
<div>
{todos.map(t => (
<TodoItem key={t.id} {...t} onToggle={(id) => {
const idx = todos.findIndex(t => t.id === id)
todos[idx].done = !todos[idx].done
}} />
))}
</div>
)
}
// --- 3. حالات الاختبار ---
describe('Todo App', () => {
const mockTodos = [
{ id: '1', title: 'Learn Next.js testing', done: false },
{ id: '2', title: 'Write unit tests', done: true },
{ id: '3', title: 'Set up CI pipeline', done: false }
]
it('renders all todos', () => {
render(<TodoList todos={mockTodos} />)
expect(screen.getAllByTestId('todo-item')).toHaveLength(3)
expect(screen.getByText('Learn Next.js testing')).toBeInTheDocument()
})
it('shows strikethrough for done items', () => {
render(<TodoList todos={mockTodos} />)
const doneItem = screen.getByText('Write unit tests')
expect(doneItem).toHaveStyle('text-decoration: line-through')
})
it('toggles todo on button click', () => {
render(<TodoList todos={mockTodos} />)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
expect(buttons[0]).toHaveTextContent('Undo')
})
it('has correct button labels', () => {
render(<TodoList todos={mockTodos} />)
const buttons = screen.getAllByRole('button')
expect(buttons[0]).toHaveTextContent('Done')
expect(buttons[1]).toHaveTextContent('Undo')
})
})
// --- 4. اختبار MSW API ---
describe('Todo API Mock', () => {
it('fetch todos from mocked API', async () => {
server.use(
http.get('https://api.example.com/todos', () => {
return HttpResponse.json(mockTodos)
})
)
const res = await fetch('https://api.example.com/todos')
const data = await res.json()
expect(data).toHaveLength(3)
expect(data[0].title).toBe('Learn Next.js testing')
})
it('handles API error gracefully', async () => {
server.use(
http.get('https://api.example.com/todos', () => {
return HttpResponse.json(null, { status: 500 })
})
)
const res = await fetch('https://api.example.com/todos')
expect(res.status).toBe(500)
})
})
✓ src/__tests__/todo-comprehensive.test.ts (7 tests) 45ms
Test Files 1 passed (1)
Tests 7 passed (7)
❓ أسئلة شائعة
expect/describe/it). يستخدم Next.js 16 Turbopack (جزء من نظام Vite البيئي) في الخلفية، لذا فإن Vitest هو الخيار الأكثر طبيعية.cookies() أو headers()؟global.fetch بسيطة، لكنها لا تستطيع التعامل مع مطابقة الطلبات المعقدة أو تسلسل الاستجابة أو سيناريوهات الأخطاء.useActionState؟useActionState على useFormState في React 19، لذا تحتاج إلى محاكاة useFormState في react-dom أثناء الاختبار. نوصي باستخدام fireEvent.submit لتشغيل إرسال النموذج، ثم تأكيد حالة واجهة المستخدم بعد الإرسال (رسائل النجاح/الخطأ).fireEvent و userEvent في Testing Library؟fireEvent بتشغيل أحداث DOM مباشرة (مثل النقر)، بينما يحاكي userEvent تفاعلات مستخدم أكثر واقعية (مثل إدخال لوحة المفاتيح وإدارة التركيز). نوصي باستخدام userEvent لاختبارات الإنتاج (لأنه يحاكي تجربة المستخدم بشكل أقرب) و fireEvent لاختبارات الوحدة (لأنه أخف وزنا).📖 ملخص
- Vitest هو إطار الاختبار المفضل لـ Next.js 16. عند إعداد
vitest.config.ts، يجب تحديدenvironment: 'jsdom'وإضافة React plugin. - توفر
@testing-library/reactواجهتيrenderوscreen، مع التركيز على سلوك المستخدم المرئي بدلا من تفاصيل التنفيذ - مطابقات jest-dom (
toBeInTheDocument،toHaveTextContent،toBeVisible) تجعل تأكيدات الاختبار أكثر معنى - يتطلب اختبار Server Actions محاكاة Prisma Client و
revalidatePathوredirectللتحقق من كتابة البيانات وإبطال ذاكرة التخزين المؤقت - يعترض MSW طلبات الشبكة على مستوى Service Worker، مما يجعله مناسبا لعزل واجهات API الخارجية في اختبارات التكامل
setup-test.tsهو ملف رئيسي في البنية التحتية للاختبار يدير المحاكاة والتهيئة العامة بشكل مركزي
📝 تمارين
-
تمرين أساسي (⭐): أنشئ
vitest.config.tsيتضمن React plugin وبيئة jsdom ومسارا مستعارا لـ@/، ثم اكتب أبسط اختبارrender(Hello)للتحقق من أن مطابق Jest-DOM يعمل بشكل صحيح. -
تمرين متقدم (⭐⭐): اكتب اختبارات شاملة لـ Server Actions في مشروعك (مثل
createUserأوsubmitOrder): قم بمحاكاة طريقةcreateفي Prisma، وتحقق من استدعاءrevalidatePath، واختبر استجابة الخطأ عند فشل التحقق من Zod. -
تحدّ (⭐⭐⭐): استخدم MSW لبناء ثلاثة معالجات محاكاة (GET /api/tasks، POST /api/tasks، DELETE /api/tasks/:id)، ثم اكتب 8 حالات اختبار على الأقل لمكون TaskList، تتضمن وظائف استرجاع البيانات وإنشائها وحذفها (بما في ذلك التحميل الأولي والقائمة الفارغة ومعالجة الأخطاء).