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

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

الاستعلام عن الـ DOM كما يراه المستخدم

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

الدرس في لمحة

تجد العناصر في اختبارات الـ DOM بالطريقة التي يجدها بها المستخدمون والتقنيات المساعدة، باستخدام استعلامات Testing Library بالدور والتسمية والنص، وفق ترتيب الأولوية الموصى به.

  1. 1المبدأ الموجّه
  2. 2ترتيب الأولوية
  3. 3اختبار DOM باستخدام Testing Library
  4. 4getBy وqueryBy وfindBy
  5. 5حين لا يجد الـ query عنصرك
  6. 6في ساحة التجربة

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

إليك اختبارًا وجدته في قاعدة كود العام الماضي: document.querySelector('.card > div:nth-child(2) span.amount-red'). انكسر حين غيّر مصمّم لون المبالغ السالبة. لم ينكسر شيء مما يراه المستخدم، باستثناء اللون، الذي لم يختبره أحد أصلًا. كان الاختبار مرتبطًا بشكل الـ DOM وبالـ CSS، لا بما تقوله الصفحة.

المبدأ الموجّه

فكرة Testing Library الجوهرية تتّسع لها جملة واحدة: كلما أشبهت اختباراتك الطريقة التي يُستخدم بها برنامجك، زادت الثقة التي تمنحك إياها. لا يرى المستخدمون أسماء الأصناف ولا nth-child. بل يرون زرًّا عنوانه “Add expense”، وحقلًا تسميته “Amount”، وقائمة أرصدة. ويحصل مستخدمو قارئ الشاشة على هذه المعلومات بالضبط عبر شجرة إمكانية الوصول (accessibility tree): دور كل عنصر (button وlist وtextbox) واسمه القابل للوصول (تسميته أو نصّه).

لذا تجد استعلامات Testing Library العناصر بالدور والتسمية والنص. والاختبار الذي يجد زر “Settle up” بدوره واسمه يظلّ ينجح مع أي إعادة تصميم، ويفشل في اليوم الذي يتوقّف فيه الزر عن أن يكون متاحًا لمستخدمي لوحة المفاتيح وقارئ الشاشة. وهذا خطأ يستحق الالتقاط.

ترتيب الأولوية

ثلاث طبقات مكدّسة: متاح للجميع (ByRole وByLabelText وByPlaceholderText وByText وByDisplayValue)، ودلالي (ByAltText وByTitle)، والـ test ids في الأخير. 1. متاح للجميع ByRole · ByLabelText · ByPlaceholderText · ByText · ByDisplayValue 2. دلالي ByAltText · ByTitle 3. الملاذ الأخير ByTestId
أولوية الاستعلامات: ابدأ من الأعلى، ولا تنزل إلا عند الضرورة

يغطّي getByRole مع الخيار name معظم الحالات: الأزرار، والروابط، والعناوين، وحقول النماذج، والقوائم، والحوارات، والتنبيهات. وgetByLabelText هو الخيار الطبيعي لحقول النماذج. ويجد getByText المحتوى غير التفاعلي مثل رسالة أو سطر رصيد. أما الـ test ids فللعناصر التي لا دور لها ولا نص، مثل لوحة رسم بياني (canvas)، وهي لا تثبت شيئًا عن إمكانية الوصول.

اختبار DOM باستخدام Testing Library

يعرض Splitwise-lite الأرصدة كقائمة بسيطة. مع ضبط البيئة على jsdom وتثبيت @testing-library/dom، يبدو الاختبار هكذا:

src/render-balances.test.js
// @vitest-environment jsdom
import { afterEach, test, expect } from 'vitest';
import { screen, within } from '@testing-library/dom';
import { renderBalances } from './render-balances.js';

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

test('lists each person with what they owe or are owed', () => {
  renderBalances(document.body, { Ben: -500, Ana: 1200, Cai: 0 });

  const list = screen.getByRole('list', { name: 'Balances' });
  const items = within(list).getAllByRole('listitem');

  expect(items.map((item) => item.textContent)).toEqual([
    'Ana is owed $12.00',
    'Ben owes $5.00',
    'Cai is settled up',
  ]);
});

test('shows an empty state instead of an empty list', () => {
  renderBalances(document.body, {});

  expect(screen.getByText('No expenses yet')).toBeTruthy();
  expect(screen.queryByRole('list')).toBeNull();
});

يستعلم screen في المستند كله. ويحصر within(element) الاستعلامات في جزء منه، وهذا مهم بمجرد أن تحتوي الصفحة على قائمتين. وتنظيف document.body بعد كل اختبار يمنع الـ DOM الخاص باختبار من التسرّب إلى الاختبار التالي. (تستطيع React Testing Library أن تتولّى هذا التنظيف عنك؛ ويوضّح الدرس الرابع من هذا القسم متى يحدث ذلك.)

getBy وqueryBy وfindBy

لكل استعلام ثلاث نكهات، إضافة إلى نسخة All من كل منها:

البادئة لا يوجد تطابق عدة تطابقات استخدمها لـ
getBy يرمي خطأً يرمي خطأً عناصر يجب أن تكون موجودة
queryBy يعيد null يرمي خطأً تأكيد أن شيئًا ما غائب
findBy يُرفض بعد الانتظار يُرفض عناصر تظهر لاحقًا

رمي getBy خطأً عند وجود عدة تطابقات ميزة لا عيب: إن كان استعلامك ملتبسًا، يخبرك الاختبار بدل أن يختار الأول بصمت.

حين لا يجد الـ query عنصرك

تفشل استعلامات الدور بصوت عالٍ، وفشلها مفيد. حين لا يجد getByRole('button', { name: 'Settle up' }) شيئًا، تطبع Testing Library كل دور متاح داخل الحاوية مع الأسماء التي حسبتها، فترى أن اسم الزر في الحقيقة “Settle up all debts”، أو أن الـ div القابل للنقر لديك بلا دور أصلًا. استدعِ screen.debug() لطباعة الـ DOM الحالي حين تحتاج إلى سياق أكثر. تأتي الأسماء المتاحة من بضعة مصادر، بهذا الترتيب تقريبًا: aria-labelledby، ثم aria-label، ثم <label> مرتبط، ثم نص العنصر نفسه (أو alt للصور). وإن لم يعطِ أيّ منها الاسم الذي تتوقّعه، فمستخدم قارئ الشاشة يسمع الشيء المربك نفسه الذي يراه اختبارك.

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

لا توجد حزم npm في ساحة التجربة، لذا تبدأ تمارين هذا القسم بنحو أربعين سطرًا من البدائل: getByRole وgetAllByRole وqueryByRole وgetByText وqueryByText، بالأسماء والسلوك نفسه في الحالات التي تحتاجها. ومثل الدوال المصدَّرة مباشرة من @testing-library/dom (لا عبر screen)، تأخذ هذه البدائل الحاوية معاملًا أول: getByRole(container, 'list'). تقرأ الدور من الوسم (أو من خاصية role) والاسم من aria-label أو <label> أو النص. أما Testing Library الحقيقية فتحسب شجرة إمكانية الوصول بشمول أكبر بكثير، لذا ثبّتها في مشاريعك.

الدرس القادم: النقر والكتابة كما يفعل المستخدم.

أهم النقاط

  • استعلم بما يدركه المستخدمون (الدور والاسم القابل للوصول، والتسمية، والنص الظاهر)، لا بأصناف CSS أو بنية الـ DOM، كي لا تنكسر الاختبارات حين يتغيّر التصميم.
  • ترتيب الأولوية هو: getByRole، ثم getByLabelText وgetByPlaceholderText وgetByText وgetByDisplayValue، ثم النص البديل والعنوان، وgetByTestId في الأخير.
  • يرمي getBy خطأً حين يجد صفر تطابقات أو عدة تطابقات، ويعيد queryBy القيمة null (استخدمه لتأكيد الغياب)، وينتظر findBy ظهور العنصر.
  • إن لم يجد استعلام الدور عنصرك، فغالبًا ما تكون البنية غير قابلة للوصول بعد؛ أصلح البنية بدل الانتقال إلى test id.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

يُعرض زر “Settle up” هكذا: <button class="btn btn-primary" data-testid="settle">Settle up</button>. أيّ query يجب أن يستخدمه الاختبار؟

السؤال 2 من 4

تريد تأكيد أن رسالة الحالة الفارغة اختفت بعد إضافة مصروف. أيّ query يناسب؟

السؤال 3 من 4

يفشل screen.getByRole('textbox', { name: 'Amount' }) بالرسالة “Unable to find an accessible element with the role textbox and name Amount”. تعرض الصفحة كلمة Amount بجوار حقل الإدخال. ما الإصلاح الأرجح؟

السؤال 4 من 4

أيّ زوج من الاستعلامات هو الأقرب إلى الطريقة التي يجد بها مستخدم مبصر يستعمل الفأرة ومستخدم قارئ الشاشة قائمةَ الأرصدة؟

تدريب

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

استعلم عن الأرصدة كما يفعل المستخدم

تعرض renderBalances(container, balances) قائمة تسميتها Balances: "Ana is owed $12.00"، "Ben owes $5.00"، "Cai is settled up"، مرتّبة حسب الاسم. وحين لا يوجد أشخاص تعرض No expenses yet دون أي قائمة.

يبدأ الكود المبدئي ببدائل صغيرة لاستعلامات Testing Library (getByRole وgetAllByRole وqueryByRole وgetByText وqueryByText؛ وكلها تأخذ الحاوية معاملًا أول)، وينشئ container جديدة لكل اختبار. أضف اختبارات حتى تلتقط مجموعتك أربعة أخطاء مخفيّة: صياغة owes/is owed متبادلة، ونسخة تُخفي من سوّوا حساباتهم، ونسخة تطبع إشارة سالب (“owes -$5.00”)، ونسخة بلا حالة فارغة.

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

// Renders balances (in cents) into container.
// - A list labelled "Balances", one item per person, sorted by name:
//     positive -> "Ana is owed $12.00"
//     negative -> "Ben owes $5.00"
//     zero     -> "Cai is settled up"
// - With no people at all: a paragraph "No expenses yet" and no list.
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);
}
جارٍ تحميل المحرّر…

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

النتائج

(لا مخرجات)

الفحوص

  • كل اختباراتك تنجح على renderBalances الحقيقية
  • تلتقط النسخة التي تبادل بين “owes” و"is owed"
  • تلتقط النسخة التي تُخفي من سوّوا حساباتهم
  • تلتقط النسخة التي تطبع “owes -$5.00”
  • تلتقط النسخة التي لا تعرض حالة فارغة