اختبار مكونات React
مقدمة في اختبار مكونات React
الاختبار جزء حاسم من بناء تطبيقات React موثوقة. في هذا الدرس، سنستكشف كيفية اختبار مكونات React باستخدام Jest و React Testing Library، الأدوات القياسية في الصناعة لاختبار تطبيقات React.
لماذا نختبر مكونات React؟
اختبار مكونات React يوفر عدة فوائد:
- الثقة: التأكد من أن المكونات تعمل كما هو متوقع
- منع التراجع: اكتشاف الأخطاء عند إجراء التغييرات
- التوثيق: الاختبارات تعمل كوثائق حية
- أمان إعادة الهيكلة: تغيير الكود بثقة مع تغطية الاختبار
- تصميم أفضل: كتابة الاختبارات تشجع على تصميم أفضل للمكونات
إعداد Jest و React Testing Library
Create React App يأتي مع Jest و React Testing Library مُعدّة مسبقاً. للإعدادات المخصصة:
npm install --save-dev @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event
// استيراد Jest DOM matchers
import '@testing-library/jest-dom';
// اختياري: إضافة matchers مخصصة أو إعداد اختبار عام
global.matchMedia = global.matchMedia || function() {
return {
matches: false,
addListener: jest.fn(),
removeListener: jest.fn(),
};
};
اختبار المكون الأول
لنبدأ بمكون بسيط ونختبره:
function Button({ onClick, children, disabled = false }) {
return (
<button
onClick={onClick}
disabled={disabled}
className="btn"
>
{children}
</button>
);
}
export default Button;
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';
describe('Button Component', () => {
test('renders button with text', () => {
render(<Button>Click Me</Button>);
const button = screen.getByRole('button', { name: /click me/i });
expect(button).toBeInTheDocument();
});
test('calls onClick when clicked', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click Me</Button>);
const button = screen.getByRole('button');
await user.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('does not call onClick when disabled', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick} disabled>Click Me</Button>);
const button = screen.getByRole('button');
await user.click(button);
expect(handleClick).not.toHaveBeenCalled();
expect(button).toBeDisabled();
});
});
describe لتجميع الاختبارات المرتبطة وأعط كل اختبار اسماً واضحاً ووصفياً يشرح ما يختبره.
طرق الاستعلام في React Testing Library
React Testing Library توفر عدة طرق استعلام للعثور على العناصر:
// 1. استعلامات إمكانية الوصول (مفضلة)
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/username/i)
screen.getByPlaceholderText(/enter email/i)
screen.getByText(/welcome back/i)
// 2. استعلامات دلالية
screen.getByAltText(/profile picture/i)
screen.getByTitle(/close dialog/i)
// 3. معرفات الاختبار (الملاذ الأخير)
screen.getByTestId('custom-element')
// متغيرات الاستعلام:
// getBy* - يطرح خطأ إذا لم يُعثر عليه (عنصر واحد)
// queryBy* - يعيد null إذا لم يُعثر عليه (عنصر واحد)
// findBy* - يعيد promise، ينتظر العنصر (async)
// getAllBy* - يعيد مصفوفة، يطرح خطأ إذا لم يُعثر على أي
// queryAllBy* - يعيد مصفوفة فارغة إذا لم يُعثر على أي
// findAllBy* - يعيد promise مع مصفوفة
اختبار مكون مع حالة
لنختبر مكوناً يدير الحالة:
import { useState } from 'react';
function Counter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>Count: <span data-testid="count-value">{count}</span></p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
export default Counter;
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
describe('Counter Component', () => {
test('renders with initial count', () => {
render(<Counter initialCount={5} />);
const countValue = screen.getByTestId('count-value');
expect(countValue).toHaveTextContent('5');
});
test('increments count when increment button is clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
const incrementButton = screen.getByRole('button', { name: /increment/i });
await user.click(incrementButton);
const countValue = screen.getByTestId('count-value');
expect(countValue).toHaveTextContent('1');
});
test('decrements count when decrement button is clicked', async () => {
const user = userEvent.setup();
render(<Counter initialCount={10} />);
const decrementButton = screen.getByRole('button', { name: /decrement/i });
await user.click(decrementButton);
const countValue = screen.getByTestId('count-value');
expect(countValue).toHaveTextContent('9');
});
test('resets count to zero', async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
const resetButton = screen.getByRole('button', { name: /reset/i });
await user.click(resetButton);
const countValue = screen.getByTestId('count-value');
expect(countValue).toHaveTextContent('0');
});
test('performs multiple interactions correctly', async () => {
const user = userEvent.setup();
render(<Counter />);
const incrementButton = screen.getByRole('button', { name: /increment/i });
const decrementButton = screen.getByRole('button', { name: /decrement/i });
const countValue = screen.getByTestId('count-value');
await user.click(incrementButton);
await user.click(incrementButton);
await user.click(incrementButton);
expect(countValue).toHaveTextContent('3');
await user.click(decrementButton);
expect(countValue).toHaveTextContent('2');
});
});
اختبار العمليات غير المتزامنة
العديد من المكونات تؤدي عمليات غير متزامنة. React Testing Library توفر أدوات لاختبار السلوك غير المتزامن:
import { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('https://api.example.com/users')
.then(res => res.json())
.then(data => {
setUsers(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return <p>Loading users...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
export default UserList;
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';
// محاكاة fetch عمومياً
global.fetch = jest.fn();
describe('UserList Component', () => {
beforeEach(() => {
fetch.mockClear();
});
test('displays loading state initially', () => {
fetch.mockImplementation(() => new Promise(() => {})); // لا يُحل أبداً
render(<UserList />);
expect(screen.getByText(/loading users/i)).toBeInTheDocument();
});
test('displays users after successful fetch', async () => {
const mockUsers = [
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' }
];
fetch.mockResolvedValueOnce({
json: async () => mockUsers
});
render(<UserList />);
// انتظر ظهور المستخدمين
const userItems = await screen.findAllByRole('listitem');
expect(userItems).toHaveLength(2);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
test('displays error message on fetch failure', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
render(<UserList />);
// انتظر رسالة الخطأ
const errorMessage = await screen.findByText(/error: network error/i);
expect(errorMessage).toBeInTheDocument();
});
test('loading indicator disappears after data loads', async () => {
fetch.mockResolvedValueOnce({
json: async () => [{ id: 1, name: 'Test User' }]
});
render(<UserList />);
expect(screen.getByText(/loading users/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByText(/loading users/i)).not.toBeInTheDocument();
});
});
});
await مع استعلامات غير متزامنة مثل findBy* أو waitFor يمكن أن يؤدي إلى اختبارات غير مستقرة. دائماً انتظر العمليات غير المتزامنة في الاختبارات.
User Event مقابل FireEvent
React Testing Library توفر طريقتين لمحاكاة تفاعلات المستخدم:
import { render, screen, fireEvent } from '@testing-library/react';
test('using fireEvent', () => {
render(<Input />);
const input = screen.getByRole('textbox');
// يطلق حدثاً واحداً
fireEvent.change(input, { target: { value: 'Hello' } });
fireEvent.click(screen.getByRole('button'));
});
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('using userEvent', async () => {
const user = userEvent.setup();
render(<Input />);
const input = screen.getByRole('textbox');
// يحاكي تفاعلات المستخدم الحقيقية (أحداث متعددة)
await user.type(input, 'Hello');
await user.click(screen.getByRole('button'));
// طرق مفيدة أخرى:
await user.hover(element);
await user.unhover(element);
await user.selectOptions(select, 'option1');
await user.clear(input);
await user.keyboard('{Enter}');
await user.tab();
});
userEvent على fireEvent لأنه يحاكي تفاعلات المستخدم الحقيقية بشكل أقرب من خلال إطلاق أحداث متعددة بالتسلسل الصحيح.
تمرين 1: اختبار مكون نموذج
أنشئ مكون نموذج تسجيل دخول مع حقول البريد الإلكتروني وكلمة المرور، واكتب اختبارات لـ:
- عرض جميع عناصر النموذج بشكل صحيح
- التحقق من أن الحقول الفارغة تظهر رسائل خطأ
- استدعاء onSubmit مع البيانات الصحيحة عندما يكون النموذج صالحاً
- تعطيل زر الإرسال أثناء الإرسال
تمرين 2: اختبار مكون البحث
أنشئ مكون بحث يستدعي النتائج من API. اكتب اختبارات لـ:
- عرض عدم وجود نتائج في البداية
- عرض حالة التحميل أثناء البحث
- عرض نتائج البحث بعد استدعاء API ناجح
- التعامل مع أخطاء API بشكل سلس
- تأخير إدخال البحث (متقدم)
تمرين 3: اختبار مكون التبديل
أنشئ مكون مفتاح تبديل واكتب اختبارات لـ:
- الحالة الأولية محددة/غير محددة
- تبديل الحالة عند النقر
- استدعاء callback onChange مع القيمة الصحيحة
- إمكانية الوصول (يمكن التبديل بلوحة المفاتيح)
- الحالة المعطلة تمنع التبديل
Jest Matchers لاختبار React
Matchers شائعة من @testing-library/jest-dom:
// الوجود
expect(element).toBeInTheDocument()
expect(element).not.toBeInTheDocument()
// الرؤية
expect(element).toBeVisible()
expect(element).not.toBeVisible()
// القيم
expect(input).toHaveValue('test')
expect(checkbox).toBeChecked()
expect(button).toBeDisabled()
// محتوى النص
expect(element).toHaveTextContent('Hello')
expect(element).toContainHTML('<span>Test</span>')
// الخصائص
expect(element).toHaveAttribute('href', '/home')
expect(element).toHaveClass('active')
expect(element).toHaveStyle({ color: 'red' })
// عناصر النموذج
expect(input).toBeRequired()
expect(input).toBeInvalid()
expect(select).toHaveDisplayValue('Option 1')
// التركيز
expect(input).toHaveFocus()
// إمكانية الوصول
expect(element).toHaveAccessibleName('Submit button')
expect(element).toHaveAccessibleDescription('Click to submit')
npm test لتشغيل الاختبارات في وضع المراقبة، أو npm test -- --coverage لرؤية تقارير تغطية الكود.