أساسيات React.js

اختبار مكونات React

20 دقيقة الدرس 26 من 40

مقدمة في اختبار مكونات React

الاختبار جزء حاسم من بناء تطبيقات React موثوقة. في هذا الدرس، سنستكشف كيفية اختبار مكونات React باستخدام Jest و React Testing Library، الأدوات القياسية في الصناعة لاختبار تطبيقات React.

لماذا نختبر مكونات React؟

اختبار مكونات React يوفر عدة فوائد:

  • الثقة: التأكد من أن المكونات تعمل كما هو متوقع
  • منع التراجع: اكتشاف الأخطاء عند إجراء التغييرات
  • التوثيق: الاختبارات تعمل كوثائق حية
  • أمان إعادة الهيكلة: تغيير الكود بثقة مع تغطية الاختبار
  • تصميم أفضل: كتابة الاختبارات تشجع على تصميم أفضل للمكونات
فلسفة الاختبار: React Testing Library تشجع على اختبار المكونات بالطريقة التي يتفاعل بها المستخدمون معها، مع التركيز على السلوك بدلاً من تفاصيل التنفيذ.

إعداد 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
ملف الإعداد (setupTests.js):
// استيراد Jest DOM matchers
import '@testing-library/jest-dom';

// اختياري: إضافة matchers مخصصة أو إعداد اختبار عام
global.matchMedia = global.matchMedia || function() {
  return {
    matches: false,
    addListener: jest.fn(),
    removeListener: jest.fn(),
  };
};

اختبار المكون الأول

لنبدأ بمكون بسيط ونختبره:

المكون (Button.jsx):
function Button({ onClick, children, disabled = false }) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className="btn"
    >
      {children}
    </button>
  );
}

export default Button;
ملف الاختبار (Button.test.jsx):
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 مع مصفوفة

اختبار مكون مع حالة

لنختبر مكوناً يدير الحالة:

مكون العداد (Counter.jsx):
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;
اختبار العداد (Counter.test.jsx):
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 توفر أدوات لاختبار السلوك غير المتزامن:

مكون قائمة المستخدمين (UserList.jsx):
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;
الاختبار غير المتزامن (UserList.test.jsx):
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 توفر طريقتين لمحاكاة تفاعلات المستخدم:

FireEvent (مستوى منخفض):
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'));
});
User Event (موصى به - مستوى عالي):
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:

Matchers مفيدة:
// الوجود
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 لرؤية تقارير تغطية الكود.