{"id":"l-tj-1-3","courseId":"testing-javascript","lang":"ar","kind":"lesson","summary":"تبني كل اختبار بنمط Arrange وAct وAssert، وتختار بين toBe وtoEqual، وتقرأ رسالة فشل Vitest لتصل إلى السطر المعطوب في ثوانٍ.","html":"<p>يُقرأ الاختبار أكثر بكثير مما يُكتب. يُقرأ حين يفشل في لحظة سيئة، على يد شخص لم يكتبه، يريد إجابة واحدة: <em>ما الذي انكسر؟</em> هذا الدرس عن كتابة اختبارات تجيب عن هذا السؤال في ثوانٍ، وعن قراءة الإجابة التي تعطيك إياها Vitest.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">Arrange وAct وAssert</h2>\n<p>لكل اختبار جيد تقريبًا الأجزاء الثلاثة نفسها:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">src/tip.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:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { 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\"> { withTip } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './tip.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\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'rounds a half-cent tip up to the next cent'</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:#6E7781;--shiki-dark:#8B949E\">  // Arrange: the inputs that matter for this behaviour</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> subtotalCents</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 1980</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\"> percent</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 12.5</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">  // Act: one call to the code under test</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> result</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> withTip</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(subtotalCents, percent);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">  // Assert: the claims about the outcome</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(result).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toEqual</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ tipCents: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">248</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, totalCents: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2228</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><strong>Arrange (التهيئة)</strong> يبني العالم الذي يحتاجه الاختبار. <strong>Act (الفعل)</strong> يفعل شيئًا واحدًا فقط: السلوك الذي تختبره. <strong>Assert (التأكيد)</strong> يذكر ما يجب أن يكون صحيحًا الآن. لن تحتاج إلى التعليقات بعد أن يصبح هذا الشكل عادة؛ الأسطر الفارغة تكفي.</p>\n<p>يحميك هذا الشكل من أشهر رائحة سيئة في الاختبارات: اختبار يفعل، ثم يؤكّد، ثم يفعل مرة أخرى، ثم يؤكّد مرة أخرى. حين يفشل، لا تستطيع أن تعرف أيّ خطوة انكسرت. قسّمه إلى اختبارين، لكل منهما اسم يقول ما الذي يثبته.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 640 170\" role=\"img\" aria-labelledby=\"l-tj-1-3-t1\"><defs><marker id=\"arrow-l-tj-1-3-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-1-3-t1\">ثلاثة صناديق متجاورة: Arrange يبني المدخلات، وAct يستدعي withTip مرة واحدة، وAssert يقارن النتيجة بـ toEqual.</title>\n  <rect class=\"d-box-accent\" x=\"20\" y=\"40\" width=\"170\" height=\"90\" rx=\"12\"/>\n  <text class=\"d-label-strong\" x=\"105\" y=\"76\" text-anchor=\"middle\">Arrange</text>\n  <text class=\"d-code\" x=\"105\" y=\"104\" text-anchor=\"middle\">1980, 12.5</text>\n  <path class=\"d-arrow\" d=\"M190 85 L232 85\" marker-end=\"url(#arrow-l-tj-1-3-1)\"/>\n  <rect class=\"d-box-primary\" x=\"235\" y=\"40\" width=\"170\" height=\"90\" rx=\"12\"/>\n  <text class=\"d-label-strong\" x=\"320\" y=\"76\" text-anchor=\"middle\">Act</text>\n  <text class=\"d-code\" x=\"320\" y=\"104\" text-anchor=\"middle\">withTip(...)</text>\n  <path class=\"d-arrow\" d=\"M405 85 L447 85\" marker-end=\"url(#arrow-l-tj-1-3-1)\"/>\n  <rect class=\"d-box-success\" x=\"450\" y=\"40\" width=\"170\" height=\"90\" rx=\"12\"/>\n  <text class=\"d-label-strong\" x=\"535\" y=\"76\" text-anchor=\"middle\">Assert</text>\n  <text class=\"d-code\" x=\"535\" y=\"104\" text-anchor=\"middle\">toEqual({...})</text>\n</svg><figcaption>Arrange وAct وAssert: تهيئة واحدة، وفعل واحد، ثم الادّعاءات</figcaption></figure>\n<h3 id=\"sec-2\" tabindex=\"-1\">كم assertion في الاختبار الواحد؟</h3>\n<p>ستسمع قاعدة “assertion واحد لكل اختبار”، وهي قاعدة صارمة أكثر من اللازم. القاعدة الحقيقية هي <strong>سلوك واحد لكل اختبار</strong>. فحص <code dir=\"ltr\">tipCents</code> و<code dir=\"ltr\">totalCents</code> معًا بعد استدعاء واحد هو سلوك واحد له حقيقتان يمكن ملاحظتهما، فلا بأس بتأكيد الاثنتين، و<code dir=\"ltr\">toEqual</code> على الكائن كله يفعل ذلك في سطر واحد. ما تريد تجنّبه هو اختبار يفحص التقريب، ثم يستدعي الدالة مجددًا ليفحص حالة الخطأ، ثم مرة ثالثة ليفحص الصفر. حين يفشل الادّعاء الأول، توقف Vitest ذلك الاختبار، فلا تعرف أبدًا إن كان السلوكان الآخران ما زالا يعملان. ثلاثة اختبارات صغيرة تعطيك ثلاث إجابات منفصلة.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">الأسماء هي السطر الأول في تقرير الخطأ</h2>\n<p>حين يفشل اختبار، يطبع التقرير مساره الكامل: <code dir=\"ltr\">withTip &gt; rounds a half-cent tip up to the next cent</code>. اكتب ذلك الاسم جملةً عن السلوك. “withTip works” لا تخبرك بشيء؛ أما “rejects a negative tip percent” فتخبرك بما تراجع قبل أن تفتح أي ملف. اجمع بـ <code dir=\"ltr\">describe</code> حسب الوحدة، وسمِّ بـ <code dir=\"ltr\">test</code> حسب القاعدة.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">toBe أم toEqual؟</h2>\n<p>يقوم اثنان من الـ matchers (أدوات المطابقة) بمعظم العمل، ويقارن كلٌّ منهما بطريقة مختلفة:</p>\n<ul>\n<li><code dir=\"ltr\">toBe</code> يستخدم <code dir=\"ltr\">Object.is</code>: القيمة البدائية نفسها، أو <strong>مرجع الكائن نفسه</strong>. استخدمه مع الأرقام والنصوص والقيم المنطقية.</li>\n<li><code dir=\"ltr\">toEqual</code> يقارن <strong>البنية</strong> بشكل متكرّر: المفاتيح نفسها، والقيم نفسها. استخدمه مع الكائنات والمصفوفات. (ويفحص <code dir=\"ltr\">toStrictEqual</code> أيضًا تطابق الخصائص التي قيمتها <code dir=\"ltr\">undefined</code> وأنواع الأصناف.)</li>\n</ul>\n<p><code dir=\"ltr\">expect(withTip(2000, 15)).toBe({ tipCents: 300, totalCents: 2300 })</code> يفشل رغم تطابق القيم، لأن الدالة تعيد كائنًا جديدًا. هذا الفخ يقع فيه الجميع مرة واحدة على الأقل.</p>\n<h2 id=\"sec-5\" tabindex=\"-1\">قراءة رسالة الفشل</h2>\n<p>إليك نوعًا حقيقيًا من الأخطاء. النسخة الأولى من <code dir=\"ltr\">toCents</code>، التي تحوّل مبلغًا بالدولار إلى سنتات، استخدمت <code dir=\"ltr\">Math.floor</code>:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-run=\"js\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">JavaScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"js\" data-index=\"1\" 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\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>تشغيل</span></button><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:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> toCents</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#953800;--shiki-dark:#FFA657\">dollars</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> Math.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">floor</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(dollars </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 100</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">console.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">log</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0.29</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> *</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 100</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);    </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// what the computer actually computes</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">console.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">log</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toCents</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0.29</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)); </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// one cent short</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">console.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">log</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(Math.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">round</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0.29</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> *</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 100</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">));</span></span></code></pre><div class=\"run-output\" data-run-output=\"1\" hidden></div></div>\n<p>يفشل الاختبار <code dir=\"ltr\">expect(toCents(0.29)).toBe(29)</code>، وتعرض Vitest شيئًا كهذا:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"text\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">Text</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> FAIL  src/money.test.js &gt; toCents &gt; converts 0.29 dollars to 29 cents\nAssertionError: expected 28 to be 29 // Object.is equality\n\n- Expected\n+ Received\n\n- 29\n+ 28\n\n ❯ src/money.test.js:7:28</code></pre></div>\n<p>اقرأها من الأعلى إلى الأسفل، على ثلاث مراحل:</p>\n<ol>\n<li><strong>المسار</strong> يخبرك أيّ سلوك انكسر: تحويل 0.29 دولار.</li>\n<li><strong>المتوقّع مقابل المستلم</strong> يخبرك كيف انكسر: ينقص سنتًا واحدًا، إذن شيء ما اقتطع القيمة بدل أن يقرّبها.</li>\n<li><strong>الموقع</strong> (<code dir=\"ltr\">money.test.js:7:28</code>) يشير إلى سطر الـ assertion في الاختبار. والخطأ موجود في الكود الذي يستدعيه ذلك السطر.</li>\n</ol>\n<p>مع الكائنات، يعلّم الفرق (diff) الأسطر المختلفة فقط، فيبرز <code dir=\"ltr\">totalCents</code> الخاطئ داخل كائن من عشرة مفاتيح فورًا.</p>\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>إضعاف الـ assertion كي ينجح الاختبار</span></p>\n<p>حين ترى <code dir=\"ltr\">expected 28 to be 29</code>، يغريك أن تغيّر التوقّع إلى 28 (“فهذا ما يعيده الكود”) أو أن تقرّب القيمة داخل الاختبار، ثم تمضي. لكن الاختبار كان محقًّا؛ والكود هو المخطئ. لا تغيّر assertion إلا حين تستطيع أن تشرح لماذا كان التوقّع القديم خطأً. ولهذا السبب أيضًا يخزّن Splitwise-lite المبالغ <strong>كسنتات صحيحة</strong>: فالأعداد الصحيحة تُجمع بدقّة تامّة، فيكون <code dir=\"ltr\">toBe</code> دائمًا الـ matcher الصحيح للمال.</p>\n</aside>\n<h2 id=\"sec-6\" tabindex=\"-1\">اختبار أن شيئًا ما يرمي خطأً</h2>\n<p>أحيانًا يكون السلوك المطلوب هو “رفض المدخلات السيئة”. لفّ الاستدعاء داخل دالة كي يستطيع <code dir=\"ltr\">expect</code> أن يستدعيها ويلتقط الخطأ:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">JavaScript</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:#8250DF;--shiki-dark:#D2A8FF\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'rejects a negative tip percent'</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\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> withTip</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1000</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">-</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">5</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toThrow</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(RangeError);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span></code></pre></div>\n<p>بدون الـ arrow function، ترمي <code dir=\"ltr\">withTip</code> الخطأ أثناء حساب المعاملات، قبل أن يعمل <code dir=\"ltr\">expect</code>، فينهار الاختبار بدل أن ينجح. ويقبل <code dir=\"ltr\">toThrow</code> أيضًا نصًّا أو تعبيرًا نمطيًا (regex) لمطابقة رسالة الخطأ.</p>\n<p>في التمرين تكتب اختبارات بنمط AAA لـ <code dir=\"ltr\">withTip</code>، بما فيها الحدّ الذي يسهل نسيانه: بقشيش قيمته صفر. وينتقل القسم التالي من “كيف يبدو الاختبار” إلى “أيّ الاختبارات تستحق أن تُكتب”.</p>\n","toc":[{"id":"sec-1","text":"Arrange وAct وAssert","level":2},{"id":"sec-2","text":"كم assertion في الاختبار الواحد؟","level":3},{"id":"sec-3","text":"الأسماء هي السطر الأول في تقرير الخطأ","level":2},{"id":"sec-4","text":"toBe أم toEqual؟","level":2},{"id":"sec-5","text":"قراءة رسالة الفشل","level":2},{"id":"sec-6","text":"اختبار أن شيئًا ما يرمي خطأً","level":2}],"takeaways":["يعطي نمط Arrange وAct وAssert كل اختبار الشكل الثلاثي نفسه، فيجد القارئ التهيئة والفعل والادّعاء دون بحث.","يفحص الاختبار الواحد سلوكًا واحدًا، واسمه جملة تخبرك أيّ سلوك انكسر حين يفشل.","يقارن <code dir=\"ltr\">toBe</code> باستخدام <code dir=\"ltr\">Object.is</code> (القيمة نفسها أو المرجع نفسه)؛ أما <code dir=\"ltr\">toEqual</code> فيقارن البنية، وهذا ما تريده مع الكائنات والمصفوفات.","يعطيك تقرير الفشل مسار الاختبار، والقيمتين المتوقّعة والمستلمة، والملف ورقم السطر؛ اقرأ الثلاثة قبل أن تلمس الكود."],"further":[{"title":"Vitest — expect API","url":"https://vitest.dev/api/expect"},{"title":"Vitest — Test API","url":"https://vitest.dev/api/test"},{"title":"MDN — Number.EPSILON and floating point","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/EPSILON"}],"quiz":[{"q":"<p>ماذا يُظهر هذا الاختبار؟</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">JavaScript</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:#8250DF;--shiki-dark:#D2A8FF\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'builds the tip summary'</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\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">withTip</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2000</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">15</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBe</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ tipCents: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">300</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, totalCents: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2300</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","options":[{"text":"ينجح، لأن الكائن يحمل القيم الصحيحة.","why":"القيم صحيحة، لكن <code dir=\"ltr\">toBe</code> يفحص التطابق في الهوية باستخدام <code dir=\"ltr\">Object.is</code>، وكائنان أُنشئ كلٌّ منهما على حدة لا يكونان المرجع نفسه أبدًا."},{"text":"يفشل، لأن <code dir=\"ltr\">toBe</code> يقارن المراجع والدالة تعيد كائنًا جديدًا.","why":"صحيح. استخدم <code dir=\"ltr\">toEqual</code> (أو <code dir=\"ltr\">toStrictEqual</code>) لمقارنة الكائنات بمحتواها."},{"text":"يرمي خطأً في الصياغة لأن الكائنات لا يمكن تمريرها إلى <code dir=\"ltr\">toBe</code>.","why":"يمكن تمرير أي قيمة إلى <code dir=\"ltr\">toBe</code>؛ المشكلة في نوع المقارنة التي يجريها، لا في الصياغة."}],"answer":1},{"q":"<p>يطبع اختبار فاشل <code dir=\"ltr\">expected 28 to be 29</code> للاستدعاء <code dir=\"ltr\">toCents(0.29)</code>. أين تنظر أولًا؟</p>","options":[{"text":"إلى الاختبار، كي تغيّر القيمة المتوقّعة إلى 28 فتطابق ما يعيده الكود.","why":"هكذا تجعل الاختبار يوافق الخطأ؛ السنتات أعداد صحيحة، و28 خطأ بسنت كامل."},{"text":"إلى إعدادات مشغّل الاختبارات، لأن Vitest تتعامل مع الأعداد العشرية بطريقة مختلفة.","why":"تشغّل Vitest كود JavaScript عاديًا؛ و<code dir=\"ltr\">0.29 * 100</code> يساوي <code dir=\"ltr\">28.999999999999996</code> في كل مكان، فلا علاقة للإعدادات."},{"text":"إلى الطريقة التي تحوّل بها <code dir=\"ltr\">toCents</code> العدد العشري إلى عدد صحيح، لأن <code dir=\"ltr\">0.29 * 100</code> أقل من 29 بقليل.","why":"صحيح. القيمة المستلمة تخبرك أن الكود اقتطع قيمة كانت أقل من 29 بشعرة؛ <code dir=\"ltr\">Math.round</code> يصلح ذلك، و<code dir=\"ltr\">Math.floor</code> هو سببه."}],"answer":2},{"q":"<p>أيّ اسم اختبار هو الأكثر فائدة حين يفشل في CI في الثانية فجرًا؟</p>","options":[{"text":"<code dir=\"ltr\">test('withTip works')</code>","why":"يذكر الدالة لا السلوك؛ ما زال عليك فتح الملف لتعرف ما الذي انكسر."},{"text":"<code dir=\"ltr\">test('test 3')</code>","why":"الرقم لا يحمل أي معلومة، ويصبح خاطئًا بمجرد إعادة ترتيب الاختبارات."},{"text":"<code dir=\"ltr\">test('rounds a half-cent tip up to the next cent')</code>","why":"صحيح. الاسم يذكر القاعدة، فتقرير الفشل وحده يخبرك أيّ سلوك تراجع."},{"text":"<code dir=\"ltr\">test('withTip(1980, 12.5)')</code>","why":"المدخل ظاهر لكن السلوك المتوقّع غير ظاهر؛ لا يستطيع القارئ أن يعرف ما الناتج الصحيح المقصود."}],"answer":2},{"q":"<p>لماذا تظهر مراحل Arrange وAct وAssert عادةً بهذا الترتيب، يفصل بينها سطر فارغ؟</p>","options":[{"text":"تشترط Vitest المراحل الثلاث بهذا الترتيب وإلا تخطّت الاختبار.","why":"لا تعرف Vitest شيئًا عن المراحل؛ نمط AAA عُرف موجّه للبشر، لا قاعدة يفرضها المشغّل."},{"text":"يجعل التهيئة والفعل الوحيد الخاضع للاختبار والادّعاءات سهلة الرؤية بنظرة واحدة.","why":"صحيح. الشكل الثابت يعني أن القارئ يستطيع القفز مباشرة إلى الفعل والـ assertion في أي اختبار."},{"text":"يسمح لـ Vitest بتشغيل مرحلة Arrange مرة واحدة للملف كله.","why":"التهيئة المشتركة مكانها الـ hooks مثل <code dir=\"ltr\">beforeEach</code>؛ والأسطر الفارغة داخل الاختبار لا تغيّر شيئًا وقت التشغيل."}],"answer":1}],"exercise":{"kind":"web","mode":"test","subject":"// Adds a tip to a subtotal. Money is in integer cents; percent is a\n// number such as 15 or 12.5. The tip is rounded to the nearest cent\n// (a half cent rounds up). A zero tip is allowed; a negative one is not.\nfunction withTip(subtotalCents, percent) {\n  if (percent < 0) throw new RangeError('Tip percent cannot be negative');\n  const tipCents = Math.round((subtotalCents * percent) / 100);\n  return { tipCents, totalCents: subtotalCents + tipCents };\n}\n","mutants":[{"id":"floors-tip","code":"// Adds a tip to a subtotal. Money is in integer cents; percent is a\n// number such as 15 or 12.5. The tip is rounded to the nearest cent\n// (a half cent rounds up). A zero tip is allowed; a negative one is not.\nfunction withTip(subtotalCents, percent) {\n  if (percent < 0) throw new RangeError('Tip percent cannot be negative');\n  const tipCents = Math.floor((subtotalCents * percent) / 100);\n  return { tipCents, totalCents: subtotalCents + tipCents };\n}\n"},{"id":"allows-negative","code":"// Adds a tip to a subtotal. Money is in integer cents; percent is a\n// number such as 15 or 12.5. The tip is rounded to the nearest cent\n// (a half cent rounds up). A zero tip is allowed; a negative one is not.\nfunction withTip(subtotalCents, percent) {\n  \n  const tipCents = Math.round((subtotalCents * percent) / 100);\n  return { tipCents, totalCents: subtotalCents + tipCents };\n}\n"},{"id":"rejects-zero","code":"// Adds a tip to a subtotal. Money is in integer cents; percent is a\n// number such as 15 or 12.5. The tip is rounded to the nearest cent\n// (a half cent rounds up). A zero tip is allowed; a negative one is not.\nfunction withTip(subtotalCents, percent) {\n  if (percent <= 0) throw new RangeError('Tip percent cannot be negative');\n  const tipCents = Math.round((subtotalCents * percent) / 100);\n  return { tipCents, totalCents: subtotalCents + tipCents };\n}\n"}],"starter":{"js":"describe('withTip', () => {\n  test('adds a 15% tip to the subtotal', () => {\n    // Arrange\n    const subtotalCents = 2000;\n\n    // Act\n    const result = withTip(subtotalCents, 15);\n\n    // Assert\n    expect(result).toEqual({ tipCents: 300, totalCents: 2300 });\n  });\n\n  // Add: a half-cent tip that must round up,\n  // a zero tip, and a negative percent that must throw.\n});\n"},"solution":{"js":"describe('withTip', () => {\n  test('adds a 15% tip to the subtotal', () => {\n    const subtotalCents = 2000;\n\n    const result = withTip(subtotalCents, 15);\n\n    expect(result).toEqual({ tipCents: 300, totalCents: 2300 });\n  });\n\n  test('rounds a half-cent tip up to the next cent', () => {\n    const result = withTip(1980, 12.5);\n\n    expect(result).toEqual({ tipCents: 248, totalCents: 2228 });\n  });\n\n  test('allows a zero tip', () => {\n    const result = withTip(2000, 0);\n\n    expect(result).toEqual({ tipCents: 0, totalCents: 2000 });\n  });\n\n  test('rejects a negative tip percent', () => {\n    expect(() => withTip(2000, -5)).toThrow(RangeError);\n  });\n});\n"},"checks":[{"id":"passes","suite":"subject"},{"id":"catches-floors-tip","suite":"floors-tip"},{"id":"catches-allows-negative","suite":"allows-negative"},{"id":"catches-rejects-zero","suite":"rejects-zero"}],"title":"اختبارات AAA لـ withTip","prompt":"<p>تعيد <code dir=\"ltr\">withTip(subtotalCents, percent)</code> الكائن <code dir=\"ltr\">{ tipCents, totalCents }</code>. يُقرَّب البقشيش إلى أقرب سنت (ونصف السنت يُقرَّب <strong>إلى الأعلى</strong>)، والبقشيش بنسبة 0% صالح، والنسبة السالبة ترمي <code dir=\"ltr\">RangeError</code>.</p>\n<p>يفحص الاختبار المبدئي بقشيشًا بنسبة 15% على $20.00. أضف اختبارات بشكل Arrange وAct وAssert حتى تنجح مجموعتك على الكود الحقيقي وتلتقط ثلاثة أخطاء مخفيّة: نسخة تأخذ الجزء الصحيح من البقشيش بدل تقريبه، ونسخة تنسى رفض النسب السالبة، ونسخة ترفض البقشيش الصفري خطأً.</p>\n","hints":["بقشيش 15% على 2000 يساوي 300 بالضبط، فلا يؤثّر التقريب هناك أبدًا. تحتاج إلى مدخل ينتهي فيه البقشيش الدقيق بـ .5 أو أكثر.","1980 سنتًا بنسبة 12.5% تساوي 247.5 سنتًا، ويجب أن تُقرَّب إلى 248. قارن الكائن كله باستخدام <code dir=\"ltr\">toEqual</code>.","للخطأ، لفّ الاستدعاء: <code dir=\"ltr\">expect(() =&gt; withTip(2000, -5)).toThrow(RangeError)</code>. وللصفر، أكّد النتيجة نفسها، لا مجرّد أن الدالة لا ترمي خطأً."],"explanation":"<p>كل اختبار مضاف يثبّت قاعدة واحدة من المواصفات بمدخل يفصل الصحيح عن الخاطئ. البقشيش الذي يقع على نصف سنت يميّز <code dir=\"ltr\">Math.round</code> من <code dir=\"ltr\">Math.floor</code>. والبقشيش الصفري يلتقط زلّة الحدود الكلاسيكية (<code dir=\"ltr\">&lt;=</code> بدل <code dir=\"ltr\">&lt;</code>)، التي كانت ستجعل Splitwise-lite يرفض فاتورة عادية تمامًا. وتحتاج الحالة السالبة إلى الـ arrow function كي يلتقط <code dir=\"ltr\">expect</code> الخطأ المرمي. وتأكيد الكائن الكامل <code dir=\"ltr\">{ tipCents, totalCents }</code> باستخدام <code dir=\"ltr\">toEqual</code> يحمي المجموع أيضًا، فيغطّي assertion واحد الحقلين معًا.</p>\n","checkLabels":{"passes":"كل اختباراتك تنجح على withTip الحقيقية","catches-floors-tip":"تلتقط النسخة التي تأخذ الجزء الصحيح من البقشيش","catches-allows-negative":"تلتقط النسخة التي تقبل نسبة سالبة","catches-rejects-zero":"تلتقط النسخة التي ترفض البقشيش الصفري"}},"words":635,"readMinutes":4}