We are still cooking the magic in the way!
اختبار مكونات 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 لرؤية تقارير تغطية الكود.