{"id":"l-tj-2-3","courseId":"testing-javascript","lang":"ar","kind":"lesson","summary":"تختبر كودًا يرمي أخطاءً، أو يعيد promises، أو يعتمد على API غير مستقر، باستخدام assertions من نوع resolves وrejects مع await، وبدائل صغيرة مكتوبة باليد.","html":"<p>يحفظ Splitwise-lite المصروفات عبر اتصال هاتف في قطار. الطلبات تفشل. بعض حالات الفشل تستحق إعادة محاولة واحدة (انتهاء المهلة)؛ وبعضها لا (حين يقول الخادم إن المبلغ غير صالح). الكود الذي يقرّر ذلك نحو اثني عشر سطرًا لا أكثر، وهو بالضبط الكود الذي لا يختبره أحد، لأنه “غير متزامن” و&quot;يحتاج إلى الـ API&quot;. ولا واحد من السببين عائق حقيقي.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">الدالة الخاضعة للاختبار</h2>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">src/save-expense.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\">export</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> async</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> saveExpense</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">expense</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">api</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">!</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">expense.description?.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">trim</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">()) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">throw</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> new</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> Error</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Description is required'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">!</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">Number.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">isInteger</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(expense.amountCents) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">||</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> expense.amountCents </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">&#x3C;=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    throw</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> new</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> Error</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Amount must be positive'</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:#CF222E;--shiki-dark:#FF7B72\">  try</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:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> api.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">post</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'/expenses'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, expense);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> id;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">catch</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (error) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">!</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">error.retryable) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">throw</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> error;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    const</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> api.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">post</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'/expenses'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, expense); </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// one retry</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> id;</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></code></pre></div>\n<p>لأنها دالة <code dir=\"ltr\">async</code>، يتحوّل كل <code dir=\"ltr\">throw</code> داخلها إلى <strong>promise مرفوض</strong>. المستدعون (والاختبارات) لا يرون أبدًا استثناءً متزامنًا؛ بل يرون promise يُرفض.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">الـ assertions على الـ promises</h2>\n<p>تنتظر Vitest الاختبار الذي يكون <code dir=\"ltr\">async</code> أو يعيد promise. وفي داخله لديك خياران جيدان:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"1\"><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\">'resolves with the new id'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">async</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:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> api</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">post</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">async</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> () </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> ({ id: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'e1'</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\">  await</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">saveExpense</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ description: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Dinner'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, amountCents: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">4500</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }, api))</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    .resolves.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBe</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'e1'</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\">'rejects a blank description'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">async</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:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> api</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">post</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">async</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> () </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> ({ id: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'e1'</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\">  await</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">saveExpense</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ description: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'  '</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, amountCents: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">4500</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }, api))</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    .rejects.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toThrow</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Description is required'</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\">await</code> مع الاستدعاء ثم تؤكّد القيمة: <code dir=\"ltr\">const id = await saveExpense(...); expect(id).toBe('e1');</code>. أما مع حالات الرفض، ففضّل <code dir=\"ltr\">rejects</code> على <code dir=\"ltr\">try/catch</code>: إن حُلّ الـ promise بنجاح على غير المتوقّع، يُفشل <code dir=\"ltr\">rejects</code> الاختبار، بينما تتخطّى <code dir=\"ltr\">try/catch</code> كتلة <code dir=\"ltr\">catch</code> بصمت وينجح الاختبار. (وإن اضطررت إلى <code dir=\"ltr\">try/catch</code>، فضع <code dir=\"ltr\">expect.assertions(1)</code> في أعلى الاختبار كي تُفشله Vitest حين لا يُنفَّذ أي assertion.)</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>إنهاء الاختبار قبل أن يستقرّ الـ promise</span></p>\n<p><code dir=\"ltr\">saveExpense(expense, api).then((id) =&gt; expect(id).toBe('e1'))</code> بلا <code dir=\"ltr\">await</code> ولا <code dir=\"ltr\">return</code> يترك دالة الاختبار تنتهي أولًا. فيُعلَن الاختبار أخضر قبل أن يعمل الـ assertion؛ وفي أحسن الأحوال يظهر خطأ غير معالَج ومربك لاحقًا أثناء التشغيل، منفصلًا عن الاختبار الذي تسبّب فيه. تحميك Vitest 4 في حالة واحدة: الـ assertion من نوع <code dir=\"ltr\">resolves</code> أو <code dir=\"ltr\">rejects</code> الذي تنسى أن تضع قبله <code dir=\"ltr\">await</code> يُفشل الاختبار. لكنها لا تستطيع حماية دوال <code dir=\"ltr\">.then</code> الراجعة، ولا <code dir=\"ltr\">try/catch</code> لا تعمل كتلة <code dir=\"ltr\">catch</code> فيها أبدًا. العادة التي تغطّي كل ذلك: <strong>كل promise في الاختبار يُنتظَر بـ await.</strong></p>\n</aside>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 220\" role=\"img\" aria-labelledby=\"l-tj-2-3-t1\"><defs><marker id=\"arrow-l-tj-2-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-2-3-t1\">خطّان زمنيان. في الأعلى: يعود الاختبار، ثم يُرفض الـ promise لاحقًا ولا أحد يستمع. في الأسفل: ينتظر الاختبار بـ await، فيُفشل الرفضُ الاختبار.</title>\n  <text class=\"d-label-strong\" x=\"20\" y=\"40\">بلا await</text>\n  <path class=\"d-line\" d=\"M140 60 L660 60\"/>\n  <circle class=\"d-dot\" cx=\"170\" cy=\"60\" r=\"7\"/>\n  <text class=\"d-label\" x=\"170\" y=\"90\" text-anchor=\"middle\">يبدأ الاختبار</text>\n  <rect class=\"d-box-success\" x=\"270\" y=\"44\" width=\"140\" height=\"32\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"340\" y=\"65\" text-anchor=\"middle\">ينجح الاختبار</text>\n  <rect class=\"d-box-warn\" x=\"490\" y=\"44\" width=\"160\" height=\"32\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"570\" y=\"65\" text-anchor=\"middle\">يُرفض الـ promise</text>\n  <text class=\"d-label-strong\" x=\"20\" y=\"150\">await</text>\n  <path class=\"d-line\" d=\"M140 170 L660 170\"/>\n  <circle class=\"d-dot\" cx=\"170\" cy=\"170\" r=\"7\"/>\n  <text class=\"d-label\" x=\"170\" y=\"200\" text-anchor=\"middle\">يبدأ الاختبار</text>\n  <path class=\"d-arrow d-dashed\" d=\"M190 170 L480 170\" marker-end=\"url(#arrow-l-tj-2-3-1)\"/>\n  <text class=\"d-label-muted\" x=\"335\" y=\"160\" text-anchor=\"middle\">ينتظر</text>\n  <rect class=\"d-box-warn\" x=\"490\" y=\"154\" width=\"160\" height=\"32\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"570\" y=\"175\" text-anchor=\"middle\">يفشل الاختبار</text>\n</svg><figcaption>بدون await، ينتهي الاختبار قبل أن يعمل الـ assertion</figcaption></figure>\n<h2 id=\"sec-3\" tabindex=\"-1\">الـ stubs: متعاونون تتحكّم فيهم</h2>\n<p>تتحدّث <code dir=\"ltr\">saveExpense</code> إلى <code dir=\"ltr\">api</code>. في الاختبار لا تريد خادمًا حقيقيًا؛ بل تريد كائنًا تفعل دالة <code dir=\"ltr\">post</code> فيه ما يحتاجه هذا الاختبار بالذات. هذا الكائن هو <strong>الـ stub</strong> (بديل ثابت الاستجابة)، ولا تحتاج إلى مكتبة لكتابته:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"2\"><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:#6E7781;--shiki-dark:#8B949E\">// Fails once with a retryable error, then succeeds; counts its calls.</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> flakyApi</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  let</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> calls </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    get</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> calls</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() { </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> calls; },</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    async</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> post</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      calls </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">+=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 1</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">      if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (calls </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">===</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 1</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">throw</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> Object.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">assign</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">new</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> Error</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Timed out'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">), { retryable: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">true</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> });</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">      return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { id: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'e2'</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 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\">'retries once after a retryable error'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">async</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:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> api</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> flakyApi</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\">  await</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">saveExpense</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ description: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Taxi'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, amountCents: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1800</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }, api)).resolves.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBe</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'e2'</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\">(api.calls).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBe</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2</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\">api</code> كمعامل هو ما يجعل هذا سهلًا. الكود الذي يستورد عميلًا عامًّا في أعماقه أصعب اختبارًا بكثير؛ وستتعرّف إلى أدوات ذلك (وثمنها) في الدرس القادم.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">اختبر كل فرع من فروع معالجة الأخطاء</h2>\n<p>لمعالجة الأخطاء فروع أكثر من المسار السعيد، وكل فرع منها قرار اتّخذه شخص ما. بالنسبة إلى <code dir=\"ltr\">saveExpense</code>:</p>\n<div class=\"table-wrap\" role=\"region\" aria-label=\"جدول قابل للتمرير\" tabindex=\"0\"><table><thead>\n<tr>\n<th>الموقف</th>\n<th>المتوقّع</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>ينجح الـ API</td>\n<td>يُحلّ بالـ id، باستدعاء واحد</td>\n</tr>\n<tr>\n<td>خطأ قابل لإعادة المحاولة، ثم نجاح</td>\n<td>يُحلّ بالـ id، باستدعاءين</td>\n</tr>\n<tr>\n<td>خطأ غير قابل لإعادة المحاولة</td>\n<td>يُرفض بـ <strong>ذلك</strong> الخطأ، باستدعاء واحد</td>\n</tr>\n<tr>\n<td>مدخل غير صالح</td>\n<td>يُرفض برسالة تحقّق، بلا أي استدعاء</td>\n</tr>\n</tbody>\n</table></div><p>الصف الثالث هو الذي يتخطّاه الناس، وهو يخبّئ خطأين شائعين: <code dir=\"ltr\">catch</code> تبتلع الخطأ وتعيد <code dir=\"ltr\">null</code> (فيظنّ المستدعون أن الحفظ نجح)، و<code dir=\"ltr\">catch</code> تعيد المحاولة مع كل شيء (فتُحصَّل دفعة مرفوضة مرتين). الرفض وحده لا يستطيع كشف الخطأ الثاني، لأن إعادة المحاولة تفشل هي الأخرى. أما <strong>عدّ الاستدعاءات</strong> فيستطيع.</p>\n<p>لاحظ الصف الأخير أيضًا: المدخل غير الصالح يجب ألا يُحدث <strong>أي</strong> استدعاء. هذا وعد يتعلّق بالآثار الجانبية، وهو مهم. النسخة التي تتحقّق <em>بعد</em> الإرسال سترفض بالرسالة الصحيحة رغم ذلك، فلا يثبت أن المصروف السيئ لم يصل إلى الخادم سوى عدد الاستدعاءات.</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>حالات الرفض غير المعالَجة تُفشل التشغيل</span></p>\n<p>إن بدأ الكود الخاضع للاختبار promise يُرفض ولم يعالجه أحد، تبلّغ Vitest عنه كخطأ غير معالَج وتعلّم التشغيل كله بأنه فاشل، حتى لو نجح كل اختبار. تعامل مع تلك الرسالة كتقرير خطأ حقيقي: في مكان ما ينقص promise ما <code dir=\"ltr\">await</code> أو <code dir=\"ltr\">catch</code>.</p>\n</aside>\n<h2 id=\"sec-5\" tabindex=\"-1\">المهل الزمنية (timeouts)</h2>\n<p>إن لم يستقرّ promise أبدًا (stub نسي أن يُحلّ، أو <code dir=\"ltr\">await</code> مفقود داخل حلقة)، يعلق الاختبار حتى تنتهي مهلة Vitest، وهي خمس ثوانٍ افتراضيًا، ثم يفشل بخطأ انتهاء المهلة. مرّر معاملًا ثالثًا إلى <code dir=\"ltr\">test</code> لحالة أبطأ، أو اضبط <code dir=\"ltr\">testTimeout</code> في الإعدادات، لكن تعامل مع الـ unit test البطيء كعلامة على مشكلة: على الأرجح يتسرّب شيء حقيقي إلى الاختبار.</p>\n<p>في التمرين تختبر الصفوف الأربعة في ذلك الجدول وتلتقط أربعة أخطاء واقعية، منها خطأ التحصيل المزدوج. ويستبدل الدرس القادم الـ stubs المكتوبة باليد بـ <code dir=\"ltr\">vi.fn</code>.</p>\n","toc":[{"id":"sec-1","text":"الدالة الخاضعة للاختبار","level":2},{"id":"sec-2","text":"الـ assertions على الـ promises","level":2},{"id":"sec-3","text":"الـ stubs: متعاونون تتحكّم فيهم","level":2},{"id":"sec-4","text":"اختبر كل فرع من فروع معالجة الأخطاء","level":2},{"id":"sec-5","text":"المهل الزمنية (timeouts)","level":2}],"takeaways":["استخدم <code dir=\"ltr\">await</code> دائمًا مع الـ promise أو مع الـ assertion عليه؛ فالاختبار الذي ينتهي قبل أن يستقرّ الـ promise قد ينجح أيًّا كان ما يفعله الكود.","<code dir=\"ltr\">await expect(promise).rejects.toThrow('message')</code> هي أوضح طريقة لاختبار فشل غير متزامن.","الـ stub (بديل ثابت الاستجابة) متعاون مزيّف صغير تتحكّم فيه، مثل كائن api تنجح دالة post فيه، أو تفشل، أو تعدّ استدعاءاتها.","اختبر كل فرع من فروع معالجة الأخطاء، بما فيها الفرع الذي يجب أن يستسلم، وعُدّ الاستدعاءات حين يكون الفرق في عدد المحاولات."],"further":[{"title":"Vitest — resolves and rejects","url":"https://vitest.dev/api/expect#rejects"},{"title":"MDN — Using promises","url":"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises"},{"title":"Vitest — testTimeout","url":"https://vitest.dev/config/testtimeout"}],"quiz":[{"q":"<p>ينجح هذا الاختبار، مع أن التحقّق داخل <code dir=\"ltr\">saveExpense</code> حُذف وصار الاستدعاء يُحلّ بنجاح. لماذا لم يلتقط الاختبار ذلك؟</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\">'rejects a blank description'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">async</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:#CF222E;--shiki-dark:#FF7B72\">  try</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    await</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> saveExpense</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ description: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">' '</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, amountCents: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">100</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }, api);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">catch</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (error) {</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">    expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(error.message).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBe</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Description is required'</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></code></pre></div>\n","options":[{"text":"لا يستطيع <code dir=\"ltr\">toBe</code> مقارنة رسائل الأخطاء؛ يحتاج إلى <code dir=\"ltr\">toEqual</code>.","why":"الرسائل نصوص، فيقارنها <code dir=\"ltr\">toBe</code> بشكل صحيح؛ والمشكلة أن الـ matcher لا يُبلغ أصلًا هنا."},{"text":"حين يُحلّ الـ promise بنجاح، لا تعمل كتلة <code dir=\"ltr\">catch</code> أبدًا، فلا يُنفَّذ أي assertion وينجح الاختبار.","why":"صحيح. استخدم <code dir=\"ltr\">await expect(...).rejects.toThrow(...)</code>، الذي يفشل حين يُحلّ الـ promise بنجاح، أو أضف <code dir=\"ltr\">expect.assertions(1)</code>."},{"text":"تتجاهل Vitest الأخطاء المرمية داخل كتل <code dir=\"ltr\">catch</code>.","why":"فشل assertion داخل <code dir=\"ltr\">catch</code> كان سيُفشل الاختبار؛ المشكلة أن <code dir=\"ltr\">catch</code> لا تعمل أصلًا."}],"answer":1},{"q":"<p>يجب أن تعيد <code dir=\"ltr\">saveExpense</code> المحاولة مرة واحدة عند خطأ قابل لإعادة المحاولة، وأن تستسلم فورًا عند أي خطأ آخر. أيّ اختبار يلتقط نسخة تعيد المحاولة مع كل خطأ؟</p>","options":[{"text":"اجعل الـ stub لـ <code dir=\"ltr\">post</code> يرفض دائمًا بخطأ غير قابل لإعادة المحاولة، وأكّد أن الـ promise يُرفض.","why":"النسخة المعطوبة تنتهي بالرفض أيضًا (لأن محاولتها الثانية تفشل كذلك)، فلا يستطيع هذا الـ assertion وحده التمييز بين النسختين."},{"text":"اجعل الـ stub لـ <code dir=\"ltr\">post</code> ينجح، وأكّد أن الـ id يُعاد.","why":"في المسار السعيد لا يقع أي خطأ، فلا يعمل منطق إعادة المحاولة أبدًا."},{"text":"اجعل الـ stub لـ <code dir=\"ltr\">post</code> يرفض مرة واحدة بخطأ غير قابل لإعادة المحاولة، وعُدّ الاستدعاءات، وأكّد أنها واحدة بالضبط.","why":"صحيح. كلتا النسختين ترفضان، لكن المعطوبة وحدها تستدعي الـ API مرتين؛ وعدد الاستدعاءات هو الفرق الذي يمكن ملاحظته."}],"answer":2},{"q":"<p>يفشل اختبار يستخدم الـ stub التالي <code dir=\"ltr\">{ post: () =&gt; new Promise(() =&gt; {}) }</code> بعد خمس ثوانٍ بسبب انتهاء المهلة. ما الذي حدث؟</p>","options":[{"text":"يعيد الـ stub promise لا يستقرّ أبدًا، فينتظر <code dir=\"ltr\">await saveExpense(...)</code> حتى تنتهي مهلة الاختبار في Vitest.","why":"صحيح. الـ promise الذي لا يُستدعى فيه resolve ولا reject يبقى معلّقًا إلى الأبد؛ اجعل الـ stub ينجح أو يرفض صراحةً."},{"text":"تحتاج Vitest إلى <code dir=\"ltr\">vi.fn()</code> بدل الكائن العادي مع أي متعاون غير متزامن.","why":"الكائنات العادية التي تحتوي دوالًا غير متزامنة stubs صالحة تمامًا؛ المشكلة أن هذا تحديدًا لا ينتهي أبدًا."},{"text":"المهلة الافتراضية للاختبار أقصر من اللازم لكود الشبكة، ويجب رفعها إلى 30 ثانية.","why":"لا توجد شبكة هنا أصلًا. رفع المهلة يجعل الفشل نفسه يستغرق وقتًا أطول فقط."}],"answer":0}],"exercise":{"kind":"web","mode":"test","subject":"// Saves an expense through the API and resolves with the new id.\n// - Rejects with \"Description is required\" if the description is blank.\n// - Rejects with \"Amount must be positive\" unless amountCents is a\n//   positive integer. Invalid input never reaches the API.\n// - If api.post fails with an error that has { retryable: true },\n//   it tries exactly ONE more time.\n// - Any other API error is passed on: the promise rejects with it.\nasync function saveExpense(expense, api) {\n  if (!expense.description || !expense.description.trim()) {\n    throw new Error('Description is required');\n  }\n  if (!Number.isInteger(expense.amountCents) || expense.amountCents <= 0) {\n    throw new Error('Amount must be positive');\n  }\n  try {\n    const { id } = await api.post('/expenses', expense);\n    return id;\n  } catch (error) {\n    if (!error.retryable) throw error;\n    const { id } = await api.post('/expenses', expense);\n    return id;\n  }\n}\n","mutants":[{"id":"missing-await","code":"// Saves an expense through the API and resolves with the new id.\n// - Rejects with \"Description is required\" if the description is blank.\n// - Rejects with \"Amount must be positive\" unless amountCents is a\n//   positive integer. Invalid input never reaches the API.\n// - If api.post fails with an error that has { retryable: true },\n//   it tries exactly ONE more time.\n// - Any other API error is passed on: the promise rejects with it.\nasync function saveExpense(expense, api) {\n  if (!expense.description || !expense.description.trim()) {\n    throw new Error('Description is required');\n  }\n  if (!Number.isInteger(expense.amountCents) || expense.amountCents <= 0) {\n    throw new Error('Amount must be positive');\n  }\n  try {\n    const { id } = api.post('/expenses', expense);\n    return id;\n  } catch (error) {\n    if (!error.retryable) throw error;\n    const { id } = await api.post('/expenses', expense);\n    return id;\n  }\n}\n"},{"id":"swallows-error","code":"// Saves an expense through the API and resolves with the new id.\n// - Rejects with \"Description is required\" if the description is blank.\n// - Rejects with \"Amount must be positive\" unless amountCents is a\n//   positive integer. Invalid input never reaches the API.\n// - If api.post fails with an error that has { retryable: true },\n//   it tries exactly ONE more time.\n// - Any other API error is passed on: the promise rejects with it.\nasync function saveExpense(expense, api) {\n  if (!expense.description || !expense.description.trim()) {\n    throw new Error('Description is required');\n  }\n  if (!Number.isInteger(expense.amountCents) || expense.amountCents <= 0) {\n    throw new Error('Amount must be positive');\n  }\n  try {\n    const { id } = await api.post('/expenses', expense);\n    return id;\n  } catch (error) {\n    if (!error.retryable) return null;\n    const { id } = await api.post('/expenses', expense);\n    return id;\n  }\n}\n"},{"id":"no-retry","code":"// Saves an expense through the API and resolves with the new id.\n// - Rejects with \"Description is required\" if the description is blank.\n// - Rejects with \"Amount must be positive\" unless amountCents is a\n//   positive integer. Invalid input never reaches the API.\n// - If api.post fails with an error that has { retryable: true },\n//   it tries exactly ONE more time.\n// - Any other API error is passed on: the promise rejects with it.\nasync function saveExpense(expense, api) {\n  if (!expense.description || !expense.description.trim()) {\n    throw new Error('Description is required');\n  }\n  if (!Number.isInteger(expense.amountCents) || expense.amountCents <= 0) {\n    throw new Error('Amount must be positive');\n  }\n  try {\n    const { id } = await api.post('/expenses', expense);\n    return id;\n  } catch (error) {\n    throw error;\n    const { id } = await api.post('/expenses', expense);\n    return id;\n  }\n}\n"},{"id":"retries-everything","code":"// Saves an expense through the API and resolves with the new id.\n// - Rejects with \"Description is required\" if the description is blank.\n// - Rejects with \"Amount must be positive\" unless amountCents is a\n//   positive integer. Invalid input never reaches the API.\n// - If api.post fails with an error that has { retryable: true },\n//   it tries exactly ONE more time.\n// - Any other API error is passed on: the promise rejects with it.\nasync function saveExpense(expense, api) {\n  if (!expense.description || !expense.description.trim()) {\n    throw new Error('Description is required');\n  }\n  if (!Number.isInteger(expense.amountCents) || expense.amountCents <= 0) {\n    throw new Error('Amount must be positive');\n  }\n  try {\n    const { id } = await api.post('/expenses', expense);\n    return id;\n  } catch (error) {\n    \n    const { id } = await api.post('/expenses', expense);\n    return id;\n  }\n}\n"}],"starter":{"js":"const dinner = { description: 'Dinner', amountCents: 4500 };\n\n// A stub API: each call to post runs the next step in the list.\n// A step is either a value to resolve with or an Error to reject with.\nfunction stubApi(...steps) {\n  const api = {\n    calls: 0,\n    async post() {\n      const step = steps[api.calls];\n      api.calls += 1;\n      if (step instanceof Error) throw step;\n      return step;\n    },\n  };\n  return api;\n}\n\nconst retryable = (message) => Object.assign(new Error(message), { retryable: true });\n\ndescribe('saveExpense', () => {\n  test('rejects a blank description without calling the API', async () => {\n    const api = stubApi({ id: 'e1' });\n\n    await expect(saveExpense({ description: ' ', amountCents: 100 }, api)).rejects.toThrow('Description is required');\n    expect(api.calls).toBe(0);\n  });\n\n  // Add: the happy path, a retryable error followed by success,\n  // and a non-retryable error.\n});\n"},"solution":{"js":"const dinner = { description: 'Dinner', amountCents: 4500 };\n\nfunction stubApi(...steps) {\n  const api = {\n    calls: 0,\n    async post() {\n      const step = steps[api.calls];\n      api.calls += 1;\n      if (step instanceof Error) throw step;\n      return step;\n    },\n  };\n  return api;\n}\n\nconst retryable = (message) => Object.assign(new Error(message), { retryable: true });\n\ndescribe('saveExpense', () => {\n  test('rejects a blank description without calling the API', async () => {\n    const api = stubApi({ id: 'e1' });\n\n    await expect(saveExpense({ description: ' ', amountCents: 100 }, api)).rejects.toThrow('Description is required');\n    expect(api.calls).toBe(0);\n  });\n\n  test('resolves with the id from the API', async () => {\n    const api = stubApi({ id: 'e1' });\n\n    await expect(saveExpense(dinner, api)).resolves.toBe('e1');\n    expect(api.calls).toBe(1);\n  });\n\n  test('retries once after a retryable error', async () => {\n    const api = stubApi(retryable('Timed out'), { id: 'e2' });\n\n    await expect(saveExpense(dinner, api)).resolves.toBe('e2');\n    expect(api.calls).toBe(2);\n  });\n\n  test('passes on a non-retryable error without retrying', async () => {\n    const api = stubApi(new Error('Amount too large'), { id: 'e3' });\n\n    await expect(saveExpense(dinner, api)).rejects.toThrow('Amount too large');\n    expect(api.calls).toBe(1);\n  });\n});\n"},"checks":[{"id":"passes","suite":"subject"},{"id":"catches-missing-await","suite":"missing-await"},{"id":"catches-swallows-error","suite":"swallows-error"},{"id":"catches-no-retry","suite":"no-retry"},{"id":"catches-retries-everything","suite":"retries-everything"}],"title":"اختبر كل فروع saveExpense","prompt":"<p>تتحقّق <code dir=\"ltr\">saveExpense(expense, api)</code> من المصروف، وتستدعي <code dir=\"ltr\">api.post</code>، وتعيد المحاولة <strong>مرة واحدة</strong> عند الأخطاء المعلَّمة بـ <code dir=\"ltr\">retryable: true</code>، وتمرّر أي خطأ آخر كما هو. قواعدها مذكورة في تعليق الـ subject.</p>\n<p>يعطيك الكود المبدئي الدالة المساعدة <code dir=\"ltr\">stubApi(...steps)</code>: كل استدعاء لـ <code dir=\"ltr\">post</code> يستخدم الخطوة التالية، فيُحلّ بكائن أو يُرفض بـ <code dir=\"ltr\">Error</code>، ويعدّ <code dir=\"ltr\">api.calls</code> الاستدعاءات. وهناك أيضًا الدالة المساعدة <code dir=\"ltr\">retryable(message)</code>.</p>\n<p>أضف اختبارات حتى تلتقط مجموعتك أربعة أخطاء مخفيّة: <code dir=\"ltr\">await</code> مفقود، و<code dir=\"ltr\">catch</code> تعيد <code dir=\"ltr\">null</code> بدل الرفض، ونسخة لا تعيد المحاولة أبدًا، ونسخة تعيد المحاولة مع <strong>كل</strong> خطأ. تذكّر أن تضع <code dir=\"ltr\">await</code> قبل كل <code dir=\"ltr\">resolves</code> و<code dir=\"ltr\">rejects</code>.</p>\n","hints":["ابدأ بالمسار السعيد: <code dir=\"ltr\">stubApi({ id: 'e1' })</code> ثم <code dir=\"ltr\">await expect(saveExpense(dinner, api)).resolves.toBe('e1')</code>. هذا وحده يلتقط الـ await المفقود.","لإعادة المحاولة: يجب أن يُحلّ <code dir=\"ltr\">stubApi(retryable('Timed out'), { id: 'e2' })</code> بالقيمة <code dir=\"ltr\">'e2'</code> بعد استدعاءين.","للخطأ غير القابل لإعادة المحاولة: يجب أن يُرفض <code dir=\"ltr\">stubApi(new Error('Amount too large'), { id: 'e3' })</code> بتلك الرسالة <strong>وأن</strong> يكون <code dir=\"ltr\">api.calls</code> يساوي 1. عدد الاستدعاءات يثبّت قاعدة “لا إعادة محاولة” مباشرةً، أيًّا كانت الخطوة الثانية في الـ stub."],"explanation":"<p>الاختبارات الأربعة تعكس الفروع الأربعة. المسار السعيد يلتقط الـ <code dir=\"ltr\">await</code> المفقود: تفكيك promise معلّق يعطي <code dir=\"ltr\">id === undefined</code>. واختبار إعادة المحاولة يلتقط النسخة التي لا تعيد المحاولة أبدًا. أما اختبار الخطأ غير القابل لإعادة المحاولة فيؤدّي مهمّتين: <code dir=\"ltr\">rejects.toThrow</code> يلتقط الـ <code dir=\"ltr\">catch</code> التي تبتلع الأخطاء (لأنها تُحلّ بـ <code dir=\"ltr\">null</code>)، و<code dir=\"ltr\">expect(api.calls).toBe(1)</code> يلتقط النسخة التي تعيد المحاولة مع كل شيء. لو كان الـ stub يفشل في كل استدعاء، لكان هذا الخطأ الأخير غير مرئي لـ assertion الرفض، لأن محاولته الثانية ستفشل هي أيضًا؛ وفي تطبيق حقيقي هو الخطأ الذي يحصّل المبلغ من البطاقة مرتين. أما هنا فخطوة الـ stub <em>الثانية</em> نجاح، فتُحلّ النسخة المعطوبة بـ <code dir=\"ltr\">'e3'</code> وتُفشل assertion الـ <code dir=\"ltr\">rejects</code> أيضًا؛ لكن عدد الاستدعاءات هو ما يثبّت القاعدة أيًّا كان إعداد الـ stub.</p>\n","checkLabels":{"passes":"كل اختباراتك تنجح على saveExpense الحقيقية","catches-missing-await":"تلتقط النسخة التي ينقصها await","catches-swallows-error":"تلتقط النسخة التي تبتلع أخطاء الـ API","catches-no-retry":"تلتقط النسخة التي لا تعيد المحاولة أبدًا","catches-retries-everything":"تلتقط النسخة التي تعيد المحاولة مع الأخطاء غير القابلة لإعادة المحاولة"}},"words":624,"readMinutes":4}