الاختبارات و TDD

اختبار React Hooks و Context

32 دقيقة الدرس 12 من 35

مقدمة في اختبار Hooks و Context

يتطلب اختبار React Hooks و Context تقنيات خاصة لأنه لا يمكن استدعاؤها خارج مكونات React. توفر React Testing Library أدوات مثل renderHook لاختبار الخطافات المخصصة بشكل منفصل واستراتيجيات لاختبار موفري Context بفعالية.

لماذا اختبار Hooks بشكل منفصل؟

غالباً ما تحتوي الخطافات المخصصة على منطق أعمال قابل لإعادة الاستخدام يجب اختباره بشكل مستقل:

  • العزل: اختبار منطق الخطاف دون عبء عرض المكون
  • إعادة الاستخدام: التأكد من أن الخطافات تعمل بشكل صحيح في سياقات مختلفة
  • التصحيح: تحديد المشكلات في منطق الخطاف مقابل عرض المكون
  • التوثيق: تعمل الاختبارات كأمثلة استخدام للخطافات الخاصة بك

إعداد اختبار Hook

تثبيت الحزمة الضرورية:

npm install --save-dev @testing-library/react-hooks # لـ React 18+ (renderHook مدمج في @testing-library/react) # لا حاجة لحزمة إضافية
ملاحظة: اعتباراً من React 18، يتم تضمين renderHook في @testing-library/react. لـ React 16-17، استخدم @testing-library/react-hooks.

اختبار الخطافات المخصصة البسيطة

لنختبر خطاف عداد بسيط:

// useCounter.js import { useState } from 'react'; export function useCounter(initialValue = 0) { const [count, setCount] = useState(initialValue); const increment = () => setCount(c => c + 1); const decrement = () => setCount(c => c - 1); const reset = () => setCount(initialValue); return { count, increment, decrement, reset }; }

اختبار الخطاف:

// useCounter.test.js import { renderHook, act } from '@testing-library/react'; import { useCounter } from './useCounter'; 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(); result.current.reset(); }); expect(result.current.count).toBe(10); });
نصيحة: قم دائماً بلف تحديثات الحالة في act(). هذا يضمن معالجة React للتحديثات قبل التأكيدات، مطابقة سلوك المكون الحقيقي.

فهم renderHook

تعيد دالة renderHook كائناً بخصائص مفيدة:

const { result, rerender, unmount } = renderHook(() => useMyHook()); // result.current - القيمة المرجعة الحالية للخطاف console.log(result.current); // rerender() - إعادة تشغيل الخطاف مع خصائص جديدة rerender(); // unmount() - إلغاء تثبيت الخطاف (التنظيف) unmount();

اختبار Hooks مع الخصائص

اختبر الخطافات التي تقبل المعاملات عن طريق تمريرها عبر renderHook:

// useFetch.js import { useState, useEffect } from 'react'; export function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; fetch(url) .then(res => res.json()) .then(data => { if (!cancelled) { setData(data); setLoading(false); } }) .catch(err => { if (!cancelled) { setError(err); setLoading(false); } }); return () => { cancelled = true; }; }, [url]); return { data, loading, error }; }

الاختبار مع عناوين URL مختلفة:

// useFetch.test.js import { renderHook, waitFor } from '@testing-library/react'; import { useFetch } from './useFetch'; beforeEach(() => { global.fetch = jest.fn(); }); afterEach(() => { jest.restoreAllMocks(); }); test('fetches data successfully', async () => { const mockData = { id: 1, name: 'Test' }; global.fetch.mockResolvedValueOnce({ json: async () => mockData }); const { result } = renderHook(() => useFetch('/api/users/1')); // في البداية يتم التحميل expect(result.current.loading).toBe(true); expect(result.current.data).toBe(null); // انتظر اكتمال الجلب await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.data).toEqual(mockData); expect(result.current.error).toBe(null); }); test('handles fetch errors', async () => { const mockError = new Error('Network error'); global.fetch.mockRejectedValueOnce(mockError); const { result } = renderHook(() => useFetch('/api/users/1')); await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.error).toBe(mockError); expect(result.current.data).toBe(null); }); test('refetches when URL changes', async () => { const mockData1 = { id: 1, name: 'User 1' }; const mockData2 = { id: 2, name: 'User 2' }; global.fetch .mockResolvedValueOnce({ json: async () => mockData1 }) .mockResolvedValueOnce({ json: async () => mockData2 }); const { result, rerender } = renderHook( ({ url }) => useFetch(url), { initialProps: { url: '/api/users/1' } } ); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.data).toEqual(mockData1); // تغيير URL rerender({ url: '/api/users/2' }); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.data).toEqual(mockData2); });

اختبار Hooks مع التبعيات

اختبر الخطافات التي تعتمد على خطافات أخرى أو مكتبات خارجية:

// useLocalStorage.js import { useState, useEffect } from 'react'; export function useLocalStorage(key, initialValue) { const [value, setValue] = useState(() => { const stored = localStorage.getItem(key); return stored ? JSON.parse(stored) : initialValue; }); useEffect(() => { localStorage.setItem(key, JSON.stringify(value)); }, [key, value]); return [value, setValue]; }

محاكاة localStorage للاختبار:

// useLocalStorage.test.js import { renderHook, act } from '@testing-library/react'; import { useLocalStorage } from './useLocalStorage'; let localStorageMock; beforeEach(() => { localStorageMock = { getItem: jest.fn(), setItem: jest.fn(), clear: jest.fn() }; global.localStorage = localStorageMock; }); test('reads initial value from localStorage', () => { localStorageMock.getItem.mockReturnValue(JSON.stringify('stored value')); const { result } = renderHook(() => useLocalStorage('key', 'default')); expect(result.current[0]).toBe('stored value'); expect(localStorageMock.getItem).toHaveBeenCalledWith('key'); }); test('uses default value when localStorage is empty', () => { localStorageMock.getItem.mockReturnValue(null); const { result } = renderHook(() => useLocalStorage('key', 'default')); expect(result.current[0]).toBe('default'); }); test('updates localStorage when value changes', () => { localStorageMock.getItem.mockReturnValue(null); const { result } = renderHook(() => useLocalStorage('key', 'default')); act(() => { result.current[1]('new value'); }); expect(localStorageMock.setItem).toHaveBeenCalledWith( 'key', JSON.stringify('new value') ); });

اختبار موفري Context

يتطلب Context لف المكونات/الخطافات بالموفرين. أنشئ غلافاً مخصصاً:

// AuthContext.js import { createContext, useContext, useState } from 'react'; const AuthContext = createContext(); export function AuthProvider({ children }) { const [user, setUser] = useState(null); const login = (userData) => setUser(userData); const logout = () => setUser(null); return ( <AuthContext.Provider value={{ user, login, logout }}> {children} </AuthContext.Provider> ); } export function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within AuthProvider'); } return context; }

اختبر السياق والخطاف:

// AuthContext.test.js import { renderHook, act } from '@testing-library/react'; import { AuthProvider, useAuth } from './AuthContext'; test('provides authentication context', () => { const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider }); expect(result.current.user).toBe(null); }); test('logs in user', () => { const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider }); const userData = { id: 1, name: 'John Doe' }; act(() => { result.current.login(userData); }); expect(result.current.user).toEqual(userData); }); test('logs out user', () => { const { result } = renderHook(() => useAuth(), { wrapper: AuthProvider }); act(() => { result.current.login({ id: 1, name: 'John' }); result.current.logout(); }); expect(result.current.user).toBe(null); }); 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(); });

اختبار المكونات مع Context

اختبر المكونات التي تستهلك السياق من خلال لفها بالموفرين:

// UserProfile.test.js import { render, screen } from '@testing-library/react'; import { AuthProvider } from './AuthContext'; import UserProfile from './UserProfile'; function renderWithAuth(ui, { user = null, ...options } = {}) { function Wrapper({ children }) { return ( <AuthProvider initialUser={user}> {children} </AuthProvider> ); } return render(ui, { wrapper: Wrapper, ...options }); } test('shows login prompt when not authenticated', () => { renderWithAuth(<UserProfile />); expect(screen.getByText(/please log in/i)).toBeInTheDocument(); }); test('shows user info when authenticated', () => { const user = { id: 1, name: 'John Doe', email: 'john@example.com' }; renderWithAuth(<UserProfile />, { user }); expect(screen.getByText(/john doe/i)).toBeInTheDocument(); expect(screen.getByText(/john@example.com/i)).toBeInTheDocument(); });
نصيحة: أنشئ دوال عرض قابلة لإعادة الاستخدام مع الموفرين المشتركين. هذا يقلل من النموذج ويضمن إعداد اختبار متسق.

اختبار Context مع موفرين متعددين

ضع موفرين متعددين متداخلين لسيناريوهات معقدة:

function AllProviders({ children }) { return ( <AuthProvider> <ThemeProvider> <I18nProvider> {children} </I18nProvider> </ThemeProvider> </AuthProvider> ); } test('component with multiple contexts', () => { const { result } = renderHook( () => ({ auth: useAuth(), theme: useTheme(), i18n: useI18n() }), { wrapper: AllProviders } ); expect(result.current.auth.user).toBe(null); expect(result.current.theme.mode).toBe('light'); expect(result.current.i18n.locale).toBe('en'); });

اختبار Hooks غير المتزامنة

اختبر الخطافات مع العمليات غير المتزامنة:

// useDebounce.js import { useState, useEffect } from 'react'; export function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value); }, delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; }

الاختبار مع مؤقتات وهمية:

// useDebounce.test.js import { renderHook } from '@testing-library/react'; import { useDebounce } from './useDebounce'; beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.runOnlyPendingTimers(); jest.useRealTimers(); }); test('returns initial value immediately', () => { const { result } = renderHook(() => useDebounce('initial', 500)); expect(result.current).toBe('initial'); }); test('debounces value changes', () => { const { result, rerender } = renderHook( ({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 'initial', delay: 500 } } ); expect(result.current).toBe('initial'); // تغيير القيمة rerender({ value: 'changed', delay: 500 }); // يجب ألا تتغير القيمة فوراً expect(result.current).toBe('initial'); // تسريع الوقت jest.advanceTimersByTime(500); // الآن يجب أن تتحدث القيمة expect(result.current).toBe('changed'); }); test('cancels previous timeout on rapid changes', () => { const { result, rerender } = renderHook( ({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 'first', delay: 500 } } ); rerender({ value: 'second', delay: 500 }); jest.advanceTimersByTime(250); rerender({ value: 'third', delay: 500 }); jest.advanceTimersByTime(500); // يجب تخطي 'second' والذهاب مباشرة إلى 'third' expect(result.current).toBe('third'); });
ملاحظة: استخدم jest.useFakeTimers() للتحكم في الوقت في الاختبارات. تذكر استعادة المؤقتات الحقيقية بعد كل اختبار.

اختبار تنظيف Hook

تحقق من أن الخطافات تنظف الآثار الجانبية بشكل صحيح:

// useEventListener.js import { useEffect } from 'react'; export function useEventListener(event, handler, element = window) { useEffect(() => { element.addEventListener(event, handler); return () => { element.removeEventListener(event, handler); }; }, [event, handler, element]); }

اختبار التنظيف:

// useEventListener.test.js import { renderHook } from '@testing-library/react'; import { useEventListener } from './useEventListener'; test('adds event listener', () => { const handler = jest.fn(); const addSpy = jest.spyOn(window, 'addEventListener'); renderHook(() => useEventListener('resize', handler)); expect(addSpy).toHaveBeenCalledWith('resize', handler); addSpy.mockRestore(); }); test('removes event listener on unmount', () => { const handler = jest.fn(); const removeSpy = jest.spyOn(window, 'removeEventListener'); const { unmount } = renderHook(() => useEventListener('resize', handler)); unmount(); expect(removeSpy).toHaveBeenCalledWith('resize', handler); removeSpy.mockRestore(); }); test('updates listener when handler changes', () => { const handler1 = jest.fn(); const handler2 = jest.fn(); const addSpy = jest.spyOn(window, 'addEventListener'); const removeSpy = jest.spyOn(window, 'removeEventListener'); const { rerender } = renderHook( ({ handler }) => useEventListener('click', handler), { initialProps: { handler: handler1 } } ); rerender({ handler: handler2 }); expect(removeSpy).toHaveBeenCalledWith('click', handler1); expect(addSpy).toHaveBeenCalledWith('click', handler2); addSpy.mockRestore(); removeSpy.mockRestore(); });
تمرين:

أنشئ واختبر خطاف مخصص useForm بالميزات التالية:

  1. إدارة حالة النموذج (القيم، الأخطاء، الحقول المعدلة)
  2. توفير دوال handleChange و handleBlur و handleSubmit
  3. التحقق من صحة الحقول بناءً على مخطط التحقق
  4. منع الإرسال عندما يحتوي النموذج على أخطاء
  5. إعادة تعيين النموذج بعد الإرسال الناجح

اكتب اختبارات شاملة تغطي:

  • الحالة الأولية
  • تحديثات الحقول
  • التحقق عند الضبابية
  • إرسال النموذج (حالات النجاح والخطأ)
  • إعادة تعيين النموذج

أفضل الممارسات لاختبار Hook

  • استخدم renderHook للعزل: اختبر الخطافات بشكل مستقل عندما يكون ذلك ممكناً
  • لف تحديثات الحالة في act(): يضمن معالجة React للتحديثات بشكل صحيح
  • اختبر مع موفرين حقيقيين: استخدم موفري Context الفعليين، وليس المحاكيات
  • محاكاة التبعيات الخارجية: محاكاة واجهات برمجة التطبيقات، localStorage، المؤقتات، إلخ
  • اختبر التنظيف: تحقق من أن دوال تنظيف useEffect تعمل بشكل صحيح
  • اختبر الحالات الحدية: اختبر مع قيم null، undefined، قيم فارغة
  • استخدم المؤقتات الوهمية بحكمة: فقط عند اختبار المنطق المعتمد على الوقت
أخطاء شائعة:
  • نسيان لف تحديثات الحالة في act()
  • عدم تنظيف المحاكيات والجواسيس
  • اختبار الخطافات بدون موفرين عندما تتطلب سياقاً
  • عدم استعادة المؤقتات الحقيقية بعد استخدام المؤقتات الوهمية
  • اختبار تفاصيل التنفيذ بدلاً من السلوك

الخلاصة

اختبار الخطافات والسياق ضروري للحفاظ على تطبيقات React قوية. استخدم renderHook لاختبار الخطافات المعزولة، وأنشئ أغلفة مخصصة لاختبار السياق، وتحقق دائماً من سلوك التنظيف. في الدرس التالي، سنستكشف الاختبار الشامل باستخدام Cypress.