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

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

اختبار الواجهة غير المتزامنة

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

الدرس في لمحة

تختبر واجهة تتغيّر بعد تحميل البيانات أو فشلها، باستخدام استعلامات findBy وwaitFor بدل الانتظار الثابت، وتتحكّم في الاعتمادية غير المتزامنة لتختبر حالات التحميل والنجاح والخطأ.

  1. 1الطريقة الخاطئة: الانتظار الثابت (sleep)
  2. 2findBy: انتظر عنصرًا
  3. 3waitFor: انتظر أي شيء آخر
  4. 4التحكّم في الزمن من جهة الاختبار
  5. 5حين لا تكفي المهلة الافتراضية
  6. 6في ساحة التجربة

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

تحمّل لوحة الأرصدة في Splitwise-lite بياناتها من الخادم. للحظة تعرض “Loading balances…”، ثم تعرض القائمة، أو، في قطار داخل نفق، “Could not load balances.” مع زر “Try again”. كل حالة من هذه الحالات شيء يراه المستخدم، لذا تستحق كل منها اختبارًا. الصعوبة في الزمن: الـ DOM الذي تريد فحصه لم يوجد بعد حين يصل الاختبار إلى السطر الذي يفحصه.

الطريقة الخاطئة: الانتظار الثابت (sleep)

أول ما يخطر في البال هو الانتظار قليلًا:

JavaScript
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 افتراضيًا):

src/balance-panel.test.js
// @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 حتى تنجح أو تنتهي المهلة. استخدمه للشروط التي ليست “ظهر عنصر”:

JavaScript
await waitFor(() => expect(loadBalances).toHaveBeenCalledTimes(2));

ولدى Vitest دالتها الخاصة vi.waitFor التي تعمل بالطريقة نفسها خارج الـ DOM. وللحالة المعاكسة، ينتظر waitForElementToBeRemoved(() => screen.queryByRole('status')) اختفاء شيء ما.

خط زمني: يُعاد تشغيل الاستعلام كل 50 ملّي ثانية؛ يفشل أثناء التحميل، ثم ينجح بمجرد عرض القائمة، قبل مهلة الـ 1000 ملّي ثانية بوقت طويل. 0 ms 1000 ms المهلة لم تظهر بعد وُجدت القائمة يكمل الاختبار فورًا
يستطلع findBy وwaitFor حتى يتحقّق الشرط

التحكّم في الزمن من جهة الاختبار

يتولّى findBy الانتظار. ولتختبر كل حالة بشكل حتمي، تتحكّم أيضًا في الاعتمادية. ولأن loadBalances تُمرَّر كمعامل، يستطيع vi.fn أن يجعلها تفعل أي شيء:

  • mockResolvedValue(data) للنجاح؛
  • mockRejectedValue(new Error('Offline')) للفشل؛
  • promise تحلّه يدويًا، لتجميد حالة التحميل:
JavaScript
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 يُحلّ، أو يُرفض، أو يبقى معلّقًا)، كي تكون حالات التحميل والنجاح والخطأ وإعادة المحاولة كل منها اختبارًا حتميًّا واحدًا.

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

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

تظهر قائمة الأرصدة بعد أن يُحلّ loadBalances(). أيّ سطر ينتظرها بشكل صحيح؟

السؤال 2 من 3

ما المشكلة هنا؟

JavaScript
await waitFor(() => {
  user.click(screen.getByRole('button', { name: 'Try again' }));
  expect(loadBalances).toHaveBeenCalledTimes(2);
});
السؤال 3 من 3

كيف تختبر أن رسالة “Loading balances…” تظهر قبل وصول البيانات، دون أن تتسابق مع الـ promise؟

تدريب

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

التحميل والخطأ وإعادة المحاولة

تعرض mountBalancePanel(container, loadBalances) الرسالة “Loading balances…” في role="status" ما دام loadBalances() معلّقًا، ثم قائمة Balances. وإن رُفض الـ promise، تعرض “Could not load balances.” في role="alert" مع زر Try again يحمّل من جديد.

يضيف الكود المبدئي waitFor وfindByRole وfindByText إلى البدائل (وهي تستطلع حتى 500 ms). تحكّم في loadBalances باستخدام vi.fn() وأضف اختبارات حتى تلتقط مجموعتك أربعة أخطاء مخفيّة: نسخة لا تعرض حالة التحميل أبدًا، ونسخة تُبقيها على الشاشة بعد وصول البيانات، ونسخة لا تعرض شيئًا عند الفشل، ونسخة لا يفعل زر Try again فيها شيئًا.

function formatMoney(cents) {
  const sign = cents < 0 ? '-' : '';
  const abs = Math.abs(cents);
  return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;
}

function renderBalances(container, balances) {
  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));
  if (names.length === 0) {
    const empty = document.createElement('p');
    empty.textContent = 'No expenses yet';
    container.replaceChildren(empty);
    return;
  }
  const list = document.createElement('ul');
  list.setAttribute('aria-label', 'Balances');
  for (const name of names) {
    const cents = balances[name];
    const item = document.createElement('li');
    item.textContent =
      cents > 0 ? `${name} is owed ${formatMoney(cents)}`
      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`
      : `${name} is settled up`;
    list.append(item);
  }
  container.replaceChildren(list);
}

// Shows balances loaded with loadBalances() (returns a promise).
// - While loading: <p role="status">Loading balances…</p>
// - On success: the "Balances" list from renderBalances; the status is gone.
// - On failure: <p role="alert">Could not load balances.</p> and a
//   "Try again" button that calls loadBalances again.
function mountBalancePanel(container, loadBalances) {
  async function load() {
    container.innerHTML = '<p role="status">Loading balances…</p>';
    try {
      const balances = await loadBalances();
      renderBalances(container, balances);
    } catch {
      container.innerHTML =
        '<p role="alert">Could not load balances.</p><button type="button">Try again</button>';
      container.querySelector('button').addEventListener('click', load);
    }
  }
  load();
}
جارٍ تحميل المحرّر…

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

النتائج

(لا مخرجات)

الفحوص

  • كل اختباراتك تنجح على اللوحة الحقيقية
  • تلتقط النسخة التي لا تعرض حالة التحميل
  • تلتقط النسخة التي تُبقي حالة التحميل على الشاشة
  • تلتقط النسخة التي لا تعرض شيئًا عند الفشل
  • تلتقط النسخة التي لا يفعل زر Try again فيها شيئًا