أساسيات React.js

اختبار الأنماط المتقدمة

18 دقيقة الدرس 27 من 40

أنماط الاختبار المتقدمة في React

في هذا الدرس، سنستكشف تقنيات اختبار متقدمة بما في ذلك محاكاة استدعاءات API، واختبار الخطافات المخصصة، واختبار التكامل، واختبار اللقطات، وقياس تغطية الكود. هذه الأنماط تساعدك على بناء مجموعات اختبار شاملة للتطبيقات المعقدة.

محاكاة استدعاءات API

عند اختبار المكونات التي تقوم باستدعاءات API، يجب عليك محاكاة API لتجنب طلبات الشبكة الفعلية:

استخدام Jest Mock Functions:
// 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 على مستويات مختلفة:

محاكاة يدوية مع MSW (Mock Service Worker):
// 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());
فوائد MSW: Mock Service Worker يعترض طلبات الشبكة على مستوى الشبكة، مما يجعل اختباراتك أكثر واقعية وتعمل بسلاسة مع أي عميل HTTP (fetch، axios، إلخ).

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

الخطافات المخصصة تتطلب أدوات اختبار خاصة:

خطاف مخصص (useCounter.js):
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 };
}
الاختبار باستخدام renderHook:
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 لتحديثات الحالة بشكل صحيح. نسيان هذا يؤدي إلى تحذيرات واختبارات غير موثوقة.

اختبار الخطافات مع التبعيات

اختبار الخطافات التي تعتمد على السياق أو الخصائص:

خطاف مع السياق (useAuth.js):
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;
}
الاختبار مع Wrapper:
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
تكوين التغطية (package.json):
{
  "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"
    ]
  }
}
مقاييس التغطية: تقارير التغطية تعرض أربعة مقاييس: العبارات (الأسطر المنفذة)، والفروع (مسارات if/else)، والدوال (الدوال المستدعاة)، والأسطر (أسطر المصدر المنفذة).

تمرين 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 }
  });
  // تأكيدات الاختبار...
});
اختبارات DRY: أنشئ دوال عرض مخصصة وأدوات اختبار لتقليل الشفرة النمطية وجعل الاختبارات أكثر قابلية للصيانة. لكن لا تفرط في التجريد - يجب أن تظل الاختبارات قابلة للقراءة.