EduFlow
اختبار تطبيقات JavaScript
⁦0%⁩

الوحدة 3 · اختبار واجهة المستخدم

اختبار مكوّنات React والشبكة

20 دقراءة 3 دبيئة تجريب الكود

الدرس في لمحة

تختبر مكوّنات React من خلال الـ props والمخرجات المعروضة وتفاعل المستخدم باستخدام React Testing Library، وتستبدل الشبكة عند حدودها بـ fetch محقون أو بـ MSW.

  1. 1عرض مكوّن
  2. 2أين تزيّف الشبكة
  3. 3ما لا تختبره في المكوّن
  4. 4التأكيد على طرفي الطلب

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

كل ما جاء في هذا القسم حتى الآن ينطبق على React دون تغيير، لأن React Testing Library طبقة رقيقة فوق الاستعلامات نفسها وuser-event الذي تعرفه. الجزء الجديد حقًّا هو الشبكة: مكوّنات React تحبّ جلب البيانات، والطريقة التي تزيّف بها ذلك تقرّر إن كانت اختباراتك ستلتقط أخطاءً حقيقية.

عرض مكوّن

يأخذ المكوّن ExpenseList في Splitwise-lite المصروفات كـ prop، ولديه جزء واحد من الـ state (الحالة)، وهو مرشّح البحث:

src/ExpenseList.jsx
import { useState } from 'react';
import { formatMoney } from './money.js';

export function ExpenseList({ expenses }) {
  const [query, setQuery] = useState('');
  const visible = expenses.filter((e) =>
    e.description.toLowerCase().includes(query.trim().toLowerCase()),
  );

  return (
    <section aria-label="Expenses">
      <label>
        Search <input value={query} onChange={(e) => setQuery(e.target.value)} />
      </label>
      {visible.length === 0 ? (
        <p>No matching expenses</p>
      ) : (
        <ul>
          {visible.map((e) => (
            <li key={e.id}>{e.description}: {formatMoney(e.amountCents)}</li>
          ))}
        </ul>
      )}
    </section>
  );
}

مع تثبيت @testing-library/react وضبط environment: 'jsdom'، تُقرأ الاختبارات مثل اختبارات الـ DOM التي كتبتها:

src/ExpenseList.test.jsx
import { test, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ExpenseList } from './ExpenseList.jsx';

const expenses = [
  { id: 'e1', description: 'Dinner', amountCents: 4550 },
  { id: 'e2', description: 'Taxi', amountCents: 1800 },
];

test('filters expenses as you type', async () => {
  const user = userEvent.setup();
  render(<ExpenseList expenses={expenses} />);

  await user.type(screen.getByRole('textbox', { name: 'Search' }), 'tax');

  const items = screen.getAllByRole('listitem');
  expect(items.map((li) => li.textContent)).toEqual(['Taxi: $18.00']);
});

test('shows the new expense when the prop changes', () => {
  const { rerender } = render(<ExpenseList expenses={expenses} />);

  rerender(<ExpenseList expenses={[...expenses, { id: 'e3', description: 'Museum', amountCents: 2400 }]} />);

  expect(screen.getByText('Museum: $24.00')).toBeTruthy();
});

تُختبر الـ props بالـ render بقيم مختلفة (وrerender للتحديثات). ويُختبر الـ state بالتفاعل ثم فحص النتيجة؛ لا توجد طريقة لقراءة query من الاختبار، وهذه ميزة. يلفّ render التحديثات في act() الخاصة بـ React نيابةً عنك، وتزيل React Testing Library المكوّنات (unmount) بعد كل اختبار حين يكون خيار globals في Vitest مفعّلًا (وإلا فاستدعِ cleanup داخل afterEach).

أين تزيّف الشبكة

المكوّن الذي يحمّل مجموعة يستدعي دالة بيانات، وهي تستدعي fetch، الذي يمرّ عبر الشبكة. تستطيع قطع هذه السلسلة عند ثلاث نقاط:

سلسلة من المكوّن إلى loadGroup إلى fetch إلى الشبكة. يقطع vi.mock مبكرًا ويتخطّى loadGroup؛ وحقن fetch يقطع بعد loadGroup؛ ويعترض MSW عند طرف الشبكة. المكوّن loadGroup() fetch() الشبكة vi.mock حقن fetch MSW قطع متأخّر = كود حقيقي مختبَر أكثر
اقطع السلسلة في أبعد نقطة ممكنة
  • استبدال وحدة البيانات بـ mock (vi.mock('./load-group.js')) يتخطّى بناء الـ URL، ومعالجة رمز الحالة، وتشكيل الاستجابة. وهذه بالضبط مواضع الأخطاء.
  • حقن fetch (loadGroup(id, fetchImpl)) يشغّل كل كودك ولا يزيّف إلا وسيلة النقل. بسيط، وبلا مكتبة، وهو ما يستخدمه التمرين.
  • MSW (Mock Service Worker) يعترض الطلبات على مستوى الشبكة في Node وفي المتصفّح. يستدعي كودك fetch الحقيقي دون أي تغيير:
src/test/server.js
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

export const server = setupServer(
  http.get('https://api.splitwise-lite.test/groups/:id', ({ params }) =>
    HttpResponse.json({ name: `Group ${params.id}`, members: [], expenses: [] }),
  ),
);

في ملف إعداد، استدعِ server.listen({ onUnhandledRequest: 'error' }) قبل كل الاختبارات، وserver.resetHandlers() بعد كل اختبار، وserver.close() في النهاية. ويستطيع اختبار واحد استبدال مسار ما بـ server.use(http.get(..., () => new HttpResponse(null, { status: 404 }))) ليختبر مسار الخطأ. ويمكن للمعالِجات نفسها أن تشغّل تطبيقك أثناء التطوير وفي Storybook، فيبقى الـ API المزيّف في مكان واحد.

ما لا تختبره في المكوّن

بعض اختبارات المكوّنات تكلّف أكثر مما تلتقط. تخطّها:

  • تفاصيل التنسيق: هل الصنف text-red-600، أو هل الحشوة 12px. الانتكاسات البصرية تُلتقط بشكل أفضل باختبارات لقطات الشاشة في متصفّح حقيقي، أو بمراجعة بشرية.
  • الأجزاء الداخلية لمكتبات خارجية: لا تحتاج إلى إثبات أن مكتبة منتقي التاريخ تفتح تقويمها. اختبر أن مكوّنك يمرّر لها القيمة الصحيحة ويتفاعل مع onChange الخاص بها.
  • snapshots للشجرة كاملة: expect(container).toMatchSnapshot() على مكوّن كبير يفشل مع كل تغيير غير ضارّ في البنية، ويتعلّم المراجعون الضغط على “update snapshot” دون قراءة. قد يكون snapshot صغير مضمّن لقيمة منسّقة واحدة مقبولًا؛ أما snapshot من 300 سطر فضجيج.

التأكيد على طرفي الطلب

اختبار الشبكة الجيد يفحص ما أرسلته، إضافة إلى ما فعلته بالإجابة. الـ URL الخاطئ خطأ حقيقي (/group/ بدل /groups/)، ولا يلتقطه إلا assertion على الطلب، لأن البديل المزيّف يجيب عن أي شيء بسرور. مع fetch محقون يكون ذلك expect(fetchMock).toHaveBeenCalledWith('https://api.splitwise-lite.test/groups/lisbon')؛ ومع MSW، لا يطابق المعالِج إلا المسار الصحيح، وonUnhandledRequest: 'error' يُفشل الاختبار مع أي شيء آخر.

في التمرين تختبر loadGroup مع fetch محقون يعيد كائنات Response حقيقية، وتلتقط أربعة أخطاء في الطلب وتشكيل الاستجابة. وبهذا يكتمل قسم الواجهة. بعده يأتي المتصفّح الحقيقي، حيث يلتقي أخيرًا التخطيط والتوجيه (routing) والخادم الحقيقي.

أهم النقاط

  • يركّب render في React Testing Library المكوّن داخل مستند jsdom؛ وتستعلم عنه وتتفاعل معه تمامًا كما تفعل مع الـ DOM العادي.
  • اختبر الـ props بالـ render بقيم مختلفة (أو بـ rerender)، واختبر الـ state بالتفاعل؛ ولا تمدّ يدك إلى الـ state أو الـ hooks مباشرة أبدًا.
  • استبدل الشبكة عند طرفها، إمّا بحقن fetch أو باعتراض الطلبات عبر MSW، كي يظلّ كود تشكيل البيانات الخاص بك يعمل في الاختبار.
  • أكّد الطلب الذي أرسلته (الـ URL والطريقة)، وأكّد أيضًا ما تفعله الواجهة أو الدالة بالاستجابة، بما في ذلك رموز حالة الخطأ.

قراءات إضافية

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

كيف يجب أن يتحقّق الاختبار من أن ExpenseList تُرشّح النتائج أثناء الكتابة في مربّع Search؟

السؤال 2 من 3

لماذا تفضّل فرق كثيرة MSW على vi.mock('./api.js') للمكوّنات التي تجلب البيانات؟

السؤال 3 من 3

نسيت loadGroup أن تفحص response.ok. مع استجابة 404 يكون الـ JSON هو { "error": "Not found" }، فيرمي الكود Cannot read properties of undefined. أيّ assertion يلتقط الفحص المفقود؟

تدريب

بيئة تجريب الكود

زيّف الشبكة عند طرفها

تطلب loadGroup(groupId, fetchImpl) العنوان https://api.splitwise-lite.test/groups/<id>، وتحوّل الـ JSON القادم من الـ API (بالدولار، وبصيغة snake_case) إلى شكل Splitwise-lite (بالسنتات، وبصيغة camelCase)، وتُرفض بالرسالة Could not load group <id> (HTTP <status>) حين لا تكون الاستجابة ok. الأشكال الدقيقة مذكورة في تعليق الـ subject.

يعطيك الكود المبدئي jsonResponse(body, status) التي تبني Response حقيقيًا، وعيّنة apiGroup. مرّر vi.fn().mockResolvedValue(jsonResponse(...)) على أنه fetchImpl وأضف اختبارات حتى تلتقط مجموعتك أربعة أخطاء مخفيّة: نسخة تتجاهل رمز حالة HTTP، ونسخة تطلب URL خاطئًا، ونسخة تنسى تحويل الدولارات إلى سنتات، ونسخة تقرأ paidBy من الحقل الخاطئ.

const API = 'https://api.splitwise-lite.test';

// Loads a group: GET `${API}/groups/<id>` (id URL-encoded).
// The API answers with JSON like:
//   { "name": "Lisbon trip",
//     "members": [{ "name": "Ana" }, { "name": "Ben" }],
//     "expenses": [{ "description": "Dinner", "amount": 45.5,
//                    "paid_by": "Ana", "split_among": ["Ana", "Ben"] }] }
// and loadGroup resolves with Splitwise-lite's own shape:
//   { name: "Lisbon trip", members: ["Ana", "Ben"],
//     expenses: [{ description: "Dinner", amountCents: 4550,
//                  paidBy: "Ana", splitAmong: ["Ana", "Ben"] }] }
// If the response is not ok, it rejects with
//   "Could not load group <id> (HTTP <status>)".
async function loadGroup(groupId, fetchImpl = fetch) {
  const response = await fetchImpl(`${API}/groups/${encodeURIComponent(groupId)}`);
  if (!response.ok) {
    throw new Error(`Could not load group ${groupId} (HTTP ${response.status})`);
  }
  const data = await response.json();
  return {
    name: data.name,
    members: data.members.map((member) => member.name),
    expenses: data.expenses.map((e) => ({
      description: e.description,
      amountCents: Math.round(e.amount * 100),
      paidBy: e.paid_by,
      splitAmong: e.split_among,
    })),
  };
}
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

النتائج

(لا مخرجات)

الفحوص

  • كل اختباراتك تنجح على loadGroup الحقيقية
  • تلتقط النسخة التي تتجاهل رمز حالة HTTP
  • تلتقط النسخة التي تطلب URL خاطئًا
  • تلتقط النسخة التي تُبقي الدولارات بدل السنتات
  • تلتقط النسخة التي تقرأ paidBy من الحقل الخاطئ