{"id":"l-tj-3-1","courseId":"testing-javascript","lang":"ar","kind":"lesson","summary":"تجد العناصر في اختبارات الـ DOM بالطريقة التي يجدها بها المستخدمون والتقنيات المساعدة، باستخدام استعلامات Testing Library بالدور والتسمية والنص، وفق ترتيب الأولوية الموصى به.","html":"<p>إليك اختبارًا وجدته في قاعدة كود العام الماضي: <code dir=\"ltr\">document.querySelector('.card &gt; div:nth-child(2) span.amount-red')</code>. انكسر حين غيّر مصمّم لون المبالغ السالبة. لم ينكسر شيء مما يراه المستخدم، باستثناء اللون، الذي لم يختبره أحد أصلًا. كان الاختبار مرتبطًا بشكل الـ DOM وبالـ CSS، لا بما <em>تقوله</em> الصفحة.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">المبدأ الموجّه</h2>\n<p>فكرة Testing Library الجوهرية تتّسع لها جملة واحدة: <em>كلما أشبهت اختباراتك الطريقة التي يُستخدم بها برنامجك، زادت الثقة التي تمنحك إياها.</em> لا يرى المستخدمون أسماء الأصناف ولا <code dir=\"ltr\">nth-child</code>. بل يرون زرًّا عنوانه “Add expense”، وحقلًا تسميته “Amount”، وقائمة أرصدة. ويحصل مستخدمو قارئ الشاشة على هذه المعلومات بالضبط عبر <strong>شجرة إمكانية الوصول</strong> (accessibility tree): دور كل عنصر (button وlist وtextbox) واسمه القابل للوصول (تسميته أو نصّه).</p>\n<p>لذا تجد استعلامات Testing Library العناصر بالدور والتسمية والنص. والاختبار الذي يجد زر “Settle up” بدوره واسمه يظلّ ينجح مع أي إعادة تصميم، ويفشل في اليوم الذي يتوقّف فيه الزر عن أن يكون متاحًا لمستخدمي لوحة المفاتيح وقارئ الشاشة. وهذا خطأ يستحق الالتقاط.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">ترتيب الأولوية</h2>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 640 250\" role=\"img\" aria-labelledby=\"l-tj-3-1-t1\"><defs><marker id=\"arrow-l-tj-3-1-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-tj-3-1-t1\">ثلاث طبقات مكدّسة: متاح للجميع (ByRole وByLabelText وByPlaceholderText وByText وByDisplayValue)، ودلالي (ByAltText وByTitle)، والـ test ids في الأخير.</title>\n  <rect class=\"d-box-success\" x=\"20\" y=\"20\" width=\"600\" height=\"80\" rx=\"12\"/>\n  <text class=\"d-label-strong\" x=\"40\" y=\"50\">1. متاح للجميع</text>\n  <text class=\"d-code\" x=\"40\" y=\"80\">ByRole · ByLabelText · ByPlaceholderText · ByText · ByDisplayValue</text>\n  <rect class=\"d-box-accent\" x=\"20\" y=\"115\" width=\"600\" height=\"56\" rx=\"12\"/>\n  <text class=\"d-label-strong\" x=\"40\" y=\"148\">2. دلالي</text>\n  <text class=\"d-code\" x=\"220\" y=\"148\">ByAltText · ByTitle</text>\n  <rect class=\"d-box-warn\" x=\"20\" y=\"186\" width=\"600\" height=\"50\" rx=\"12\"/>\n  <text class=\"d-label-strong\" x=\"40\" y=\"216\">3. الملاذ الأخير</text>\n  <text class=\"d-code\" x=\"220\" y=\"216\">ByTestId</text>\n</svg><figcaption>أولوية الاستعلامات: ابدأ من الأعلى، ولا تنزل إلا عند الضرورة</figcaption></figure>\n<p>يغطّي <code dir=\"ltr\">getByRole</code> مع الخيار <code dir=\"ltr\">name</code> معظم الحالات: الأزرار، والروابط، والعناوين، وحقول النماذج، والقوائم، والحوارات، والتنبيهات. و<code dir=\"ltr\">getByLabelText</code> هو الخيار الطبيعي لحقول النماذج. ويجد <code dir=\"ltr\">getByText</code> المحتوى غير التفاعلي مثل رسالة أو سطر رصيد. أما الـ test ids فللعناصر التي لا دور لها ولا نص، مثل لوحة رسم بياني (canvas)، وهي لا تثبت شيئًا عن إمكانية الوصول.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">اختبار DOM باستخدام Testing Library</h2>\n<p>يعرض Splitwise-lite الأرصدة كقائمة بسيطة. مع ضبط البيئة على <code dir=\"ltr\">jsdom</code> وتثبيت <code dir=\"ltr\">@testing-library/dom</code>، يبدو الاختبار هكذا:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">src/render-balances.test.js</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"نسخ الكود\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>نسخ</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// @vitest-environment jsdom</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { afterEach, test, expect } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'vitest'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { screen, within } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '@testing-library/dom'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { renderBalances } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './render-balances.js'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">afterEach</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  document.body.innerHTML </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> ''</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'lists each person with what they owe or are owed'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, () </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  renderBalances</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, { Ben: </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">-</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">500</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, Ana: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1200</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, Cai: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> list</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'list'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Balances'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> });</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> items</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> within</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(list).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getAllByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'listitem'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(items.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">map</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">item</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> item.textContent)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toEqual</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">([</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">    'Ana is owed $12.00'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">    'Ben owes $5.00'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">    'Cai is settled up'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  ]);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'shows an empty state instead of an empty list'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, () </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  renderBalances</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, {});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByText</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'No expenses yet'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBeTruthy</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">queryByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'list'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBeNull</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span></code></pre></div>\n<p>يستعلم <code dir=\"ltr\">screen</code> في المستند كله. ويحصر <code dir=\"ltr\">within(element)</code> الاستعلامات في جزء منه، وهذا مهم بمجرد أن تحتوي الصفحة على قائمتين. وتنظيف <code dir=\"ltr\">document.body</code> بعد كل اختبار يمنع الـ DOM الخاص باختبار من التسرّب إلى الاختبار التالي. (تستطيع React Testing Library أن تتولّى هذا التنظيف عنك؛ ويوضّح الدرس الرابع من هذا القسم متى يحدث ذلك.)</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">getBy وqueryBy وfindBy</h2>\n<p>لكل استعلام ثلاث نكهات، إضافة إلى نسخة <code dir=\"ltr\">All</code> من كل منها:</p>\n<div class=\"table-wrap\" role=\"region\" aria-label=\"جدول قابل للتمرير\" tabindex=\"0\"><table><thead>\n<tr>\n<th>البادئة</th>\n<th>لا يوجد تطابق</th>\n<th>عدة تطابقات</th>\n<th>استخدمها لـ</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code dir=\"ltr\">getBy</code></td>\n<td>يرمي خطأً</td>\n<td>يرمي خطأً</td>\n<td>عناصر يجب أن تكون موجودة</td>\n</tr>\n<tr>\n<td><code dir=\"ltr\">queryBy</code></td>\n<td>يعيد <code dir=\"ltr\">null</code></td>\n<td>يرمي خطأً</td>\n<td>تأكيد أن شيئًا ما <strong>غائب</strong></td>\n</tr>\n<tr>\n<td><code dir=\"ltr\">findBy</code></td>\n<td>يُرفض بعد الانتظار</td>\n<td>يُرفض</td>\n<td>عناصر تظهر <strong>لاحقًا</strong></td>\n</tr>\n</tbody>\n</table></div><p>رمي <code dir=\"ltr\">getBy</code> خطأً عند وجود عدة تطابقات ميزة لا عيب: إن كان استعلامك ملتبسًا، يخبرك الاختبار بدل أن يختار الأول بصمت.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">نصيحة</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>assertions أجمل مع jest-dom</span></p>\n<p>أضف <code dir=\"ltr\">import '@testing-library/jest-dom/vitest'</code> إلى ملف إعداد، فتحصل على matchers خاصة بالـ DOM مثل <code dir=\"ltr\">toBeInTheDocument()</code> و<code dir=\"ltr\">toHaveTextContent()</code> و<code dir=\"ltr\">toBeVisible()</code> و<code dir=\"ltr\">toBeDisabled()</code> و<code dir=\"ltr\">toHaveAccessibleName()</code>. رسائل الفشل فيها تطبع الجزء المعنيّ من الـ DOM، وهذا أفضل بكثير من قراءة <code dir=\"ltr\">expected null not to be null</code>.</p>\n</aside>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">خطأ شائع</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>الانتقال إلى test id حين يفشل استعلام الدور</span></p>\n<p>حين لا يجد <code dir=\"ltr\">getByRole('textbox', { name: 'Amount' })</code> حقل الإدخال، يكون السبب المعتاد أن الحقل فعلًا بلا اسم قابل للوصول: كلمة “Amount” تجاوره لكنها ليست <code dir=\"ltr\">&lt;label&gt;</code>. إضافة test id تجعل الاختبار ينجح وتترك النموذج معطّلًا لمستخدمي قارئ الشاشة. أصلح البنية؛ فالاختبار كان محقًّا.</p>\n</aside>\n<h2 id=\"sec-5\" tabindex=\"-1\">حين لا يجد الـ query عنصرك</h2>\n<p>تفشل استعلامات الدور بصوت عالٍ، وفشلها مفيد. حين لا يجد <code dir=\"ltr\">getByRole('button', { name: 'Settle up' })</code> شيئًا، تطبع Testing Library كل دور متاح داخل الحاوية مع الأسماء التي حسبتها، فترى أن اسم الزر في الحقيقة “Settle up all debts”، أو أن الـ <code dir=\"ltr\">div</code> القابل للنقر لديك بلا دور أصلًا. استدعِ <code dir=\"ltr\">screen.debug()</code> لطباعة الـ DOM الحالي حين تحتاج إلى سياق أكثر. تأتي الأسماء المتاحة من بضعة مصادر، بهذا الترتيب تقريبًا: <code dir=\"ltr\">aria-labelledby</code>، ثم <code dir=\"ltr\">aria-label</code>، ثم <code dir=\"ltr\">&lt;label&gt;</code> مرتبط، ثم نص العنصر نفسه (أو <code dir=\"ltr\">alt</code> للصور). وإن لم يعطِ أيّ منها الاسم الذي تتوقّعه، فمستخدم قارئ الشاشة يسمع الشيء المربك نفسه الذي يراه اختبارك.</p>\n<h2 id=\"sec-6\" tabindex=\"-1\">في ساحة التجربة</h2>\n<p>لا توجد حزم npm في ساحة التجربة، لذا تبدأ تمارين هذا القسم بنحو أربعين سطرًا من البدائل: <code dir=\"ltr\">getByRole</code> و<code dir=\"ltr\">getAllByRole</code> و<code dir=\"ltr\">queryByRole</code> و<code dir=\"ltr\">getByText</code> و<code dir=\"ltr\">queryByText</code>، بالأسماء والسلوك نفسه في الحالات التي تحتاجها. ومثل الدوال المصدَّرة مباشرة من <code dir=\"ltr\">@testing-library/dom</code> (لا عبر <code dir=\"ltr\">screen</code>)، تأخذ هذه البدائل الحاوية معاملًا أول: <code dir=\"ltr\">getByRole(container, 'list')</code>. تقرأ الدور من الوسم (أو من خاصية <code dir=\"ltr\">role</code>) والاسم من <code dir=\"ltr\">aria-label</code> أو <code dir=\"ltr\">&lt;label&gt;</code> أو النص. أما Testing Library الحقيقية فتحسب شجرة إمكانية الوصول بشمول أكبر بكثير، لذا ثبّتها في مشاريعك.</p>\n<p>الدرس القادم: النقر والكتابة كما يفعل المستخدم.</p>\n","toc":[{"id":"sec-1","text":"المبدأ الموجّه","level":2},{"id":"sec-2","text":"ترتيب الأولوية","level":2},{"id":"sec-3","text":"اختبار DOM باستخدام Testing Library","level":2},{"id":"sec-4","text":"getBy وqueryBy وfindBy","level":2},{"id":"sec-5","text":"حين لا يجد الـ query عنصرك","level":2},{"id":"sec-6","text":"في ساحة التجربة","level":2}],"takeaways":["استعلم بما يدركه المستخدمون (الدور والاسم القابل للوصول، والتسمية، والنص الظاهر)، لا بأصناف CSS أو بنية الـ DOM، كي لا تنكسر الاختبارات حين يتغيّر التصميم.","ترتيب الأولوية هو: <code dir=\"ltr\">getByRole</code>، ثم <code dir=\"ltr\">getByLabelText</code> و<code dir=\"ltr\">getByPlaceholderText</code> و<code dir=\"ltr\">getByText</code> و<code dir=\"ltr\">getByDisplayValue</code>، ثم النص البديل والعنوان، و<code dir=\"ltr\">getByTestId</code> في الأخير.","يرمي <code dir=\"ltr\">getBy</code> خطأً حين يجد صفر تطابقات أو عدة تطابقات، ويعيد <code dir=\"ltr\">queryBy</code> القيمة <code dir=\"ltr\">null</code> (استخدمه لتأكيد الغياب)، وينتظر <code dir=\"ltr\">findBy</code> ظهور العنصر.","إن لم يجد استعلام الدور عنصرك، فغالبًا ما تكون البنية غير قابلة للوصول بعد؛ أصلح البنية بدل الانتقال إلى test id."],"further":[{"title":"Testing Library — About Queries","url":"https://testing-library.com/docs/queries/about"},{"title":"Testing Library — ByRole","url":"https://testing-library.com/docs/queries/byrole"},{"title":"MDN — ARIA roles","url":"https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles"}],"quiz":[{"q":"<p>يُعرض زر “Settle up” هكذا: <code dir=\"ltr\">&lt;button class=&quot;btn btn-primary&quot; data-testid=&quot;settle&quot;&gt;Settle up&lt;/button&gt;</code>. أيّ query يجب أن يستخدمه الاختبار؟</p>","options":[{"text":"<code dir=\"ltr\">container.querySelector('.btn-primary')</code>","why":"أسماء الأصناف قرارات تنسيق؛ وإعادة تسميتها أثناء إعادة التصميم ستكسر الاختبار رغم أن الزر ما زال يعمل."},{"text":"<code dir=\"ltr\">screen.getByTestId('settle')</code>","why":"يعمل ويصمد أمام تغيير التنسيق، لكنه الملاذ الأخير في قائمة الأولوية لأن المستخدمين لا يرون الـ test ids، فلا يثبت شيئًا عن إمكانية الوصول."},{"text":"<code dir=\"ltr\">screen.getByRole('button', { name: 'Settle up' })</code>","why":"صحيح. يجد العنصر بالطريقة التي يجده بها مستخدم قارئ الشاشة، ويفشل إن فقد الزر دوره أو اسمه القابل للوصول."}],"answer":2},{"q":"<p>تريد تأكيد أن رسالة الحالة الفارغة اختفت بعد إضافة مصروف. أيّ query يناسب؟</p>","options":[{"text":"<code dir=\"ltr\">expect(screen.queryByText('No expenses yet')).toBeNull()</code>","why":"صحيح. يعيد <code dir=\"ltr\">queryBy</code> القيمة <code dir=\"ltr\">null</code> حين لا يوجد تطابق، وهذا بالضبط ما تريد تأكيده."},{"text":"<code dir=\"ltr\">expect(screen.getByText('No expenses yet')).toBeNull()</code>","why":"يرمي <code dir=\"ltr\">getBy</code> خطأً حين لا يوجد تطابق، فيتعطّل الاختبار قبل أن يعمل <code dir=\"ltr\">toBeNull</code>؛ ولا يمكن أن ينجح أبدًا."},{"text":"<code dir=\"ltr\">expect(screen.findByText('No expenses yet')).toBeNull()</code>","why":"يعيد <code dir=\"ltr\">findBy</code> وعدًا (promise) لا يكون <code dir=\"ltr\">null</code> أبدًا، وينتظر ظهور العنصر، وهذا عكس ما تريده."}],"answer":0},{"q":"<p>يفشل <code dir=\"ltr\">screen.getByRole('textbox', { name: 'Amount' })</code> بالرسالة “Unable to find an accessible element with the role textbox and name Amount”. تعرض الصفحة كلمة Amount بجوار حقل الإدخال. ما الإصلاح الأرجح؟</p>","options":[{"text":"أضف <code dir=\"ltr\">data-testid=&quot;amount&quot;</code> واستعلم بـ <code dir=\"ltr\">getByTestId</code>.","why":"هذا يجعل الاختبار ينجح لكنه يترك المشكلة الحقيقية؛ فمستخدمو قارئ الشاشة ما زالوا يسمعون حقلًا بلا تسمية."},{"text":"اربط النص بحقل الإدخال باستخدام <code dir=\"ltr\">&lt;label&gt;</code> (بلفّه حوله أو بـ <code dir=\"ltr\">for</code>/<code dir=\"ltr\">id</code>).","why":"صحيح. النص الذي يجاور الحقل فحسب ليس تسمية. وبمجرد ربطه، يحصل الحقل على اسم قابل للوصول فيجده الـ query، ويجده مستخدموك كذلك."},{"text":"استخدم <code dir=\"ltr\">getByText('Amount')</code> ثم اقرأ <code dir=\"ltr\">.nextSibling</code>.","why":"التنقّل بين العناصر المتجاورة يربط الاختبار ببنية الـ DOM بالضبط، ويبقى الحقل بلا تسمية."}],"answer":1},{"q":"<p>أيّ زوج من الاستعلامات هو الأقرب إلى الطريقة التي يجد بها مستخدم مبصر يستعمل الفأرة ومستخدم قارئ الشاشة قائمةَ الأرصدة؟</p>","options":[{"text":"<code dir=\"ltr\">document.querySelector('ul')</code> و<code dir=\"ltr\">.children</code>","why":"هذان يتبعان بنية الـ DOM؛ وإضافة غلاف أو قائمة ثانية ستكسر الاختبار أو تربكه."},{"text":"<code dir=\"ltr\">getByTestId('balances')</code> و<code dir=\"ltr\">getAllByTestId('row')</code>","why":"الـ test ids غير مرئية للمستخدمين، وهي الملاذ الأخير في قائمة الأولوية."},{"text":"<code dir=\"ltr\">getByRole('list', { name: 'Balances' })</code> و<code dir=\"ltr\">within(list).getAllByRole('listitem')</code>","why":"صحيح. دور القائمة وتسميتها هما ما تعلنه التقنيات المساعدة، و<code dir=\"ltr\">within</code> يحصر استعلام العناصر داخل تلك القائمة."}],"answer":2}],"exercise":{"kind":"web","mode":"test","subject":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\n// Renders balances (in cents) into container.\n// - A list labelled \"Balances\", one item per person, sorted by name:\n//     positive -> \"Ana is owed $12.00\"\n//     negative -> \"Ben owes $5.00\"\n//     zero     -> \"Cai is settled up\"\n// - With no people at all: a paragraph \"No expenses yet\" and no list.\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n","mutants":[{"id":"swapped-wording","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\n// Renders balances (in cents) into container.\n// - A list labelled \"Balances\", one item per person, sorted by name:\n//     positive -> \"Ana is owed $12.00\"\n//     negative -> \"Ben owes $5.00\"\n//     zero     -> \"Cai is settled up\"\n// - With no people at all: a paragraph \"No expenses yet\" and no list.\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} owes ${formatMoney(cents)}`\n      : cents < 0 ? `${name} is owed ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n"},{"id":"hides-settled","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\n// Renders balances (in cents) into container.\n// - A list labelled \"Balances\", one item per person, sorted by name:\n//     positive -> \"Ana is owed $12.00\"\n//     negative -> \"Ben owes $5.00\"\n//     zero     -> \"Cai is settled up\"\n// - With no people at all: a paragraph \"No expenses yet\" and no list.\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).filter((n) => balances[n] !== 0).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n"},{"id":"minus-sign","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\n// Renders balances (in cents) into container.\n// - A list labelled \"Balances\", one item per person, sorted by name:\n//     positive -> \"Ana is owed $12.00\"\n//     negative -> \"Ben owes $5.00\"\n//     zero     -> \"Cai is settled up\"\n// - With no people at all: a paragraph \"No expenses yet\" and no list.\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n"},{"id":"no-empty-state","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\n// Renders balances (in cents) into container.\n// - A list labelled \"Balances\", one item per person, sorted by name:\n//     positive -> \"Ana is owed $12.00\"\n//     negative -> \"Ben owes $5.00\"\n//     zero     -> \"Cai is settled up\"\n// - With no people at all: a paragraph \"No expenses yet\" and no list.\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (false) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n"}],"starter":{"js":"// ── Tiny stand-ins for Testing Library queries (no npm in the playground) ──\nconst ROLE_SELECTORS = {\n  alert: '[role=\"alert\"]',\n  button: 'button, [role=\"button\"]',\n  list: 'ul, ol, [role=\"list\"]',\n  listitem: 'li, [role=\"listitem\"]',\n  status: '[role=\"status\"]',\n  textbox: 'input:not([type]), input[type=\"text\"], textarea, [role=\"textbox\"]',\n};\nfunction accessibleName(el) {\n  if (el.getAttribute('aria-label')) return el.getAttribute('aria-label');\n  if (el.labels && el.labels.length) return el.labels[0].textContent.trim();\n  return el.textContent.trim();\n}\nfunction queryAllByRole(root, role, { name } = {}) {\n  return [...root.querySelectorAll(ROLE_SELECTORS[role])]\n    .filter((el) => !el.closest('[hidden]'))\n    .filter((el) => name === undefined || accessibleName(el) === name);\n}\nfunction queryByRole(root, role, options) {\n  return queryAllByRole(root, role, options)[0] ?? null;\n}\nfunction getAllByRole(root, role, options = {}) {\n  const found = queryAllByRole(root, role, options);\n  if (!found.length) throw new Error(`Unable to find role \"${role}\"${options.name ? ` named \"${options.name}\"` : ''}`);\n  return found;\n}\nfunction getByRole(root, role, options = {}) {\n  const found = getAllByRole(root, role, options);\n  if (found.length > 1) throw new Error(`Found ${found.length} elements with role \"${role}\"`);\n  return found[0];\n}\nfunction queryByText(root, text) {\n  return [...root.querySelectorAll('*')].find((el) =>\n    !el.closest('[hidden]') &&\n    el.textContent.trim() === text &&\n    ![...el.children].some((child) => child.textContent.trim() === text)) ?? null;\n}\nfunction getByText(root, text) {\n  const el = queryByText(root, text);\n  if (!el) throw new Error(`Unable to find text \"${text}\"`);\n  return el;\n}\n// ────────────────────────────────────────────────────────────────────────────\n\nlet container;\n\nbeforeEach(() => {\n  container = document.createElement('div');\n  document.body.append(container);\n});\n\nafterEach(() => {\n  container.remove();\n});\n\ndescribe('renderBalances', () => {\n  test('renders a list labelled Balances', () => {\n    renderBalances(container, { Ana: 1200, Ben: -1200 });\n\n    expect(getByRole(container, 'list', { name: 'Balances' })).toBeTruthy();\n  });\n\n  // Add tests that read what the user reads: the text of each item,\n  // a settled-up person, and the empty state.\n});\n"},"solution":{"js":"// ── Tiny stand-ins for Testing Library queries (no npm in the playground) ──\nconst ROLE_SELECTORS = {\n  alert: '[role=\"alert\"]',\n  button: 'button, [role=\"button\"]',\n  list: 'ul, ol, [role=\"list\"]',\n  listitem: 'li, [role=\"listitem\"]',\n  status: '[role=\"status\"]',\n  textbox: 'input:not([type]), input[type=\"text\"], textarea, [role=\"textbox\"]',\n};\nfunction accessibleName(el) {\n  if (el.getAttribute('aria-label')) return el.getAttribute('aria-label');\n  if (el.labels && el.labels.length) return el.labels[0].textContent.trim();\n  return el.textContent.trim();\n}\nfunction queryAllByRole(root, role, { name } = {}) {\n  return [...root.querySelectorAll(ROLE_SELECTORS[role])]\n    .filter((el) => !el.closest('[hidden]'))\n    .filter((el) => name === undefined || accessibleName(el) === name);\n}\nfunction queryByRole(root, role, options) {\n  return queryAllByRole(root, role, options)[0] ?? null;\n}\nfunction getAllByRole(root, role, options = {}) {\n  const found = queryAllByRole(root, role, options);\n  if (!found.length) throw new Error(`Unable to find role \"${role}\"${options.name ? ` named \"${options.name}\"` : ''}`);\n  return found;\n}\nfunction getByRole(root, role, options = {}) {\n  const found = getAllByRole(root, role, options);\n  if (found.length > 1) throw new Error(`Found ${found.length} elements with role \"${role}\"`);\n  return found[0];\n}\nfunction queryByText(root, text) {\n  return [...root.querySelectorAll('*')].find((el) =>\n    !el.closest('[hidden]') &&\n    el.textContent.trim() === text &&\n    ![...el.children].some((child) => child.textContent.trim() === text)) ?? null;\n}\nfunction getByText(root, text) {\n  const el = queryByText(root, text);\n  if (!el) throw new Error(`Unable to find text \"${text}\"`);\n  return el;\n}\n// ────────────────────────────────────────────────────────────────────────────\n\nlet container;\n\nbeforeEach(() => {\n  container = document.createElement('div');\n  document.body.append(container);\n});\n\nafterEach(() => {\n  container.remove();\n});\n\ndescribe('renderBalances', () => {\n  test('renders a list labelled Balances', () => {\n    renderBalances(container, { Ana: 1200, Ben: -1200 });\n\n    expect(getByRole(container, 'list', { name: 'Balances' })).toBeTruthy();\n  });\n\n  test('says who owes and who is owed, sorted by name', () => {\n    renderBalances(container, { Ben: -500, Cai: 0, Ana: 500 });\n\n    const items = getAllByRole(container, 'listitem');\n    expect(items.map((item) => item.textContent)).toEqual([\n      'Ana is owed $5.00',\n      'Ben owes $5.00',\n      'Cai is settled up',\n    ]);\n  });\n\n  test('shows an empty state instead of an empty list', () => {\n    renderBalances(container, {});\n\n    expect(getByText(container, 'No expenses yet')).toBeTruthy();\n    expect(queryByRole(container, 'list')).toBeNull();\n  });\n});\n"},"checks":[{"id":"passes","suite":"subject"},{"id":"catches-swapped-wording","suite":"swapped-wording"},{"id":"catches-hides-settled","suite":"hides-settled"},{"id":"catches-minus-sign","suite":"minus-sign"},{"id":"catches-no-empty-state","suite":"no-empty-state"}],"title":"استعلم عن الأرصدة كما يفعل المستخدم","prompt":"<p>تعرض <code dir=\"ltr\">renderBalances(container, balances)</code> قائمة تسميتها <strong>Balances</strong>: <code dir=\"ltr\">&quot;Ana is owed $12.00&quot;</code>، <code dir=\"ltr\">&quot;Ben owes $5.00&quot;</code>، <code dir=\"ltr\">&quot;Cai is settled up&quot;</code>، مرتّبة حسب الاسم. وحين لا يوجد أشخاص تعرض <code dir=\"ltr\">No expenses yet</code> دون أي قائمة.</p>\n<p>يبدأ الكود المبدئي ببدائل صغيرة لاستعلامات Testing Library (<code dir=\"ltr\">getByRole</code> و<code dir=\"ltr\">getAllByRole</code> و<code dir=\"ltr\">queryByRole</code> و<code dir=\"ltr\">getByText</code> و<code dir=\"ltr\">queryByText</code>؛ وكلها تأخذ الحاوية معاملًا أول)، وينشئ <code dir=\"ltr\">container</code> جديدة لكل اختبار. أضف اختبارات حتى تلتقط مجموعتك أربعة أخطاء مخفيّة: صياغة <strong>owes</strong>/<strong>is owed</strong> متبادلة، ونسخة <strong>تُخفي</strong> من سوّوا حساباتهم، ونسخة تطبع <strong>إشارة سالب</strong> (“owes -$5.00”)، ونسخة <strong>بلا حالة فارغة</strong>.</p>\n","hints":["اقرأ ما يقرؤه المستخدم: <code dir=\"ltr\">getAllByRole(container, 'listitem').map((li) =&gt; li.textContent)</code> يعطيك كل سطر نص.","استخدم رصيدًا واحدًا من كل نوع (موجب، وسالب، وصفر)، كي يغطّي <code dir=\"ltr\">toEqual</code> واحد على النصوص الصياغةَ والإشارةَ والشخصَ الذي سوّى حسابه.","للحالة الفارغة، استدعِ <code dir=\"ltr\">renderBalances(container, {})</code>، ثم <code dir=\"ltr\">getByText(container, 'No expenses yet')</code> و<code dir=\"ltr\">expect(queryByRole(container, 'list')).toBeNull()</code>."],"explanation":"<p>أقوى assertion هنا يقارن النص الظاهر لكل عنصر مع رصيد واحد من كل نوع. هذا الـ <code dir=\"ltr\">toEqual</code> الوحيد يلتقط الصياغة المتبادلة، وإشارة السالب الشاردة، وسطر الشخص المسوّى الغائب، لأن كلًّا منها يغيّر ما سيقرؤه المستخدم. ويستخدم اختبار الحالة الفارغة <code dir=\"ltr\">getByText</code> لما يجب أن يوجد، و<code dir=\"ltr\">queryByRole(...)</code> مع <code dir=\"ltr\">toBeNull()</code> لما يجب ألا يوجد، وهذا هو التقسيم المعتاد بين <code dir=\"ltr\">getBy</code> و<code dir=\"ltr\">queryBy</code>. لا يعتمد أيّ من الاختبارات على تداخل الوسوم أو الأصناف: تستطيع عرض الأرصدة في <code dir=\"ltr\">&lt;div role=&quot;list&quot;&gt;</code> بصفوف <code dir=\"ltr\">role=&quot;listitem&quot;</code>، أو تغيير التنسيق كله، وستظلّ تنجح.</p>\n","checkLabels":{"passes":"كل اختباراتك تنجح على renderBalances الحقيقية","catches-swapped-wording":"تلتقط النسخة التي تبادل بين “owes” و&quot;is owed&quot;","catches-hides-settled":"تلتقط النسخة التي تُخفي من سوّوا حساباتهم","catches-minus-sign":"تلتقط النسخة التي تطبع “owes -$5.00”","catches-no-empty-state":"تلتقط النسخة التي لا تعرض حالة فارغة"}},"words":657,"readMinutes":4}