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

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

محاكاة تفاعل المستخدم

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

الدرس في لمحة

تقود النماذج والأزرار في الاختبارات كما يفعل الإنسان، وتختار user-event بدل fireEvent، وتؤكّد ما يراه المستخدم وما يبلّغ عنه الكود.

  1. 1fireEvent مقابل user-event
  2. 2اختبار نموذج المصروفات
  3. 3المسار غير السعيد هو معظم العمل
  4. 4في ساحة التجربة

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

نموذج المصروفات هو المكان الذي يلتقي فيه Splitwise-lite بالناس الحقيقيين، والناس الحقيقيون يفعلون أشياء فوضوية: يتنقّلون بين الحقول بمفتاح Tab، ويلصقون " 45.50 "، وينقرون “Add” وحقل المبلغ ما زال فارغًا، وينقرون نقرًا مزدوجًا. لا يستطيع unit test لـ parseAmount أن يخبرك إن كان النموذج يربط كل ذلك بشكل صحيح. أما الاختبار الذي يستخدم النموذج كما يستخدمه إنسان فيستطيع.

fireEvent مقابل user-event

تأتي Testing Library بطريقتين للتفاعل.

fireEvent (من @testing-library/dom) يُطلق حدث DOM واحدًا بالضبط. fireEvent.click(button) يرسل click. وfireEvent.change(input, { target: { value: '45.50' } }) يضبط القيمة ويرسل change. إنه سريع ودقيق، لكنه ليس ما يحدث حين ينقر إنسان أو يكتب.

user-event (من @testing-library/user-event) يحاكي التفاعل نفسه. user.click(button) يُنتج أحداث المؤشّر والفأرة، وينقل التركيز، ثم يُطلق click، ويرفض النقر على زر معطّل أو مخفيّ تحت pointer-events: none، تمامًا كالمتصفّح الحقيقي. وuser.type(input, '45.50') ينقر الحقل، ثم يرسل لكل حرف keydown وkeypress وinput وkeyup.

يرسل fireEvent.click حدث click فقط. أما user.click فيرسل pointerdown وmousedown وfocus وpointerup وmouseup ثم click. fireEvent.click click user.click pointerdown mousedown focus pointerup mouseup click تعمل معالِجات أيّ من هذه الأحداث، كما في المتصفّح
حدث واحد مقابل التسلسل الذي يُحدثه إنسان

اجعل user-event خيارك الافتراضي. ولا تلجأ إلى fireEvent إلا مع أحداث لا يحاكيها user-event، مثل حدث مخصّص أو scroll.

اختبار نموذج المصروفات

في النموذج حقلان لهما تسميتان، وزر “Add expense”، ومنطقة للتنبيه. إليك المسار السعيد، مكتوبًا بالطريقة التي ستصفه بها لزميل:

src/expense-form.test.js
// @vitest-environment jsdom
import { afterEach, test, expect, vi } from 'vitest';
import { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { mountExpenseForm } from './expense-form.js';

afterEach(() => {
  document.body.innerHTML = '';
});

test('adds a valid expense in cents and clears the form', async () => {
  const user = userEvent.setup();
  const onAdd = vi.fn();
  mountExpenseForm(document.body, onAdd);

  await user.type(screen.getByRole('textbox', { name: 'Description' }), 'Dinner');
  await user.type(screen.getByRole('textbox', { name: 'Amount' }), '45.50');
  await user.click(screen.getByRole('button', { name: 'Add expense' }));

  expect(onAdd).toHaveBeenCalledWith({ description: 'Dinner', amountCents: 4550 });
  expect(screen.getByRole('textbox', { name: 'Amount' }).value).toBe('');
});

لاحظ ثلاثة أشياء. يأتي userEvent.setup() أولًا ويعطيك جلسة user تتتبّع حالة لوحة المفاتيح والمؤشّر عبر الاستدعاءات. وكل تفاعل يُنتظَر بـ await: فدوال user-event غير متزامنة، وبدون await تعمل الـ assertions قبل أن تنتهي الكتابة. والـ assertions تتعلّق بالنتائج: ما استلمته onAdd (العقد مع بقية التطبيق) وما يراه المستخدم الآن.

ومستخدمو لوحة المفاتيح مهمّون أيضًا. ينقل await user.tab() التركيز كما يفعل مفتاح Tab، ويضغط await user.keyboard('{Enter}') مفتاح Enter، فتستطيع اختبار أن النموذج يعمل دون فأرة.

ما لن يفعله user-event من أجلك

يحاكي user-event السلوك الافتراضي للمتصفّح في التفاعل، لكنه لا يرى تخطيط الـ CSS لديك. لن يلاحظ أن نافذة منبثقة تغطّي الزر بصريًا، أو أن زر “Add expense” خارج الشاشة على الهاتف. ليس في jsdom محرّك تخطيط، فكل الأحجام والمواقع أصفار. هذه مهام اختبارات الـ end-to-end في متصفّح حقيقي، وهذا ما يتّجه إليه القسم 4. أما في اختبارات الوحدات والمكوّنات، فيعطيك user-event تسلسل الأحداث الصحيح وسلوك التركيز الصحيح، وهذا يغطّي الغالبية العظمى من أخطاء النماذج.

المسار غير السعيد هو معظم العمل

تعيش معظم أخطاء النماذج في معالجة الأخطاء، فأعطها على الأقل عدد الاختبارات نفسه. كتابة abc كمبلغ ثم النقر على “Add expense” يجب أن:

  1. يعرض السبب في عنصر له role="alert"، وهو ما تعلنه قارئات الشاشة؛
  2. لا يستدعي onAdd؛
  3. يُبقي ما كتبه المستخدم، كي يصلح حرفًا واحدًا بدل إعادة الكتابة.
JavaScript
test('explains an invalid amount and keeps the input', async () => {
  const user = userEvent.setup();
  const onAdd = vi.fn();
  mountExpenseForm(document.body, onAdd);

  await user.type(screen.getByRole('textbox', { name: 'Description' }), 'Taxi');
  await user.type(screen.getByRole('textbox', { name: 'Amount' }), 'abc');
  await user.click(screen.getByRole('button', { name: 'Add expense' }));

  expect(screen.getByRole('alert').textContent).toMatch('Not a valid amount');
  expect(onAdd).not.toHaveBeenCalled();
  expect(screen.getByRole('textbox', { name: 'Amount' }).value).toBe('abc');
});

كل assertion يحمي من خطأ مختلف. احذف فحص التنبيه، فينجح الفشل الصامت. احذف not.toHaveBeenCalled، فينجح نموذج يحفظ قيمًا عبثية. احذف الأخير، فينجح نموذج يمسح ما كتبه المستخدم.

في ساحة التجربة

لا توجد حزمة user-event في البيئة المعزولة، لذا يتضمّن الكود المبدئي للتمرين دالتين مساعدتين صغيرتين: typeInto(element, text)، التي تضع التركيز على الحقل وترسل أحداث المفاتيح والإدخال لكل حرف، وclickOn(element)، التي ترسل تسلسل المؤشّر والفأرة، وتضع التركيز وتنقر (ولا تفعل شيئًا مع زر معطّل). كلتاهما متزامنة، فلا تضع قبلهما await. وتمنع البيئة المعزولة أيضًا الإرسال الحقيقي للنماذج، لذا يستمع العنصر إلى نقر الزر بدل حدث submit؛ أما في تطبيقك، فـ <form> حقيقي مع معالِج submit أفضل، لأنه يعطيك الإرسال بمفتاح Enter دون جهد.

بعد ذلك، أصعب جزء في اختبار الواجهة: الأشياء التي تحدث لاحقًا.

أهم النقاط

  • يُطلق fireEvent حدث DOM واحدًا؛ أما user-event فيؤدّي التسلسل الكامل الذي يُحدثه إنسان حقيقي (المؤشّر، والتركيز، وkeydown، وinput، وkeyup، والنقر).
  • أنشئ جلسة بـ userEvent.setup() وضع await قبل كل تفاعل، لأن دوال user-event غير متزامنة.
  • اختبر النموذج من خلال نتائجه: معاملات الدالة الراجعة (callback)، والخطأ الذي سيقرؤه المستخدم، وحالة الحقول بعد ذلك.
  • اختبر المسار غير الصالح بالعناية نفسها التي تختبر بها الصالح؛ “لم يحدث شيء” سلوك يجب تأكيده.

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

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

ينسّق حقل المبلغ المدخلات عند كل keydown. يستخدم اختبار fireEvent.change(input, { target: { value: '45.50' } })، فلا يعمل كود التنسيق أبدًا. لماذا؟

السؤال 2 من 3

ما المشكلة في هذا الاختبار؟

JavaScript
test('adds the expense', () => {
  const user = userEvent.setup();
  mountExpenseForm(document.body, onAdd);
  user.type(screen.getByRole('textbox', { name: 'Amount' }), '12');
  user.click(screen.getByRole('button', { name: 'Add expense' }));
  expect(onAdd).toHaveBeenCalled();
});
السؤال 3 من 3

كتابة 'abc' في حقل المبلغ ثم النقر على “Add expense” يجب أن يُظهر خطأً. أيّ مجموعة assertions تختبر هذا المسار على أفضل وجه؟

تدريب

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

استخدم نموذج المصروفات كما يستخدمه إنسان

تعرض mountExpenseForm(container, onAdd) حقلين تسميتاهما Description وAmount، وزر Add expense. النقرة الصالحة تستدعي onAdd({ description, amountCents }) وتمسح الحقول؛ وغير الصالحة تعرض السبب في role="alert"، ولا تستدعي onAdd، وتُبقي النص المكتوب.

يتضمّن الكود المبدئي بدائل الاستعلامات من التمرين السابق، إضافة إلى typeInto(el, text) وclickOn(el) اللتين تحاكيان user-event (وهما متزامنتان: بلا await). أضف اختبارات حتى تلتقط مجموعتك أربعة أخطاء مخفيّة: نسخة تستدعي onAdd حتى حين يكون المبلغ غير صالح، ونسخة لا تمسح الحقول، ونسخة تمرّر المبلغ بالدولار بدل السنتات، ونسخة لا تعرض التنبيه أبدًا.

function parseAmount(input) {
  const text = String(input).trim();
  if (!/^\d+(\.\d{1,2})?$/.test(text)) throw new Error(`Not a valid amount: "${input}"`);
  const [whole, fraction = ''] = text.split('.');
  const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));
  if (cents === 0) throw new Error('Amount must be greater than zero');
  return cents;
}

// Mounts the "Add expense" form into container.
// - Text fields labelled "Description" and "Amount", and an
//   "Add expense" button.
// - On click, if valid: calls onAdd({ description, amountCents }) with the
//   trimmed description and the amount in integer cents, hides any error,
//   then clears both fields.
// - If invalid: shows the reason in the element with role="alert",
//   does NOT call onAdd, and keeps what the user typed.
//   Blank description -> "Add a description"; bad amount -> parseAmount's message.
// (The playground blocks real form submission, so this reacts to the click.)
function mountExpenseForm(container, onAdd) {
  container.innerHTML = `
    <div>
      <label>Description <input name="description"></label>
      <label>Amount <input name="amount" inputmode="decimal"></label>
      <button type="button">Add expense</button>
      <p role="alert" hidden></p>
    </div>`;
  const [description, amount] = container.querySelectorAll('input');
  const alert = container.querySelector('[role="alert"]');
  container.querySelector('button').addEventListener('click', () => {
    let amountCents;
    try {
      if (!description.value.trim()) throw new Error('Add a description');
      amountCents = parseAmount(amount.value);
    } catch (error) {
      alert.textContent = error.message;
      alert.hidden = false;
      return;
    }
    alert.hidden = true;
    alert.textContent = '';
    onAdd({ description: description.value.trim(), amountCents });
    description.value = '';
    amount.value = '';
  });
}
جارٍ تحميل المحرّر…

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

النتائج

(لا مخرجات)

الفحوص

  • كل اختباراتك تنجح على النموذج الحقيقي
  • تلتقط النسخة التي تستدعي onAdd بمبلغ غير صالح
  • تلتقط النسخة التي لا تمسح الحقول
  • تلتقط النسخة التي ترسل الدولارات بدل السنتات
  • تلتقط النسخة التي لا تعرض التنبيه أبدًا