{"id":"l-tj-3-4","courseId":"testing-javascript","lang":"ar","kind":"lesson","summary":"تختبر مكوّنات React من خلال الـ props والمخرجات المعروضة وتفاعل المستخدم باستخدام React Testing Library، وتستبدل الشبكة عند حدودها بـ fetch محقون أو بـ MSW.","html":"<p>كل ما جاء في هذا القسم حتى الآن ينطبق على React دون تغيير، لأن React Testing Library طبقة رقيقة فوق الاستعلامات نفسها وuser-event الذي تعرفه. الجزء الجديد حقًّا هو الشبكة: مكوّنات React تحبّ جلب البيانات، والطريقة التي تزيّف بها ذلك تقرّر إن كانت اختباراتك ستلتقط أخطاءً حقيقية.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">عرض مكوّن</h2>\n<p>يأخذ المكوّن <code dir=\"ltr\">ExpenseList</code> في Splitwise-lite المصروفات كـ prop، ولديه جزء واحد من الـ state (الحالة)، وهو مرشّح البحث:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"jsx\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">src/ExpenseList.jsx</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\"> { useState } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'react'</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\"> { formatMoney } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './money.js'</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\">export</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> ExpenseList</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ </span><span style=\"color:#953800;--shiki-dark:#FFA657\">expenses</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\">query</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">setQuery</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> useState</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</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:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> visible</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> expenses.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">filter</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">e</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    e.description.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toLowerCase</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">().</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">includes</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(query.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">trim</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">().</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toLowerCase</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:#CF222E;--shiki-dark:#FF7B72\">  return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">section</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> aria-label</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"Expenses\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">        Search &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">input</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> value</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">query</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> onChange</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">e</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> setQuery</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(e.target.value)</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\">      &#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">      {</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">visible.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">length</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> ===</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0</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\">        &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">p</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>No matching expenses&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">p</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><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\">        &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">ul</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">          {</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">visible.</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\">e</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\">            &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">li</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> key</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">e.id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">{</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">e.description</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">{</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">formatMoney</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(e.amountCents)</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">li</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">          ))</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">        &#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">ul</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      )</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    &#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">section</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<p>مع تثبيت <code dir=\"ltr\">@testing-library/react</code> وضبط <code dir=\"ltr\">environment: 'jsdom'</code>، تُقرأ الاختبارات مثل اختبارات الـ DOM التي كتبتها:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"jsx\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">src/ExpenseList.test.jsx</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\"> { render, screen } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '@testing-library/react'</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\"> userEvent </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '@testing-library/user-event'</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\"> { ExpenseList } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './ExpenseList.jsx'</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\"> expenses</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\">  { id: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'e1'</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\">4550</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> },</span></span>\n<span class=\"line\"><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\">, 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\"> },</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\">'filters expenses as you type'</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\"> user</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> userEvent.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">setup</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  render</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">ExpenseList</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> expenses</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">expenses</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</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:#1F2328;--shiki-dark:#E6EDF3\"> user.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">type</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\">'textbox'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Search'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }), </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'tax'</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\"> items</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\">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 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\">li</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> li.textContent)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toEqual</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">([</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Taxi: $18.00'</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\">'shows the new expense when the prop changes'</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:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">rerender</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> render</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">ExpenseList</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> expenses</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">expenses</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</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\">  rerender</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">ExpenseList</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> expenses</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">[</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">...</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">expenses, { id: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'e3'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, description: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Museum'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, amountCents: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2400</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>\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\">'Museum: $24.00'</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:#1F2328;--shiki-dark:#E6EDF3\">});</span></span></code></pre></div>\n<p>تُختبر <strong>الـ props</strong> بالـ render بقيم مختلفة (و<code dir=\"ltr\">rerender</code> للتحديثات). ويُختبر <strong>الـ state</strong> بالتفاعل ثم فحص النتيجة؛ لا توجد طريقة لقراءة <code dir=\"ltr\">query</code> من الاختبار، وهذه ميزة. يلفّ <code dir=\"ltr\">render</code> التحديثات في <code dir=\"ltr\">act()</code> الخاصة بـ React نيابةً عنك، وتزيل React Testing Library المكوّنات (unmount) بعد كل اختبار حين يكون خيار <code dir=\"ltr\">globals</code> في Vitest مفعّلًا (وإلا فاستدعِ <code dir=\"ltr\">cleanup</code> داخل <code dir=\"ltr\">afterEach</code>).</p>\n<aside class=\"callout callout-note\" 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\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 16v-4\"/><path d=\"M12 8h.01\"/></svg><span><span class=\"callout-kind\">ملاحظة</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>المكوّنات في متصفّح حقيقي</span></p>\n<p>لدى Vitest 4 أيضًا وضع متصفّح (Browser Mode) مستقر، يشغّل اختبارات المكوّنات في متصفّح حقيقي (يقوده مزوّد مثل Playwright) بدل jsdom. بدء تشغيله أبطأ، لكنه يلتقط فروقات التخطيط وواجهات المتصفّح التي لا يستطيع jsdom التقاطها. تحتفظ فرق كثيرة بـ jsdom لمعظم اختبارات المكوّنات، وتستخدم Browser Mode حيث يهمّ العرض الحقيقي.</p>\n</aside>\n<h2 id=\"sec-2\" tabindex=\"-1\">أين تزيّف الشبكة</h2>\n<p>المكوّن الذي يحمّل مجموعة يستدعي دالة بيانات، وهي تستدعي <code dir=\"ltr\">fetch</code>، الذي يمرّ عبر الشبكة. تستطيع قطع هذه السلسلة عند ثلاث نقاط:</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 210\" role=\"img\" aria-labelledby=\"l-tj-3-4-t1\"><defs><marker id=\"arrow-l-tj-3-4-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-4-t1\">سلسلة من المكوّن إلى loadGroup إلى fetch إلى الشبكة. يقطع vi.mock مبكرًا ويتخطّى loadGroup؛ وحقن fetch يقطع بعد loadGroup؛ ويعترض MSW عند طرف الشبكة.</title>\n  <rect class=\"d-box\" x=\"10\" y=\"40\" width=\"140\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"80\" y=\"74\" text-anchor=\"middle\">المكوّن</text>\n  <path class=\"d-arrow\" d=\"M150 68 L188 68\" marker-end=\"url(#arrow-l-tj-3-4-1)\"/>\n  <rect class=\"d-box\" x=\"190\" y=\"40\" width=\"140\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-code\" x=\"260\" y=\"74\" text-anchor=\"middle\">loadGroup()</text>\n  <path class=\"d-arrow\" d=\"M330 68 L368 68\" marker-end=\"url(#arrow-l-tj-3-4-1)\"/>\n  <rect class=\"d-box\" x=\"370\" y=\"40\" width=\"140\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-code\" x=\"440\" y=\"74\" text-anchor=\"middle\">fetch()</text>\n  <path class=\"d-arrow\" d=\"M510 68 L548 68\" marker-end=\"url(#arrow-l-tj-3-4-1)\"/>\n  <rect class=\"d-box\" x=\"550\" y=\"40\" width=\"120\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"610\" y=\"74\" text-anchor=\"middle\">الشبكة</text>\n  <path class=\"d-line d-dashed\" d=\"M170 30 L170 150\"/>\n  <text class=\"d-label-muted\" x=\"170\" y=\"172\" text-anchor=\"middle\">vi.mock</text>\n  <path class=\"d-line d-dashed\" d=\"M350 30 L350 150\"/>\n  <text class=\"d-label-muted\" x=\"350\" y=\"172\" text-anchor=\"middle\">حقن fetch</text>\n  <path class=\"d-line d-dashed\" d=\"M530 30 L530 150\"/>\n  <text class=\"d-label-muted\" x=\"530\" y=\"172\" text-anchor=\"middle\">MSW</text>\n  <text class=\"d-label\" x=\"350\" y=\"200\" text-anchor=\"middle\">قطع متأخّر = كود حقيقي مختبَر أكثر</text>\n</svg><figcaption>اقطع السلسلة في أبعد نقطة ممكنة</figcaption></figure>\n<ul>\n<li><strong>استبدال وحدة البيانات بـ mock</strong> (<code dir=\"ltr\">vi.mock('./load-group.js')</code>) يتخطّى بناء الـ URL، ومعالجة رمز الحالة، وتشكيل الاستجابة. وهذه بالضبط مواضع الأخطاء.</li>\n<li><strong>حقن <code dir=\"ltr\">fetch</code></strong> (<code dir=\"ltr\">loadGroup(id, fetchImpl)</code>) يشغّل كل كودك ولا يزيّف إلا وسيلة النقل. بسيط، وبلا مكتبة، وهو ما يستخدمه التمرين.</li>\n<li><strong>MSW</strong> (Mock Service Worker) يعترض الطلبات على مستوى الشبكة في Node وفي المتصفّح. يستدعي كودك <code dir=\"ltr\">fetch</code> الحقيقي دون أي تغيير:</li>\n</ul>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">src/test/server.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\"> { setupServer } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'msw/node'</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\"> { http, HttpResponse } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'msw'</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\">export</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> server</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> setupServer</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  http.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">get</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'https://api.splitwise-lite.test/groups/:id'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, ({ </span><span style=\"color:#953800;--shiki-dark:#FFA657\">params</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    HttpResponse.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">json</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">`Group ${</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">params</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">.</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">id</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">}`</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, members: [], expenses: [] }),</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\">server.listen({ onUnhandledRequest: 'error' })</code> قبل كل الاختبارات، و<code dir=\"ltr\">server.resetHandlers()</code> بعد كل اختبار، و<code dir=\"ltr\">server.close()</code> في النهاية. ويستطيع اختبار واحد استبدال مسار ما بـ <code dir=\"ltr\">server.use(http.get(..., () =&gt; new HttpResponse(null, { status: 404 })))</code> ليختبر مسار الخطأ. ويمكن للمعالِجات نفسها أن تشغّل تطبيقك أثناء التطوير وفي Storybook، فيبقى الـ API المزيّف في مكان واحد.</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>تزييف المسار السعيد وحده</span></p>\n<p>الـ mock الشبكي الذي يعيد دائمًا 200 مع بيانات مثالية يختبر الكود الذي كنت أقلّ قلقًا بشأنه. كل دالة بيانات تستحق اختبارًا لرمز حالة غير ناجح، ويُفضَّل أيضًا اختبار لانقطاع الشبكة (<code dir=\"ltr\">HttpResponse.error()</code> في MSW، أو promise مرفوض من fetch محقون). أكّد رسالة الخطأ بعينها، كي لا يمرّ انهيار في مكان آخر على أنه معالجة صحيحة للخطأ.</p>\n</aside>\n<h2 id=\"sec-3\" tabindex=\"-1\">ما لا تختبره في المكوّن</h2>\n<p>بعض اختبارات المكوّنات تكلّف أكثر مما تلتقط. تخطّها:</p>\n<ul>\n<li><strong>تفاصيل التنسيق</strong>: هل الصنف <code dir=\"ltr\">text-red-600</code>، أو هل الحشوة 12px. الانتكاسات البصرية تُلتقط بشكل أفضل باختبارات لقطات الشاشة في متصفّح حقيقي، أو بمراجعة بشرية.</li>\n<li><strong>الأجزاء الداخلية لمكتبات خارجية</strong>: لا تحتاج إلى إثبات أن مكتبة منتقي التاريخ تفتح تقويمها. اختبر أن مكوّنك يمرّر لها القيمة الصحيحة ويتفاعل مع <code dir=\"ltr\">onChange</code> الخاص بها.</li>\n<li><strong>snapshots للشجرة كاملة</strong>: <code dir=\"ltr\">expect(container).toMatchSnapshot()</code> على مكوّن كبير يفشل مع كل تغيير غير ضارّ في البنية، ويتعلّم المراجعون الضغط على “update snapshot” دون قراءة. قد يكون snapshot صغير مضمّن لقيمة منسّقة واحدة مقبولًا؛ أما snapshot من 300 سطر فضجيج.</li>\n</ul>\n<h2 id=\"sec-4\" tabindex=\"-1\">التأكيد على طرفي الطلب</h2>\n<p>اختبار الشبكة الجيد يفحص ما <strong>أرسلته</strong>، إضافة إلى ما فعلته بالإجابة. الـ URL الخاطئ خطأ حقيقي (<code dir=\"ltr\">/group/</code> بدل <code dir=\"ltr\">/groups/</code>)، ولا يلتقطه إلا assertion على الطلب، لأن البديل المزيّف يجيب عن أي شيء بسرور. مع fetch محقون يكون ذلك <code dir=\"ltr\">expect(fetchMock).toHaveBeenCalledWith('https://api.splitwise-lite.test/groups/lisbon')</code>؛ ومع MSW، لا يطابق المعالِج إلا المسار الصحيح، و<code dir=\"ltr\">onUnhandledRequest: 'error'</code> يُفشل الاختبار مع أي شيء آخر.</p>\n<p>في التمرين تختبر <code dir=\"ltr\">loadGroup</code> مع <code dir=\"ltr\">fetch</code> محقون يعيد كائنات <code dir=\"ltr\">Response</code> حقيقية، وتلتقط أربعة أخطاء في الطلب وتشكيل الاستجابة. وبهذا يكتمل قسم الواجهة. بعده يأتي المتصفّح الحقيقي، حيث يلتقي أخيرًا التخطيط والتوجيه (routing) والخادم الحقيقي.</p>\n","toc":[{"id":"sec-1","text":"عرض مكوّن","level":2},{"id":"sec-2","text":"أين تزيّف الشبكة","level":2},{"id":"sec-3","text":"ما لا تختبره في المكوّن","level":2},{"id":"sec-4","text":"التأكيد على طرفي الطلب","level":2}],"takeaways":["يركّب <code dir=\"ltr\">render</code> في React Testing Library المكوّن داخل مستند jsdom؛ وتستعلم عنه وتتفاعل معه تمامًا كما تفعل مع الـ DOM العادي.","اختبر الـ props بالـ render بقيم مختلفة (أو بـ <code dir=\"ltr\">rerender</code>)، واختبر الـ state بالتفاعل؛ ولا تمدّ يدك إلى الـ state أو الـ hooks مباشرة أبدًا.","استبدل الشبكة عند طرفها، إمّا بحقن <code dir=\"ltr\">fetch</code> أو باعتراض الطلبات عبر MSW، كي يظلّ كود تشكيل البيانات الخاص بك يعمل في الاختبار.","أكّد الطلب الذي أرسلته (الـ URL والطريقة)، وأكّد أيضًا ما تفعله الواجهة أو الدالة بالاستجابة، بما في ذلك رموز حالة الخطأ."],"further":[{"title":"Testing Library — React Testing Library","url":"https://testing-library.com/docs/react-testing-library/intro"},{"title":"Testing Library — React Testing Library API","url":"https://testing-library.com/docs/react-testing-library/api"},{"title":"MDN — Response","url":"https://developer.mozilla.org/en-US/docs/Web/API/Response"},{"title":"Vitest — Mocking requests","url":"https://vitest.dev/guide/mocking/requests"}],"quiz":[{"q":"<p>كيف يجب أن يتحقّق الاختبار من أن <code dir=\"ltr\">ExpenseList</code> تُرشّح النتائج أثناء الكتابة في مربّع Search؟</p>","options":[{"text":"اكتب في حقل Search باستخدام user-event، وأكّد أيّ عناصر القائمة ظاهرة على الشاشة.","why":"صحيح. هذا هو السلوك الذي يراه المستخدم، ويظلّ الاختبار ينجح إن نقلت حالة الترشيح إلى reducer أو إلى معامل في الـ URL."},{"text":"اقرأ الـ state المسمّى <code dir=\"ltr\">query</code> في المكوّن بعد الكتابة، وتحقّق من أنه يساوي النص المكتوب.","why":"الـ state تفصيل داخلي؛ قد يخزّن المكوّن النص الصحيح ويعرض مع ذلك القائمة الخاطئة."},{"text":"استدعِ الدالة <code dir=\"ltr\">setQuery</code> مباشرة والتقط snapshot للنتيجة.","why":"لا تستطيع الاختبارات (ولا يجب أن تستطيع) الوصول إلى دوال ضبط الـ state في المكوّن؛ والـ snapshot لن يقول أيّ العناصر يجب أن تكون ظاهرة."}],"answer":0},{"q":"<p>لماذا تفضّل فرق كثيرة MSW على <code dir=\"ltr\">vi.mock('./api.js')</code> للمكوّنات التي تجلب البيانات؟</p>","options":[{"text":"يجعل MSW الطلبات إلى الخادم الحقيقي أسرع.","why":"لا يتحدّث MSW إلى الخادم الحقيقي أصلًا؛ بل يعترض الطلبات ويجيب عنها بالمعالِجات التي تكتبها."},{"text":"لا يعمل <code dir=\"ltr\">vi.mock</code> في الملفات التي تعرض مكوّنات React.","why":"يعمل <code dir=\"ltr\">vi.mock</code> في أي مكان؛ المشكلة في مقدار الكود الحقيقي الذي يتخطّاه."},{"text":"يعترض MSW الطلبات على مستوى الشبكة، فيظلّ الـ fetch الحقيقي وكود تشكيل البيانات لديك يعملان في الاختبار.","why":"صحيح. مع mock للوحدة، يُستبدل أي خطأ في بناء الـ URL أو تحليل الاستجابة ويختفي؛ أما مع MSW فيُختبر فعلًا."}],"answer":2},{"q":"<p>نسيت <code dir=\"ltr\">loadGroup</code> أن تفحص <code dir=\"ltr\">response.ok</code>. مع استجابة 404 يكون الـ JSON هو <code dir=\"ltr\">{ &quot;error&quot;: &quot;Not found&quot; }</code>، فيرمي الكود <code dir=\"ltr\">Cannot read properties of undefined</code>. أيّ assertion يلتقط الفحص المفقود؟</p>","options":[{"text":"<code dir=\"ltr\">await expect(loadGroup('nope', fetchMock)).rejects.toThrow()</code>","why":"النسخة المعطوبة تُرفض أيضًا، بخطأ <code dir=\"ltr\">TypeError</code>، فينجح أي assertion يقبل أي خطأ."},{"text":"<code dir=\"ltr\">await expect(loadGroup('nope', fetchMock)).rejects.toThrow('HTTP 404')</code>","why":"صحيح. مطابقة الرسالة تثبت أن رمز الحالة فُحص، لا أن شيئًا آخر انفجر بالمصادفة."},{"text":"<code dir=\"ltr\">expect(fetchMock).toHaveBeenCalledTimes(1)</code>","why":"كلتا النسختين تستدعيان fetch مرة واحدة بالضبط؛ الفرق فيما تفعلانه بالاستجابة."}],"answer":1}],"exercise":{"kind":"web","mode":"test","subject":"const API = 'https://api.splitwise-lite.test';\n\n// Loads a group: GET `${API}/groups/<id>` (id URL-encoded).\n// The API answers with JSON like:\n//   { \"name\": \"Lisbon trip\",\n//     \"members\": [{ \"name\": \"Ana\" }, { \"name\": \"Ben\" }],\n//     \"expenses\": [{ \"description\": \"Dinner\", \"amount\": 45.5,\n//                    \"paid_by\": \"Ana\", \"split_among\": [\"Ana\", \"Ben\"] }] }\n// and loadGroup resolves with Splitwise-lite's own shape:\n//   { name: \"Lisbon trip\", members: [\"Ana\", \"Ben\"],\n//     expenses: [{ description: \"Dinner\", amountCents: 4550,\n//                  paidBy: \"Ana\", splitAmong: [\"Ana\", \"Ben\"] }] }\n// If the response is not ok, it rejects with\n//   \"Could not load group <id> (HTTP <status>)\".\nasync function loadGroup(groupId, fetchImpl = fetch) {\n  const response = await fetchImpl(`${API}/groups/${encodeURIComponent(groupId)}`);\n  if (!response.ok) {\n    throw new Error(`Could not load group ${groupId} (HTTP ${response.status})`);\n  }\n  const data = await response.json();\n  return {\n    name: data.name,\n    members: data.members.map((member) => member.name),\n    expenses: data.expenses.map((e) => ({\n      description: e.description,\n      amountCents: Math.round(e.amount * 100),\n      paidBy: e.paid_by,\n      splitAmong: e.split_among,\n    })),\n  };\n}\n","mutants":[{"id":"ignores-status","code":"const API = 'https://api.splitwise-lite.test';\n\n// Loads a group: GET `${API}/groups/<id>` (id URL-encoded).\n// The API answers with JSON like:\n//   { \"name\": \"Lisbon trip\",\n//     \"members\": [{ \"name\": \"Ana\" }, { \"name\": \"Ben\" }],\n//     \"expenses\": [{ \"description\": \"Dinner\", \"amount\": 45.5,\n//                    \"paid_by\": \"Ana\", \"split_among\": [\"Ana\", \"Ben\"] }] }\n// and loadGroup resolves with Splitwise-lite's own shape:\n//   { name: \"Lisbon trip\", members: [\"Ana\", \"Ben\"],\n//     expenses: [{ description: \"Dinner\", amountCents: 4550,\n//                  paidBy: \"Ana\", splitAmong: [\"Ana\", \"Ben\"] }] }\n// If the response is not ok, it rejects with\n//   \"Could not load group <id> (HTTP <status>)\".\nasync function loadGroup(groupId, fetchImpl = fetch) {\n  const response = await fetchImpl(`${API}/groups/${encodeURIComponent(groupId)}`);\n  const data = await response.json();\n  return {\n    name: data.name,\n    members: data.members.map((member) => member.name),\n    expenses: data.expenses.map((e) => ({\n      description: e.description,\n      amountCents: Math.round(e.amount * 100),\n      paidBy: e.paid_by,\n      splitAmong: e.split_among,\n    })),\n  };\n}\n"},{"id":"wrong-url","code":"const API = 'https://api.splitwise-lite.test';\n\n// Loads a group: GET `${API}/groups/<id>` (id URL-encoded).\n// The API answers with JSON like:\n//   { \"name\": \"Lisbon trip\",\n//     \"members\": [{ \"name\": \"Ana\" }, { \"name\": \"Ben\" }],\n//     \"expenses\": [{ \"description\": \"Dinner\", \"amount\": 45.5,\n//                    \"paid_by\": \"Ana\", \"split_among\": [\"Ana\", \"Ben\"] }] }\n// and loadGroup resolves with Splitwise-lite's own shape:\n//   { name: \"Lisbon trip\", members: [\"Ana\", \"Ben\"],\n//     expenses: [{ description: \"Dinner\", amountCents: 4550,\n//                  paidBy: \"Ana\", splitAmong: [\"Ana\", \"Ben\"] }] }\n// If the response is not ok, it rejects with\n//   \"Could not load group <id> (HTTP <status>)\".\nasync function loadGroup(groupId, fetchImpl = fetch) {\n  const response = await fetchImpl(`${API}/group/${encodeURIComponent(groupId)}`);\n  if (!response.ok) {\n    throw new Error(`Could not load group ${groupId} (HTTP ${response.status})`);\n  }\n  const data = await response.json();\n  return {\n    name: data.name,\n    members: data.members.map((member) => member.name),\n    expenses: data.expenses.map((e) => ({\n      description: e.description,\n      amountCents: Math.round(e.amount * 100),\n      paidBy: e.paid_by,\n      splitAmong: e.split_among,\n    })),\n  };\n}\n"},{"id":"no-cents","code":"const API = 'https://api.splitwise-lite.test';\n\n// Loads a group: GET `${API}/groups/<id>` (id URL-encoded).\n// The API answers with JSON like:\n//   { \"name\": \"Lisbon trip\",\n//     \"members\": [{ \"name\": \"Ana\" }, { \"name\": \"Ben\" }],\n//     \"expenses\": [{ \"description\": \"Dinner\", \"amount\": 45.5,\n//                    \"paid_by\": \"Ana\", \"split_among\": [\"Ana\", \"Ben\"] }] }\n// and loadGroup resolves with Splitwise-lite's own shape:\n//   { name: \"Lisbon trip\", members: [\"Ana\", \"Ben\"],\n//     expenses: [{ description: \"Dinner\", amountCents: 4550,\n//                  paidBy: \"Ana\", splitAmong: [\"Ana\", \"Ben\"] }] }\n// If the response is not ok, it rejects with\n//   \"Could not load group <id> (HTTP <status>)\".\nasync function loadGroup(groupId, fetchImpl = fetch) {\n  const response = await fetchImpl(`${API}/groups/${encodeURIComponent(groupId)}`);\n  if (!response.ok) {\n    throw new Error(`Could not load group ${groupId} (HTTP ${response.status})`);\n  }\n  const data = await response.json();\n  return {\n    name: data.name,\n    members: data.members.map((member) => member.name),\n    expenses: data.expenses.map((e) => ({\n      description: e.description,\n      amountCents: e.amount,\n      paidBy: e.paid_by,\n      splitAmong: e.split_among,\n    })),\n  };\n}\n"},{"id":"snake-case-leak","code":"const API = 'https://api.splitwise-lite.test';\n\n// Loads a group: GET `${API}/groups/<id>` (id URL-encoded).\n// The API answers with JSON like:\n//   { \"name\": \"Lisbon trip\",\n//     \"members\": [{ \"name\": \"Ana\" }, { \"name\": \"Ben\" }],\n//     \"expenses\": [{ \"description\": \"Dinner\", \"amount\": 45.5,\n//                    \"paid_by\": \"Ana\", \"split_among\": [\"Ana\", \"Ben\"] }] }\n// and loadGroup resolves with Splitwise-lite's own shape:\n//   { name: \"Lisbon trip\", members: [\"Ana\", \"Ben\"],\n//     expenses: [{ description: \"Dinner\", amountCents: 4550,\n//                  paidBy: \"Ana\", splitAmong: [\"Ana\", \"Ben\"] }] }\n// If the response is not ok, it rejects with\n//   \"Could not load group <id> (HTTP <status>)\".\nasync function loadGroup(groupId, fetchImpl = fetch) {\n  const response = await fetchImpl(`${API}/groups/${encodeURIComponent(groupId)}`);\n  if (!response.ok) {\n    throw new Error(`Could not load group ${groupId} (HTTP ${response.status})`);\n  }\n  const data = await response.json();\n  return {\n    name: data.name,\n    members: data.members.map((member) => member.name),\n    expenses: data.expenses.map((e) => ({\n      description: e.description,\n      amountCents: Math.round(e.amount * 100),\n      paidBy: e.paidBy,\n      splitAmong: e.split_among,\n    })),\n  };\n}\n"}],"starter":{"js":"// Builds a real Response, like the one fetch resolves with.\nconst jsonResponse = (body, status = 200) =>\n  new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });\n\nconst apiGroup = {\n  name: 'Lisbon trip',\n  members: [{ name: 'Ana' }, { name: 'Ben' }],\n  expenses: [{ description: 'Dinner', amount: 45.5, paid_by: 'Ana', split_among: ['Ana', 'Ben'] }],\n};\n\ndescribe('loadGroup', () => {\n  test('resolves with the group name', async () => {\n    const fetchMock = vi.fn().mockResolvedValue(jsonResponse(apiGroup));\n\n    const group = await loadGroup('lisbon', fetchMock);\n\n    expect(group.name).toBe('Lisbon trip');\n  });\n\n  // Add: the URL that was requested, the full converted shape,\n  // and what happens with a 404.\n});\n"},"solution":{"js":"// Builds a real Response, like the one fetch resolves with.\nconst jsonResponse = (body, status = 200) =>\n  new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });\n\nconst apiGroup = {\n  name: 'Lisbon trip',\n  members: [{ name: 'Ana' }, { name: 'Ben' }],\n  expenses: [{ description: 'Dinner', amount: 45.5, paid_by: 'Ana', split_among: ['Ana', 'Ben'] }],\n};\n\ndescribe('loadGroup', () => {\n  test('requests the group by id', async () => {\n    const fetchMock = vi.fn().mockResolvedValue(jsonResponse(apiGroup));\n\n    await loadGroup('lisbon', fetchMock);\n\n    expect(fetchMock).toHaveBeenCalledWith('https://api.splitwise-lite.test/groups/lisbon');\n  });\n\n  test(\"converts the API's shape into Splitwise-lite's\", async () => {\n    const fetchMock = vi.fn().mockResolvedValue(jsonResponse(apiGroup));\n\n    const group = await loadGroup('lisbon', fetchMock);\n\n    expect(group).toEqual({\n      name: 'Lisbon trip',\n      members: ['Ana', 'Ben'],\n      expenses: [{ description: 'Dinner', amountCents: 4550, paidBy: 'Ana', splitAmong: ['Ana', 'Ben'] }],\n    });\n  });\n\n  test('rejects with the status when the response is not ok', async () => {\n    const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ error: 'Not found' }, 404));\n\n    await expect(loadGroup('nope', fetchMock)).rejects.toThrow('Could not load group nope (HTTP 404)');\n  });\n});\n"},"checks":[{"id":"passes","suite":"subject"},{"id":"catches-ignores-status","suite":"ignores-status"},{"id":"catches-wrong-url","suite":"wrong-url"},{"id":"catches-no-cents","suite":"no-cents"},{"id":"catches-snake-case-leak","suite":"snake-case-leak"}],"title":"زيّف الشبكة عند طرفها","prompt":"<p>تطلب <code dir=\"ltr\">loadGroup(groupId, fetchImpl)</code> العنوان <code dir=\"ltr\">https://api.splitwise-lite.test/groups/&lt;id&gt;</code>، وتحوّل الـ JSON القادم من الـ API (بالدولار، وبصيغة snake_case) إلى شكل Splitwise-lite (بالسنتات، وبصيغة camelCase)، وتُرفض بالرسالة <code dir=\"ltr\">Could not load group &lt;id&gt; (HTTP &lt;status&gt;)</code> حين لا تكون الاستجابة ok. الأشكال الدقيقة مذكورة في تعليق الـ subject.</p>\n<p>يعطيك الكود المبدئي <code dir=\"ltr\">jsonResponse(body, status)</code> التي تبني <code dir=\"ltr\">Response</code> حقيقيًا، وعيّنة <code dir=\"ltr\">apiGroup</code>. مرّر <code dir=\"ltr\">vi.fn().mockResolvedValue(jsonResponse(...))</code> على أنه <code dir=\"ltr\">fetchImpl</code> وأضف اختبارات حتى تلتقط مجموعتك أربعة أخطاء مخفيّة: نسخة <strong>تتجاهل</strong> رمز حالة HTTP، ونسخة تطلب <strong>URL خاطئًا</strong>، ونسخة تنسى تحويل الدولارات إلى <strong>سنتات</strong>، ونسخة تقرأ <code dir=\"ltr\">paidBy</code> من الحقل الخاطئ.</p>\n","hints":["افحص الطلب: <code dir=\"ltr\">expect(fetchMock).toHaveBeenCalledWith('https://api.splitwise-lite.test/groups/lisbon')</code>.","قارن النتيجة كلها بـ <code dir=\"ltr\">toEqual</code>؛ الكائن المتوقّع مكتوب بالتفصيل في تعليق الـ subject (45.5 دولارًا تساوي 4550 سنتًا).","لمسار الخطأ، استخدم <code dir=\"ltr\">jsonResponse({ error: 'Not found' }, 404)</code> وأكّد الرسالة، لا مجرّد الرفض: فالنسخة المعطوبة تُرفض أيضًا، بخطأ TypeError."],"explanation":"<p>لأن وسيلة النقل وحدها مزيّفة، يعمل كل سطر من <code dir=\"ltr\">loadGroup</code> في الاختبار. يلتقط <code dir=\"ltr\">toHaveBeenCalledWith</code> على الـ fetch mock الـ URL الخاطئ، الذي لا يراه أي assertion على النتيجة، لأن البديل المزيّف يجيب عن أي URL. و<code dir=\"ltr\">toEqual</code> واحد على المجموعة المحوّلة يلتقط خطأي التشكيل معًا: الدولارات التي بقيت 45.5، و<code dir=\"ltr\">paidBy</code> الذي يعود <code dir=\"ltr\">undefined</code>. اختبار 404 هو الدقيق: بدون فحص <code dir=\"ltr\">ok</code>، يظلّ الكود يُرفض، بالرسالة <code dir=\"ltr\">Cannot read properties of undefined</code>، فلا يثبت أن رمز الحالة عولج عن قصد إلا مطابقة الرسالة <code dir=\"ltr\">HTTP 404</code>. وهذا هو الانضباط نفسه الذي يمنحك إياه MSW، دون المكتبة.</p>\n","checkLabels":{"passes":"كل اختباراتك تنجح على loadGroup الحقيقية","catches-ignores-status":"تلتقط النسخة التي تتجاهل رمز حالة HTTP","catches-wrong-url":"تلتقط النسخة التي تطلب URL خاطئًا","catches-no-cents":"تلتقط النسخة التي تُبقي الدولارات بدل السنتات","catches-snake-case-leak":"تلتقط النسخة التي تقرأ paidBy من الحقل الخاطئ"}},"words":594,"readMinutes":3}