اختبار تطبيقات Next.js
فهم الاختبار في Next.js
الاختبار ضروري لبناء تطبيقات موثوقة وقابلة للصيانة. يمكن اختبار تطبيقات Next.js على مستويات متعددة: اختبارات الوحدة للمكونات والوظائف الفردية، واختبارات التكامل لسير عمل الميزات، والاختبارات الشاملة لرحلات المستخدم الكاملة. يغطي هذا الدرس أدوات واستراتيجيات الاختبار الأكثر شيوعاً لتطبيقات Next.js.
نظرة عامة على مجموعة الاختبار
تتضمن مجموعة الاختبار الموصى بها لـ Next.js:
- Jest: إطار اختبار JavaScript لاختبارات الوحدة والتكامل
- React Testing Library: أدوات الاختبار لمكونات React
- Playwright: إطار اختبار شامل لأتمتة المتصفح
- MSW (Mock Service Worker): مكتبة محاكاة API للاختبار
إعداد Jest وReact Testing Library
قم بتثبيت التبعيات المطلوبة:
# تثبيت تبعيات الاختبار
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
# لمشاريع TypeScript
npm install -D @types/jest
تكوين Jest
قم بإنشاء ملف تكوين Jest:
// jest.config.mjs
import nextJest from 'next/jest.js';
const createJestConfig = nextJest({
// توفير المسار إلى تطبيق Next.js لتحميل next.config.js وملفات .env
dir: './',
});
// تكوين Jest المخصص
const config = {
coverageProvider: 'v8',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
// معالجة أسماء الوحدات المستعارة
'^@/(.*)$': '<rootDir>/$1',
},
testMatch: [
'**/__tests__/**/*.[jt]s?(x)',
'**/?(*.)+(spec|test).[jt]s?(x)',
],
collectCoverageFrom: [
'app/**/*.{js,jsx,ts,tsx}',
'components/**/*.{js,jsx,ts,tsx}',
'lib/**/*.{js,jsx,ts,tsx}',
'!**/*.d.ts',
'!**/node_modules/**',
'!**/.next/**',
],
};
export default createJestConfig(config);
ملف إعداد Jest
قم بإنشاء ملف إعداد للمطابقات المخصصة والتكوينات العامة:
// jest.setup.js
import '@testing-library/jest-dom';
// محاكاة موجه Next.js
jest.mock('next/navigation', () => ({
useRouter() {
return {
push: jest.fn(),
replace: jest.fn(),
prefetch: jest.fn(),
back: jest.fn(),
pathname: '/',
query: {},
asPath: '/',
};
},
usePathname() {
return '/';
},
useSearchParams() {
return new URLSearchParams();
},
}));
اختبار مكونات React
اكتب اختبارات الوحدة لمكوناتك:
// components/Button.tsx
interface ButtonProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
variant?: 'primary' | 'secondary';
}
export default function Button({
children,
onClick,
disabled = false,
variant = 'primary',
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{children}
</button>
);
}
// components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
describe('Button Component', () => {
it('renders button with children', () => {
render(<Button>انقر هنا</Button>);
expect(screen.getByText('انقر هنا')).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>انقر هنا</Button>);
fireEvent.click(screen.getByText('انقر هنا'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>معطل</Button>);
expect(screen.getByText('معطل')).toBeDisabled();
});
it('applies correct variant class', () => {
const { rerender } = render(<Button variant="primary">أساسي</Button>);
expect(screen.getByText('أساسي')).toHaveClass('btn-primary');
rerender(<Button variant="secondary">ثانوي</Button>);
expect(screen.getByText('ثانوي')).toHaveClass('btn-secondary');
});
});
اختبار تفاعلات المستخدم
اختبر تفاعلات المستخدم المعقدة باستخدام user-event:
// components/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
it('submits form with email and password', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
// الكتابة في حقل البريد الإلكتروني
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
// الكتابة في حقل كلمة المرور
await user.type(screen.getByLabelText(/password/i), 'password123');
// النقر على زر الإرسال
await user.click(screen.getByRole('button', { name: /sign in/i }));
// الانتظار لإرسال النموذج
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
it('shows validation errors for invalid inputs', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
// محاولة إرسال نموذج فارغ
await user.click(screen.getByRole('button', { name: /sign in/i }));
// التحقق من رسائل الخطأ
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
});
});
محاكاة استدعاءات API باستخدام MSW
استخدم Mock Service Worker لمحاكاة استجابات API:
# تثبيت MSW
npm install -D msw
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
// محاكاة طلب GET
http.get('https://api.example.com/products', () => {
return HttpResponse.json([
{ id: 1, name: 'منتج 1', price: 99.99 },
{ id: 2, name: 'منتج 2', price: 149.99 },
]);
}),
// محاكاة طلب POST
http.post('https://api.example.com/login', async ({ request }) => {
const { email, password } = await request.json();
if (email === 'test@example.com' && password === 'password123') {
return HttpResponse.json(
{ token: 'fake-jwt-token', user: { email } },
{ status: 200 }
);
}
return HttpResponse.json(
{ error: 'بيانات اعتماد غير صالحة' },
{ status: 401 }
);
}),
// محاكاة استجابة خطأ
http.get('https://api.example.com/error', () => {
return HttpResponse.json(
{ error: 'خطأ في الخادم' },
{ status: 500 }
);
}),
];
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// jest.setup.js (أضف إلى الملف الموجود)
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
اختبار مكونات الخادم
اختبر مكونات الخادم التي تجلب البيانات:
// app/products/page.test.tsx
import { render, screen } from '@testing-library/react';
import ProductsPage from './page';
import { server } from '@/mocks/server';
import { http, HttpResponse } from 'msw';
describe('ProductsPage', () => {
it('displays products from API', async () => {
// عرض مكون الخادم غير المتزامن
const ProductsPageResolved = await ProductsPage();
render(ProductsPageResolved);
expect(screen.getByText('منتج 1')).toBeInTheDocument();
expect(screen.getByText('منتج 2')).toBeInTheDocument();
expect(screen.getByText('$99.99')).toBeInTheDocument();
});
it('displays error message when API fails', async () => {
// تجاوز المعالج الافتراضي لهذا الاختبار
server.use(
http.get('https://api.example.com/products', () => {
return HttpResponse.json(
{ error: 'فشل في الجلب' },
{ status: 500 }
);
})
);
const ProductsPageResolved = await ProductsPage();
render(ProductsPageResolved);
expect(screen.getByText(/error loading products/i)).toBeInTheDocument();
});
});
اختبار مسارات API
اختبر مسارات API ومعالجات المسار في Next.js:
// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const category = searchParams.get('category');
// جلب المنتجات (مبسط)
const products = await fetchProducts(category);
return NextResponse.json(products);
}
// app/api/products/route.test.ts
import { GET } from './route';
import { NextRequest } from 'next/server';
describe('/api/products', () => {
it('returns all products when no category specified', async () => {
const request = new NextRequest('http://localhost:3000/api/products');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBeGreaterThan(0);
});
it('filters products by category', async () => {
const request = new NextRequest(
'http://localhost:3000/api/products?category=electronics'
);
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.every((p: any) => p.category === 'electronics')).toBe(true);
});
});
إعداد Playwright للاختبار الشامل
قم بتثبيت وتكوين Playwright:
# تثبيت Playwright
npm init playwright@latest
# هذا ينشئ playwright.config.ts ويثبت المتصفحات
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
كتابة اختبارات شاملة
قم بإنشاء سيناريوهات اختبار شاملة:
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test('successful login redirects to dashboard', async ({ page }) => {
// الانتقال إلى صفحة تسجيل الدخول
await page.goto('/login');
// ملء نموذج تسجيل الدخول
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
// النقر على زر تسجيل الدخول
await page.click('button[type="submit"]');
// الانتظار للتنقل
await page.waitForURL('/dashboard');
// التحقق من أننا في لوحة التحكم
expect(page.url()).toContain('/dashboard');
await expect(page.locator('h1')).toHaveText('لوحة التحكم');
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'wrong@example.com');
await page.fill('input[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
// التحقق من رسالة الخطأ
await expect(page.locator('.error-message')).toHaveText(
'بيانات اعتماد غير صالحة'
);
});
});
اختبار سير عمل سلة التسوق
اختبر رحلات المستخدم المعقدة:
// e2e/shopping-cart.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Shopping Cart', () => {
test('add product to cart and checkout', async ({ page }) => {
// تصفح المنتجات
await page.goto('/products');
// النقر على المنتج الأول
await page.click('.product-card:first-child');
// التحقق من صفحة المنتج
await expect(page.locator('h1')).toBeVisible();
// إضافة إلى السلة
await page.click('button:has-text("أضف إلى السلة")');
// التحقق من تحديث شارة السلة
await expect(page.locator('.cart-badge')).toHaveText('1');
// الذهاب إلى السلة
await page.click('a[href="/cart"]');
// التحقق من صفحة السلة
await expect(page.locator('.cart-item')).toHaveCount(1);
// المتابعة إلى الدفع
await page.click('button:has-text("الدفع")');
// ملء نموذج الدفع
await page.fill('input[name="name"]', 'محمد أحمد');
await page.fill('input[name="address"]', '123 شارع الرئيسي');
await page.fill('input[name="city"]', 'الرياض');
await page.fill('input[name="zip"]', '12345');
// إكمال الطلب
await page.click('button:has-text("تأكيد الطلب")');
// التحقق من صفحة النجاح
await page.waitForURL('/order-success');
await expect(page.locator('h1')).toHaveText('تم تأكيد الطلب');
});
});
الاختبار بإطارات عرض مختلفة
اختبر السلوك المتجاوب:
// e2e/responsive.spec.ts
import { test, expect, devices } from '@playwright/test';
test.describe('Responsive Navigation', () => {
test('shows mobile menu on small screens', async ({ page }) => {
// تعيين إطار عرض الهاتف المحمول
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
// يجب أن يكون زر القائمة المحمولة مرئياً
await expect(page.locator('.mobile-menu-button')).toBeVisible();
// يجب أن تكون قائمة سطح المكتب مخفية
await expect(page.locator('.desktop-menu')).not.toBeVisible();
// النقر على القائمة المحمولة
await page.click('.mobile-menu-button');
// فتح درج القائمة
await expect(page.locator('.mobile-menu-drawer')).toBeVisible();
});
test('shows desktop menu on large screens', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto('/');
// يجب أن تكون قائمة سطح المكتب مرئية
await expect(page.locator('.desktop-menu')).toBeVisible();
// يجب أن يكون زر القائمة المحمولة مخفياً
await expect(page.locator('.mobile-menu-button')).not.toBeVisible();
});
});
اختبار اللقطة
استخدم اختبارات اللقطة للاختبار المرئي للانحدار:
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Visual Regression Tests', () => {
test('homepage matches snapshot', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png');
});
test('product page matches snapshot', async ({ page }) => {
await page.goto('/products/1');
// انتظر تحميل الصور
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('product-page.png');
});
});
تشغيل الاختبارات
أضف نصوص الاختبار إلى package.json:
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug"
}
}
# تشغيل اختبارات الوحدة
npm test
# تشغيل الاختبارات في وضع المراقبة
npm run test:watch
# إنشاء تقرير التغطية
npm run test:coverage
# تشغيل اختبارات شاملة
npm run test:e2e
# تشغيل اختبارات شاملة مع واجهة المستخدم
npm run test:e2e:ui
تمرين تطبيقي
المهمة: إنشاء مجموعة اختبار شاملة لتطبيق قائمة مهام:
- كتابة اختبارات الوحدة لمكونات TodoItem وTodoList
- اختبار إضافة وإكمال وحذف المهام باستخدام user-event
- محاكاة استدعاءات API لجلب وحفظ المهام باستخدام MSW
- اختبار معالج المسار POST /api/todos
- كتابة اختبارات شاملة لسير عمل المهام الكامل
- اختبار تصفية المهام حسب الحالة (الكل، النشط، المكتمل)
- إضافة اختبارات اللقطة لواجهة المستخدم لقائمة المهام
- تحقيق تغطية كود لا تقل عن 80%
إضافي: إعداد GitHub Actions لتشغيل الاختبارات تلقائياً على كل طلب سحب.
الملخص
النقاط الرئيسية حول اختبار تطبيقات Next.js:
- استخدم Jest وReact Testing Library لاختبارات الوحدة والتكامل
- محاكاة استدعاءات API باستخدام MSW لتجنب الوصول إلى نقاط نهاية حقيقية
- اختبر تفاعلات المستخدم باستخدام @testing-library/user-event
- استخدم Playwright للاختبار الشامل للمتصفح
- اختبر مسارات API بشكل منفصل عن المكونات
- تنفيذ اختبار الانحدار المرئي باستخدام لقطات الشاشة
- تشغيل الاختبارات في خطوط أنابيب CI/CD لضمان الجودة المستمر
- اهدف إلى تغطية كود عالية ولكن ركز على الاختبارات المفيدة