الوحدة 3 · اختبار واجهة المستخدم
اختبار مكوّنات React والشبكة
20 دقراءة 3 دبيئة تجريب الكود
الدرس في لمحة
تختبر مكوّنات React من خلال الـ props والمخرجات المعروضة وتفاعل المستخدم باستخدام React Testing Library، وتستبدل الشبكة عند حدودها بـ fetch محقون أو بـ MSW.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
كل ما جاء في هذا القسم حتى الآن ينطبق على React دون تغيير، لأن React Testing Library طبقة رقيقة فوق الاستعلامات نفسها وuser-event الذي تعرفه. الجزء الجديد حقًّا هو الشبكة: مكوّنات React تحبّ جلب البيانات، والطريقة التي تزيّف بها ذلك تقرّر إن كانت اختباراتك ستلتقط أخطاءً حقيقية.
عرض مكوّن
يأخذ المكوّن ExpenseList في Splitwise-lite المصروفات كـ prop، ولديه جزء واحد من الـ state (الحالة)، وهو مرشّح البحث:
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 التي كتبتها:
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، الذي يمرّ عبر الشبكة. تستطيع قطع هذه السلسلة عند ثلاث نقاط:
- استبدال وحدة البيانات بـ mock (
vi.mock('./load-group.js')) يتخطّى بناء الـ URL، ومعالجة رمز الحالة، وتشكيل الاستجابة. وهذه بالضبط مواضع الأخطاء. - حقن
fetch(loadGroup(id, fetchImpl)) يشغّل كل كودك ولا يزيّف إلا وسيلة النقل. بسيط، وبلا مكتبة، وهو ما يستخدمه التمرين. - MSW (Mock Service Worker) يعترض الطلبات على مستوى الشبكة في Node وفي المتصفّح. يستدعي كودك
fetchالحقيقي دون أي تغيير:
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 والطريقة)، وأكّد أيضًا ما تفعله الواجهة أو الدالة بالاستجابة، بما في ذلك رموز حالة الخطأ.