الوحدة 3 · اختبار واجهة المستخدم
محاكاة تفاعل المستخدم
18 دقراءة 3 دبيئة تجريب الكود
الدرس في لمحة
تقود النماذج والأزرار في الاختبارات كما يفعل الإنسان، وتختار user-event بدل fireEvent، وتؤكّد ما يراه المستخدم وما يبلّغ عنه الكود.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
نموذج المصروفات هو المكان الذي يلتقي فيه 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.
اجعل user-event خيارك الافتراضي. ولا تلجأ إلى fireEvent إلا مع أحداث لا يحاكيها user-event، مثل حدث مخصّص أو scroll.
اختبار نموذج المصروفات
في النموذج حقلان لهما تسميتان، وزر “Add expense”، ومنطقة للتنبيه. إليك المسار السعيد، مكتوبًا بالطريقة التي ستصفه بها لزميل:
// @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” يجب أن:
- يعرض السبب في عنصر له
role="alert"، وهو ما تعلنه قارئات الشاشة؛ - لا يستدعي
onAdd؛ - يُبقي ما كتبه المستخدم، كي يصلح حرفًا واحدًا بدل إعادة الكتابة.
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)، والخطأ الذي سيقرؤه المستخدم، وحالة الحقول بعد ذلك.
- اختبر المسار غير الصالح بالعناية نفسها التي تختبر بها الصالح؛ “لم يحدث شيء” سلوك يجب تأكيده.