الوحدة 3 · اختبار واجهة المستخدم
اختبار الواجهة غير المتزامنة
18 دقراءة 3 دبيئة تجريب الكود
الدرس في لمحة
تختبر واجهة تتغيّر بعد تحميل البيانات أو فشلها، باستخدام استعلامات findBy وwaitFor بدل الانتظار الثابت، وتتحكّم في الاعتمادية غير المتزامنة لتختبر حالات التحميل والنجاح والخطأ.
- 1الطريقة الخاطئة: الانتظار الثابت (sleep)
- 2findBy: انتظر عنصرًا
- 3waitFor: انتظر أي شيء آخر
- 4التحكّم في الزمن من جهة الاختبار
- 5حين لا تكفي المهلة الافتراضية
- 6في ساحة التجربة
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
تحمّل لوحة الأرصدة في Splitwise-lite بياناتها من الخادم. للحظة تعرض “Loading balances…”، ثم تعرض القائمة، أو، في قطار داخل نفق، “Could not load balances.” مع زر “Try again”. كل حالة من هذه الحالات شيء يراه المستخدم، لذا تستحق كل منها اختبارًا. الصعوبة في الزمن: الـ DOM الذي تريد فحصه لم يوجد بعد حين يصل الاختبار إلى السطر الذي يفحصه.
الطريقة الخاطئة: الانتظار الثابت (sleep)
أول ما يخطر في البال هو الانتظار قليلًا:
mountBalancePanel(document.body, loadBalances);
await new Promise((resolve) => setTimeout(resolve, 500)); // please be enough
expect(screen.getByRole('list', { name: 'Balances' })).toBeInTheDocument();على حاسوبك تصل البيانات في 5 ms، فيضيّع الاختبار 495. وعلى خادم CI مشغول تستغرق أحيانًا 600، فيفشل الاختبار بلا سبب. اضرب ذلك في بضع مئات من الاختبارات، فتحصل على مجموعة بطيئة حمراء كل ثلاثاء. انتظر الشرط، لا الساعة.
findBy: انتظر عنصرًا
استعلامات findBy هي استعلامات getBy تعيد المحاولة. تعيد promise يُحلّ بمجرد أن يوجد العنصر، ويُرفض إن لم يظهر خلال المهلة (1,000 ms افتراضيًا):
// @vitest-environment jsdom
import { afterEach, test, expect, vi } from 'vitest';
import { screen } from '@testing-library/dom';
import '@testing-library/jest-dom/vitest';
import { mountBalancePanel } from './balance-panel.js';
afterEach(() => {
document.body.innerHTML = '';
});
test('shows a loading message, then the balances', async () => {
const loadBalances = vi.fn().mockResolvedValue({ Ana: 500, Ben: -500 });
mountBalancePanel(document.body, loadBalances);
expect(screen.getByRole('status')).toHaveTextContent('Loading balances');
expect(await screen.findByRole('list', { name: 'Balances' })).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});الاختبار سريع حين يكون الكود سريعًا، وصبور حين يكون بطيئًا، ويُقرأ كقصة ما يراه المستخدم. والسطر الأخير مهم أيضًا: اللوحة التي تعرض البيانات لكنها تترك “Loading…” على الشاشة فيها خطأ حقيقي.
waitFor: انتظر أي شيء آخر
يشغّل waitFor(callback) الدالة، فإن رمت خطأً، يعيد تشغيلها كل 50 ms حتى تنجح أو تنتهي المهلة. استخدمه للشروط التي ليست “ظهر عنصر”:
await waitFor(() => expect(loadBalances).toHaveBeenCalledTimes(2));ولدى Vitest دالتها الخاصة vi.waitFor التي تعمل بالطريقة نفسها خارج الـ DOM. وللحالة المعاكسة، ينتظر waitForElementToBeRemoved(() => screen.queryByRole('status')) اختفاء شيء ما.
التحكّم في الزمن من جهة الاختبار
يتولّى findBy الانتظار. ولتختبر كل حالة بشكل حتمي، تتحكّم أيضًا في الاعتمادية. ولأن loadBalances تُمرَّر كمعامل، يستطيع vi.fn أن يجعلها تفعل أي شيء:
mockResolvedValue(data)للنجاح؛mockRejectedValue(new Error('Offline'))للفشل؛- promise تحلّه يدويًا، لتجميد حالة التحميل:
test('keeps showing the loading message until data arrives', async () => {
let finish;
const loadBalances = vi.fn(() => new Promise((resolve) => { finish = resolve; }));
mountBalancePanel(document.body, loadBalances);
expect(screen.getByRole('status')).toHaveTextContent('Loading balances');
finish({ Ana: 0 });
expect(await screen.findByText('Ana is settled up')).toBeInTheDocument();
});لمسار إعادة المحاولة، ابدأ بـ mock يرفض، وانتظر التنبيه، ثم حوّل الـ mock إلى النجاح، وانقر “Try again” مرة واحدة، ثم استخدم findBy لإيجاد القائمة. كل خطوة شيء يفعله المستخدم أو يراه، ولا شيء يعتمد على سرعة الجهاز.
حين لا تكفي المهلة الافتراضية
يستسلم findBy وwaitFor بعد ثانية واحدة. وهذا أكثر من كافٍ لكود تتحكّم في اعتمادياته، وإن احتاج اختبار مكوّن إلى وقت أطول، فالسبب المعتاد استدعاء شبكة حقيقي أو مؤقّت حقيقي تسرّب إليه. أصلح التسرّب أولًا. وحين يكون الانتظار مشروعًا فعلًا، مرّر خيارات: screen.findByRole('list', {}, { timeout: 3000 }) (المعامل الثالث هو خيارات الانتظار) أو waitFor(callback, { timeout: 3000 }).
في اختبارات React سترى أحيانًا تحذيرًا بأن تحديثًا “was not wrapped in act(…)”. يعني ذلك عادةً أن تحديثًا للـ state حدث بعد أن توقّف الاختبار عن النظر: promise ما حُلّ متأخرًا. والإصلاح لا يكون تقريبًا أبدًا بلفّ الأشياء في act يدويًا؛ بل بانتظار الحالة النهائية التي سيراها المستخدم بـ await، مع استعلام findBy.
في ساحة التجربة
يضيف الكود المبدئي للتمرين waitFor(callback) وfindByRole(root, role, options) وfindByText(root, text) إلى بدائل الاستعلامات. تستطلع كل 20 ms وتستسلم بعد 500 ms، فيفشل الاختبار على نسخة معطوبة بسرعة.
ينقل الدرس القادم هذا كله إلى مكوّنات React، وإلى استدعاءات الشبكة التي تقف خلف loadBalances.
أهم النقاط
- تعيد استعلامات
findByوعدًا (promise) يُحلّ حين يظهر العنصر، وتعيد المحاولة حتى انتهاء المهلة (ثانية واحدة افتراضيًا). - يعيد
waitFor(callback)تشغيل الدالة حتى تتوقّف عن رمي الأخطاء؛ استخدمه مع assertions لا تتعلّق بظهور عنصر، مثل عدد استدعاءات mock. - لا تنتظر وقتًا ثابتًا أبدًا (sleep)؛ انتظر الشرط الذي يهمّك فعلًا.
- تحكّم في الاعتمادية غير المتزامنة بنفسك (
vi.fnيُحلّ، أو يُرفض، أو يبقى معلّقًا)، كي تكون حالات التحميل والنجاح والخطأ وإعادة المحاولة كل منها اختبارًا حتميًّا واحدًا.