مقدمة في اختبار مكونات React
يضمن اختبار مكونات React أن واجهة المستخدم تتصرف بشكل صحيح في ظروف مختلفة. React Testing Library (RTL) هي الحل الأكثر شعبية للاختبار لأنها تشجع على اختبار المكونات بالطريقة التي يتفاعل بها المستخدمون معها، بدلاً من اختبار تفاصيل التنفيذ.
لماذا React Testing Library؟
تتبع React Testing Library هذه المبادئ التوجيهية:
- الاختبار المتمحور حول المستخدم: اختبار المكونات كما يتفاعل معها المستخدمون
- مستقل عن التنفيذ: التركيز على ما تفعله المكونات، وليس كيف تفعله
- يمكن الوصول إليه افتراضياً: يشجع على كتابة مكونات يمكن الوصول إليها
- اختبارات قابلة للصيانة: الاختبارات أقل عرضة للكسر مع إعادة الهيكلة
ملاحظة: تعمل React Testing Library مع أي إطار اختبار (Jest، Vitest، إلخ). معظم الأمثلة تستخدم Jest، لكن المفاهيم تنطبق عالمياً.
إعداد React Testing Library
تثبيت الحزم الضرورية:
# لـ Create React App (مدرج بالفعل)
npm test
# للإعداد المخصص
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
# لـ TypeScript
npm install --save-dev @types/jest
تكوين Jest بملف إعداد (setupTests.js):
// src/setupTests.js
import '@testing-library/jest-dom';
// مطابقات مخصصة لتأكيدات أفضل
// expect(element).toBeInTheDocument()
// expect(element).toHaveTextContent()
// expect(element).toBeVisible()
عرض المكون الأساسي
دالة render() هي أساس React Testing Library. تعرض المكون في DOM افتراضي للاختبار:
import { render, screen } from '@testing-library/react';
import Button from './Button';
test('renders button with text', () => {
render(<Button>Click Me</Button>);
// استعلام عن المكون المعروض
const button = screen.getByRole('button', { name: /click me/i });
// التأكد من وجوده
expect(button).toBeInTheDocument();
});
نصيحة: استخدم screen.debug() لطباعة هيكل DOM الحالي في وحدة التحكم. هذا مفيد للغاية لتصحيح الاختبارات.
الاستعلامات: العثور على العناصر
توفر React Testing Library عدة طرق استعلام. اختر بناءً على الأولوية:
1. الاستعلامات التي يمكن الوصول إليها (المفضلة)
// getByRole - الأكثر تفضيلاً، يختبر إمكانية الوصول
const button = screen.getByRole('button', { name: /submit/i });
const heading = screen.getByRole('heading', { level: 1 });
const textbox = screen.getByRole('textbox', { name: /username/i });
// getByLabelText - رائع لمدخلات النماذج
const input = screen.getByLabelText(/email address/i);
// getByPlaceholderText - عندما لا يكون التسمية مرئية
const search = screen.getByPlaceholderText(/search.../i);
// getByText - البحث عن طريق محتوى النص المرئي
const error = screen.getByText(/invalid email/i);
2. الاستعلامات الدلالية
// getByAltText - الصور مع نص بديل
const logo = screen.getByAltText(/company logo/i);
// getByTitle - العناصر مع سمة العنوان
const tooltip = screen.getByTitle(/help/i);
3. استعلامات معرف الاختبار (الملاذ الأخير)
// getByTestId - فقط عندما لا تعمل الاستعلامات الأخرى
const custom = screen.getByTestId('custom-element');
// في المكون:
<div data-testid="custom-element">Content</div>
تحذير: تجنب getByTestId عندما يكون ذلك ممكناً. لا يختبر كيفية تفاعل المستخدمين مع تطبيقك ويربط الاختبارات بالتنفيذ.
متغيرات الاستعلام
كل استعلام يأتي في ثلاثة متغيرات:
// getBy* - يطرح خطأ إذا لم يتم العثور عليه (متزامن)
const button = screen.getByRole('button');
// queryBy* - يعيد null إذا لم يتم العثور عليه (للتأكيد على عدم الوجود)
const error = screen.queryByText(/error/i);
expect(error).not.toBeInTheDocument();
// findBy* - يعيد وعد، ينتظر العنصر (غير متزامن)
const async = await screen.findByText(/loading complete/i);
اختبار تفاعلات المستخدم
استخدم @testing-library/user-event لمحاكاة إجراءات المستخدم:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('submits form with user credentials', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
// الكتابة في المدخلات
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
await user.type(emailInput, 'user@example.com');
await user.type(passwordInput, 'password123');
// النقر على زر الإرسال
const submitButton = screen.getByRole('button', { name: /login/i });
await user.click(submitButton);
// التأكد من إرسال النموذج
expect(onSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123'
});
});
ملاحظة: استخدم دائماً await مع إجراءات user-event. تعيد وعوداً لمحاكاة سلوك المستخدم غير المتزامن بشكل صحيح.
أحداث المستخدم الشائعة
const user = userEvent.setup();
// الكتابة
await user.type(input, 'Hello World');
await user.clear(input); // مسح قيمة المدخل
// النقر
await user.click(button);
await user.dblClick(element);
await user.tripleClick(element);
// التحويم
await user.hover(element);
await user.unhover(element);
// لوحة المفاتيح
await user.keyboard('{Enter}');
await user.keyboard('{Shift>}A{/Shift}'); // Shift+A
// التحديد
await user.selectOptions(select, ['option1', 'option2']);
await user.deselectOptions(select, 'option1');
// رفع الملف
const file = new File(['content'], 'test.png', { type: 'image/png' });
await user.upload(fileInput, file);
اختبار السلوك غير المتزامن
استخدم waitFor واستعلامات findBy* للعمليات غير المتزامنة:
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
test('loads and displays user data', async () => {
render(<UserProfile userId="123" />);
// يظهر في البداية حالة التحميل
expect(screen.getByText(/loading.../i)).toBeInTheDocument();
// انتظر ظهور بيانات المستخدم (findBy يعيد وعد)
const userName = await screen.findByText(/john doe/i);
expect(userName).toBeInTheDocument();
// يجب أن يختفي مؤشر التحميل
expect(screen.queryByText(/loading.../i)).not.toBeInTheDocument();
});
test('handles API errors', async () => {
// محاكاة API لإرجاع خطأ
jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('API Error'));
render(<UserProfile userId="123" />);
// انتظر رسالة الخطأ
await waitFor(() => {
expect(screen.getByText(/failed to load user/i)).toBeInTheDocument();
});
});
نصيحة: فضّل استعلامات findBy* على getBy* + waitFor عندما يكون ذلك ممكناً. إنها أكثر إيجازاً وقابلية للقراءة.
اختبار إمكانية الوصول
تشجع React Testing Library على المكونات التي يمكن الوصول إليها من خلال إعطاء الأولوية للاستعلامات القائمة على الأدوار:
test('form is accessible', () => {
render(<ContactForm />);
// يجب أن تحتوي جميع المدخلات على تسميات
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/message/i)).toBeInTheDocument();
// يجب أن يكون للزر اسم يمكن الوصول إليه
expect(screen.getByRole('button', { name: /send/i })).toBeInTheDocument();
// التحقق من سمات ARIA
const emailInput = screen.getByLabelText(/email/i);
expect(emailInput).toHaveAttribute('type', 'email');
expect(emailInput).toHaveAttribute('required');
});
test('error messages are announced to screen readers', async () => {
const user = userEvent.setup();
render(<ContactForm />);
const submitButton = screen.getByRole('button', { name: /send/i });
await user.click(submitButton);
// يجب أن يحتوي الخطأ على role="alert" لقارئات الشاشة
const error = screen.getByRole('alert');
expect(error).toHaveTextContent(/please fill in all fields/i);
});
محاكاة الخصائص والدوال
استخدم محاكيات Jest لاختبار سلوك المكون بشكل منفصل:
test('calls onDelete when delete button is clicked', async () => {
const user = userEvent.setup();
const onDelete = jest.fn();
render(<TaskItem task={{ id: 1, title: 'Test' }} onDelete={onDelete} />);
const deleteButton = screen.getByRole('button', { name: /delete/i });
await user.click(deleteButton);
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onDelete).toHaveBeenCalledWith(1);
});
test('disables button when loading', () => {
render(<SubmitButton loading={true} />);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(button).toHaveTextContent(/loading.../i);
});
اختبار العرض الشرطي
test('shows success message after submission', async () => {
const user = userEvent.setup();
render(<NewsletterForm />);
// في البداية لا توجد رسالة نجاح
expect(screen.queryByText(/thank you/i)).not.toBeInTheDocument();
// إرسال النموذج
const emailInput = screen.getByLabelText(/email/i);
await user.type(emailInput, 'test@example.com');
await user.click(screen.getByRole('button', { name: /subscribe/i }));
// تظهر رسالة النجاح
expect(await screen.findByText(/thank you/i)).toBeInTheDocument();
// النموذج مخفي
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
});
تصحيح الاختبارات
توفر React Testing Library أدوات تصحيح مفيدة:
import { render, screen } from '@testing-library/react';
test('debug example', () => {
render(<MyComponent />);
// طباعة DOM بالكامل
screen.debug();
// طباعة عنصر محدد
const button = screen.getByRole('button');
screen.debug(button);
// الطباعة مع خيارات
screen.debug(undefined, 20000); // زيادة حد الأحرف
// تسجيل الأدوار التي يمكن الوصول إليها
screen.logTestingPlaygroundURL();
});
تمرين:
أنشئ مكون SearchFilter واكتب اختبارات شاملة:
- اختبر أن مدخل البحث معروض ويمكن الوصول إليه
- اختبر أن الكتابة تحدث قيمة المدخل
- اختبر أن النقر على زر البحث يستدعي استدعاء
onSearch مع الاستعلام
- اختبر أن الضغط على Enter يؤدي أيضاً إلى تشغيل البحث
- اختبر أن زر المسح يعيد تعيين المدخل ويستدعي
onSearch بسلسلة فارغة
- اختبر أن زر البحث معطل عندما يكون المدخل فارغاً
- اختبر حالة الخطأ عندما يكون استعلام البحث قصيراً جداً (<3 أحرف)
ركز على إمكانية الوصول: استخدم التسميات المناسبة وسمات ARIA والاستعلامات القائمة على الأدوار.
أفضل الممارسات
- أولوية الاستعلام: getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId
- استخدم user-event: فضّل
userEvent على fireEvent للتفاعلات الواقعية
- اختبر السلوك، وليس التنفيذ: لا تختبر الحالة أو الخصائص مباشرة
- تجنب waitFor عندما يكون ذلك ممكناً: استخدم استعلامات
findBy* بدلاً من ذلك
- استخدم بيانات واقعية: اختبر بالبيانات التي تطابق سيناريوهات الإنتاج
- تأكيد واحد لكل اختبار: احتفظ بالاختبارات مركزة وسهلة التصحيح
- أسماء اختبار وصفية: يجب أن تشرح أسماء الاختبار ماذا ولماذا، وليس كيف
الأخطاء الشائعة التي يجب تجنبها
تحذير:
- لا تستعلم عن طريق أسماء الفئات أو المعرفات - استخدم الاستعلامات التي يمكن الوصول إليها
- لا تختبر تفاصيل التنفيذ مثل حالة المكون
- لا تستخدم
container.querySelector() - استخدم استعلامات الشاشة
- لا تنسى
await مع الاستعلامات غير المتزامنة وأحداث المستخدم
- لا تستخدم
waitFor لكل شيء - فضّل استعلامات findBy*
في الدرس التالي، سنستكشف اختبار React Hooks و Context، والتي تتطلب تقنيات اختبار خاصة.