اختبار الأنماط المتقدمة
أنماط الاختبار المتقدمة في React
في هذا الدرس، سنستكشف تقنيات اختبار متقدمة بما في ذلك محاكاة استدعاءات API، واختبار الخطافات المخصصة، واختبار التكامل، واختبار اللقطات، وقياس تغطية الكود. هذه الأنماط تساعدك على بناء مجموعات اختبار شاملة للتطبيقات المعقدة.
محاكاة استدعاءات API
عند اختبار المكونات التي تقوم باستدعاءات API، يجب عليك محاكاة API لتجنب طلبات الشبكة الفعلية:
// todoService.js
export const fetchTodos = async () => {
const response = await fetch('/api/todos');
return response.json();
};
export const createTodo = async (todo) => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo)
});
return response.json();
};
// TodoList.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from './TodoList';
import * as todoService from './todoService';
// محاكاة الوحدة بأكملها
jest.mock('./todoService');
describe('TodoList Component', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('loads and displays todos', async () => {
const mockTodos = [
{ id: 1, title: 'Buy groceries', completed: false },
{ id: 2, title: 'Walk the dog', completed: true }
];
todoService.fetchTodos.mockResolvedValue(mockTodos);
render(<TodoList />);
await waitFor(() => {
expect(screen.getByText('Buy groceries')).toBeInTheDocument();
expect(screen.getByText('Walk the dog')).toBeInTheDocument();
});
expect(todoService.fetchTodos).toHaveBeenCalledTimes(1);
});
test('creates a new todo', async () => {
const user = userEvent.setup();
const newTodo = { id: 3, title: 'New task', completed: false };
todoService.fetchTodos.mockResolvedValue([]);
todoService.createTodo.mockResolvedValue(newTodo);
render(<TodoList />);
const input = screen.getByPlaceholderText(/add todo/i);
const addButton = screen.getByRole('button', { name: /add/i });
await user.type(input, 'New task');
await user.click(addButton);
await waitFor(() => {
expect(screen.getByText('New task')).toBeInTheDocument();
});
expect(todoService.createTodo).toHaveBeenCalledWith({
title: 'New task',
completed: false
});
});
test('handles API errors gracefully', async () => {
todoService.fetchTodos.mockRejectedValue(
new Error('Failed to fetch todos')
);
render(<TodoList />);
const errorMessage = await screen.findByText(/failed to fetch/i);
expect(errorMessage).toBeInTheDocument();
});
});
jest.clearAllMocks() أو jest.resetAllMocks() في beforeEach لتجنب تلوث الاختبار.
محاكاة Axios أو Fetch
يمكنك محاكاة مكتبات HTTP على مستويات مختلفة:
// src/mocks/handlers.js
import { rest } from 'msw';
export const handlers = [
rest.get('/api/todos', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{ id: 1, title: 'Test todo', completed: false }
])
);
}),
rest.post('/api/todos', async (req, res, ctx) => {
const todo = await req.json();
return res(
ctx.status(201),
ctx.json({ id: Date.now(), ...todo })
);
}),
rest.delete('/api/todos/:id', (req, res, ctx) => {
return res(ctx.status(204));
})
];
// src/mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// setupTests.js
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
اختبار الخطافات المخصصة
الخطافات المخصصة تتطلب أدوات اختبار خاصة:
import { useState, useCallback } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(c => c + 1);
}, []);
const decrement = useCallback(() => {
setCount(c => c - 1);
}, []);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
return { count, increment, decrement, reset };
}
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter Hook', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(12);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
test('handles multiple operations', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
result.current.increment();
result.current.decrement();
});
expect(result.current.count).toBe(1);
});
});
act() لضمان معالجة React لتحديثات الحالة بشكل صحيح. نسيان هذا يؤدي إلى تحذيرات واختبارات غير موثوقة.
اختبار الخطافات مع التبعيات
اختبار الخطافات التي تعتمد على السياق أو الخصائص:
import { useContext } from 'react';
import { AuthContext } from './AuthContext';
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
import { renderHook } from '@testing-library/react';
import { useAuth } from './useAuth';
import { AuthProvider } from './AuthContext';
describe('useAuth Hook', () => {
test('returns auth context value', () => {
const mockUser = { id: 1, name: 'John Doe' };
const wrapper = ({ children }) => (
<AuthProvider value={{ user: mockUser, isAuthenticated: true }}>
{children}
</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.user).toEqual(mockUser);
expect(result.current.isAuthenticated).toBe(true);
});
test('throws error when used outside provider', () => {
// كبت console.error لهذا الاختبار
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => useAuth());
}).toThrow('useAuth must be used within AuthProvider');
spy.mockRestore();
});
});
اختبار التكامل
اختبارات التكامل تتحقق من عمل عدة مكونات معاً:
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App';
import { server } from './mocks/server';
import { rest } from 'msw';
describe('Todo App Integration', () => {
test('complete todo workflow', async () => {
const user = userEvent.setup();
render(<App />);
// انتظر تحميل المهام الأولية
await waitFor(() => {
expect(screen.getByText('Existing Todo')).toBeInTheDocument();
});
// أضف مهمة جديدة
const input = screen.getByPlaceholderText(/new todo/i);
await user.type(input, 'Buy milk');
await user.keyboard('{Enter}');
await waitFor(() => {
expect(screen.getByText('Buy milk')).toBeInTheDocument();
});
// علّم المهمة كمكتملة
const checkbox = screen.getByRole('checkbox', { name: /buy milk/i });
await user.click(checkbox);
await waitFor(() => {
expect(checkbox).toBeChecked();
});
// احذف المهمة
const deleteButton = screen.getByRole('button', { name: /delete buy milk/i });
await user.click(deleteButton);
await waitFor(() => {
expect(screen.queryByText('Buy milk')).not.toBeInTheDocument();
});
});
test('handles network errors', async () => {
// تجاوز المعالج لمحاكاة الخطأ
server.use(
rest.get('/api/todos', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Server error' }));
})
);
render(<App />);
const errorMessage = await screen.findByText(/failed to load todos/i);
expect(errorMessage).toBeInTheDocument();
});
test('filters todos by status', async () => {
const user = userEvent.setup();
render(<App />);
await waitFor(() => {
expect(screen.getByText('Active Todo')).toBeInTheDocument();
expect(screen.getByText('Completed Todo')).toBeInTheDocument();
});
// انقر على فلتر "نشط"
const activeFilter = screen.getByRole('button', { name: /active/i });
await user.click(activeFilter);
expect(screen.getByText('Active Todo')).toBeInTheDocument();
expect(screen.queryByText('Completed Todo')).not.toBeInTheDocument();
// انقر على فلتر "مكتمل"
const completedFilter = screen.getByRole('button', { name: /completed/i });
await user.click(completedFilter);
expect(screen.queryByText('Active Todo')).not.toBeInTheDocument();
expect(screen.getByText('Completed Todo')).toBeInTheDocument();
});
});
اختبار اللقطات
اختبارات اللقطات تلتقط مخرجات المكون وتكتشف التغييرات غير المتوقعة:
import { render } from '@testing-library/react';
import Card from './Card';
describe('Card Component Snapshots', () => {
test('matches snapshot with basic props', () => {
const { container } = render(
<Card title="Test Card" content="This is test content" />
);
expect(container.firstChild).toMatchSnapshot();
});
test('matches snapshot with image', () => {
const { container } = render(
<Card
title="Image Card"
content="Card with image"
image="https://example.com/image.jpg"
/>
);
expect(container.firstChild).toMatchSnapshot();
});
test('matches snapshot in loading state', () => {
const { container } = render(
<Card title="Loading" isLoading={true} />
);
expect(container.firstChild).toMatchSnapshot();
});
});
test('renders button correctly', () => {
const { container } = render(<Button variant="primary">Click Me</Button>);
expect(container.firstChild).toMatchInlineSnapshot(`
<button
class="btn btn-primary"
type="button"
>
Click Me
</button>
`);
});
تغطية الكود
قياس تغطية الاختبار يساعد في تحديد الكود غير المختبر:
# إنشاء تقرير التغطية
npm test -- --coverage
# إنشاء التغطية مع عتبات محددة
npm test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}'
# وضع المراقبة مع التغطية
npm test -- --coverage --watchAll=false
{
"jest": {
"collectCoverageFrom": [
"src/**/*.{js,jsx}",
"!src/index.js",
"!src/reportWebVitals.js",
"!src/**/*.test.{js,jsx}",
"!src/mocks/**"
],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
},
"coverageReporters": [
"text",
"lcov",
"html"
]
}
}
تمرين 1: محاكاة خدمة API
أنشئ مكون ملف تعريف المستخدم الذي يستدعي البيانات من API. اكتب اختبارات مع استدعاءات API محاكاة لـ:
- حالة التحميل أثناء الاستدعاء
- عرض البيانات الناجح
- معالجة الأخطاء مع زر إعادة المحاولة
- تحديث بيانات الملف الشخصي
- التحقق من استدعاء API بالمعاملات الصحيحة
تمرين 2: اختبار خطاف مخصص
أنشئ خطاف useLocalStorage الذي يزامن الحالة مع localStorage. اختبر:
- القيمة الأولية من localStorage
- تحديث القيمة يحدث localStorage
- معالجة JSON غير صالح في localStorage
- مسح التخزين
- عدة مثيلات خطاف تتزامن بشكل صحيح
تمرين 3: اختبار التكامل
ابنِ تطبيق عربة تسوق مع قائمة منتجات وعربة ودفع. اكتب اختبارات تكامل لـ:
- إضافة المنتجات إلى العربة
- تحديث الكميات
- إزالة العناصر
- حساب الإجماليات بشكل صحيح
- إكمال تدفق الدفع
- معالجة سيناريوهات نفاد المخزون
أفضل ممارسات تنظيم الاختبار
نظّم اختباراتك للقابلية للصيانة:
src/
├── components/
│ ├── Button/
│ │ ├── Button.jsx
│ │ ├── Button.test.jsx
│ │ └── Button.module.css
│ └── Card/
│ ├── Card.jsx
│ ├── Card.test.jsx
│ └── __snapshots__/
│ └── Card.test.jsx.snap
├── hooks/
│ ├── useCounter.js
│ └── useCounter.test.js
├── utils/
│ ├── formatters.js
│ └── formatters.test.js
├── mocks/
│ ├── handlers.js
│ └── server.js
└── __tests__/
└── integration/
└── TodoApp.test.jsx
// testUtils.js
import { render } from '@testing-library/react';
import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
export function renderWithProviders(
ui,
{
authValue = { user: null, isAuthenticated: false },
theme = 'light',
...renderOptions
} = {}
) {
function Wrapper({ children }) {
return (
<AuthProvider value={authValue}>
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
</AuthProvider>
);
}
return render(ui, { wrapper: Wrapper, ...renderOptions });
}
// الاستخدام في الاختبارات
import { renderWithProviders } from './testUtils';
test('renders with auth context', () => {
renderWithProviders(<MyComponent />, {
authValue: { user: mockUser, isAuthenticated: true }
});
// تأكيدات الاختبار...
});