{"id":"l-tj-3-3","courseId":"testing-javascript","lang":"en","kind":"lesson","summary":"Test UI that changes after data loads or fails, using findBy queries and waitFor instead of sleeps, and control the async dependency to test loading, success and error states.","html":"<p>The balances panel in Splitwise-lite loads data from the server. For a moment it says “Loading balances…”, then it shows the list, or, on a train in a tunnel, “Could not load balances.” with a “Try again” button. Each of those states is something a user sees, so each deserves a test. The difficulty is time: the DOM you want to check doesn’t exist yet when the test reaches the line that checks it.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">The wrong way: sleeping</h2>\n<p>The first instinct is to wait a bit:</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=\"Copy code\"><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>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">mountBalancePanel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, loadBalances);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> new</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> Promise</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">resolve</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> setTimeout</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(resolve, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">500</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)); </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// please be enough</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'list'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Balances'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> })).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBeInTheDocument</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span></code></pre></div>\n<p>On your laptop the data arrives in 5 ms and the test wastes 495. On a busy CI runner it sometimes takes 600, and the test fails for no reason. Multiply by a few hundred tests and you have a slow suite that is red every Tuesday. <strong>Wait for the condition, not for the clock.</strong></p>\n<h2 id=\"sec-2\" tabindex=\"-1\">findBy: wait for an element</h2>\n<p><code dir=\"ltr\">findBy</code> queries are <code dir=\"ltr\">getBy</code> queries that retry. They return a promise that resolves as soon as the element exists, and reject if it doesn’t appear within a timeout (1,000 ms by default):</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">src/balance-panel.test.js</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><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>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// @vitest-environment jsdom</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { afterEach, test, expect, vi } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'vitest'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { screen } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '@testing-library/dom'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '@testing-library/jest-dom/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\"> { mountBalancePanel } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './balance-panel.js'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">afterEach</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  document.body.innerHTML </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> ''</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'shows a loading message, then the balances'</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\"> loadBalances</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> vi.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">fn</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">().</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">mockResolvedValue</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ Ana: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">500</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, Ben: </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">-</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">500</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> });</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  mountBalancePanel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, loadBalances);</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\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'status'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveTextContent</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Loading balances'</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\">await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">findByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'list'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Balances'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> })).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBeInTheDocument</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">queryByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'status'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).not.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBeInTheDocument</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>The test is fast when the code is fast and patient when it is slow, and it reads like the story of what the user sees. The last line matters too: a panel that shows the data but leaves “Loading…” on screen is a real bug.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">waitFor: wait for anything else</h2>\n<p><code dir=\"ltr\">waitFor(callback)</code> runs the callback, and if it throws, runs it again every 50 ms until it passes or the timeout expires. Use it for conditions that aren’t “an element appeared”:</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=\"Copy code\"><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>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> waitFor</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(loadBalances).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveBeenCalledTimes</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></code></pre></div>\n<p>Vitest has its own <code dir=\"ltr\">vi.waitFor</code> that works the same way outside the DOM. For the reverse case, <code dir=\"ltr\">waitForElementToBeRemoved(() =&gt; screen.queryByRole('status'))</code> waits for something to disappear.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 200\" role=\"img\" aria-labelledby=\"l-tj-3-3-t1\"><defs><marker id=\"arrow-l-tj-3-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-3-3-t1\">A timeline: the query is retried every 50 milliseconds; it fails while loading, then passes once the list renders, well before the 1000 millisecond timeout.</title>\n  <path class=\"d-line\" d=\"M40 100 L640 100\"/>\n  <text class=\"d-label-muted\" x=\"40\" y=\"130\">0 ms</text>\n  <text class=\"d-label-muted\" x=\"600\" y=\"130\">1000 ms</text>\n  <path class=\"d-line d-dashed\" d=\"M620 60 L620 140\"/>\n  <text class=\"d-label-muted\" x=\"620\" y=\"50\" text-anchor=\"middle\">timeout</text>\n  <circle class=\"d-dot\" cx=\"60\" cy=\"100\" r=\"6\"/>\n  <circle class=\"d-dot\" cx=\"110\" cy=\"100\" r=\"6\"/>\n  <circle class=\"d-dot\" cx=\"160\" cy=\"100\" r=\"6\"/>\n  <circle class=\"d-dot\" cx=\"210\" cy=\"100\" r=\"6\"/>\n  <text class=\"d-label\" x=\"135\" y=\"80\" text-anchor=\"middle\">not there yet</text>\n  <rect class=\"d-box-success\" x=\"250\" y=\"82\" width=\"150\" height=\"36\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"325\" y=\"105\" text-anchor=\"middle\">list found</text>\n  <text class=\"d-label-muted\" x=\"325\" y=\"160\" text-anchor=\"middle\">test continues immediately</text>\n</svg><figcaption>findBy and waitFor poll until the condition holds</figcaption></figure>\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\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Side effects inside waitFor</span></p>\n<p>Because <code dir=\"ltr\">waitFor</code> reruns its callback, anything inside it may happen several times. A click inside <code dir=\"ltr\">waitFor</code> can fire three times and turn one retry into three. Keep the callback to queries and assertions; do clicks and typing before it, once. And don’t wrap a <code dir=\"ltr\">findBy</code> in <code dir=\"ltr\">waitFor</code>: it already waits.</p>\n</aside>\n<h2 id=\"sec-4\" tabindex=\"-1\">Controlling time from the test side</h2>\n<p><code dir=\"ltr\">findBy</code> handles <em>waiting</em>. To test each state deterministically, you also control the <strong>dependency</strong>. Since <code dir=\"ltr\">loadBalances</code> is passed in, a <code dir=\"ltr\">vi.fn</code> can make it do anything:</p>\n<ul>\n<li><code dir=\"ltr\">mockResolvedValue(data)</code> for success;</li>\n<li><code dir=\"ltr\">mockRejectedValue(new Error('Offline'))</code> for failure;</li>\n<li>a promise you resolve by hand, to freeze the loading state:</li>\n</ul>\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=\"Copy code\"><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>Copy</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\">'keeps showing the loading message until data arrives'</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\">  let</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> finish;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> loadBalances</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> vi.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">fn</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> new</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> Promise</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">((</span><span style=\"color:#953800;--shiki-dark:#FFA657\">resolve</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { finish </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> resolve; }));</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  mountBalancePanel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, loadBalances);</span></span>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'status'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveTextContent</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Loading balances'</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\">  finish</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ Ana: </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:#8250DF;--shiki-dark:#D2A8FF\">  expect</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> screen.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">findByText</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Ana is settled up'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBeInTheDocument</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>For the retry flow, start with a rejecting mock, wait for the alert, switch the mock to resolve, click “Try again” once, then <code dir=\"ltr\">findBy</code> the list. Each step is something a user does or sees, and nothing depends on how fast the machine is.</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\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Fake timers for timed UI</span></p>\n<p>Real time still matters for UI that waits on purpose, like a toast that hides after five seconds. Combine fake timers with user-event by passing <code dir=\"ltr\">advanceTimers: vi.advanceTimersByTime</code> to <code dir=\"ltr\">userEvent.setup()</code>, so typing delays and your timeouts all run on the fake clock.</p>\n</aside>\n<h2 id=\"sec-5\" tabindex=\"-1\">When the default timeout isn’t enough</h2>\n<p><code dir=\"ltr\">findBy</code> and <code dir=\"ltr\">waitFor</code> give up after one second. That is plenty for code whose dependency you control, and if a component test needs longer, the usual reason is a real network call or a real timer that leaked in. Fix the leak first. When the wait really is legitimate, pass options: <code dir=\"ltr\">screen.findByRole('list', {}, { timeout: 3000 })</code> (the third argument is the wait options) or <code dir=\"ltr\">waitFor(callback, { timeout: 3000 })</code>.</p>\n<p>In React tests you’ll sometimes see a warning that an update “was not wrapped in act(…)”. It usually means a state update happened <strong>after</strong> the test stopped looking: some promise resolved late. The fix is almost never to wrap things in <code dir=\"ltr\">act</code> by hand; it’s to <code dir=\"ltr\">await</code> the final state the user would see, with a <code dir=\"ltr\">findBy</code> query.</p>\n<h2 id=\"sec-6\" tabindex=\"-1\">In the playground</h2>\n<p>The exercise starter adds <code dir=\"ltr\">waitFor(callback)</code>, <code dir=\"ltr\">findByRole(root, role, options)</code> and <code dir=\"ltr\">findByText(root, text)</code> to the query stand-ins. They poll every 20 ms and give up after 500 ms, so a test against a broken version fails quickly.</p>\n<p>Next lesson brings this to React components, and to the network calls behind <code dir=\"ltr\">loadBalances</code>.</p>\n","toc":[{"id":"sec-1","text":"The wrong way: sleeping","level":2},{"id":"sec-2","text":"findBy: wait for an element","level":2},{"id":"sec-3","text":"waitFor: wait for anything else","level":2},{"id":"sec-4","text":"Controlling time from the test side","level":2},{"id":"sec-5","text":"When the default timeout isn’t enough","level":2},{"id":"sec-6","text":"In the playground","level":2}],"takeaways":["<code dir=\"ltr\">findBy</code> queries return a promise that resolves when the element appears, retrying until a timeout (one second by default).","<code dir=\"ltr\">waitFor(callback)</code> reruns a callback until it stops throwing; use it for assertions that aren’t about an element appearing, such as a mock’s call count.","Never wait a fixed time with a sleep; wait for the condition you actually care about.","Control the async dependency yourself (a <code dir=\"ltr\">vi.fn</code> that resolves, rejects or stays pending) so loading, success, error and retry are each one deterministic test."],"further":[{"title":"Testing Library — Async methods","url":"https://testing-library.com/docs/dom-testing-library/api-async"},{"title":"Testing Library — Appearance and disappearance","url":"https://testing-library.com/docs/guide-disappearance"},{"title":"Vitest — vi.waitFor","url":"https://vitest.dev/api/vi#vi-waitfor"}],"quiz":[{"q":"<p>The balance list appears after <code dir=\"ltr\">loadBalances()</code> resolves. Which line waits for it correctly?</p>","options":[{"text":"<code dir=\"ltr\">await new Promise((r) =&gt; setTimeout(r, 500)); screen.getByRole('list');</code>","why":"A fixed sleep is too long on a fast machine and too short on a busy CI runner; it is the classic source of flaky UI tests."},{"text":"<code dir=\"ltr\">const list = await screen.findByRole('list', { name: 'Balances' });</code>","why":"Correct. <code dir=\"ltr\">findByRole</code> retries until the list appears (or one second passes), so it waits exactly as long as needed."},{"text":"<code dir=\"ltr\">const list = screen.getByRole('list', { name: 'Balances' });</code>","why":"<code dir=\"ltr\">getByRole</code> checks once, immediately, before the promise has resolved, so it throws."}],"answer":1},{"q":"<p>What’s the problem with this?</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=\"Copy code\"><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>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">await</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> waitFor</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\">  user.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">click</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\">'button'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Try again'</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\">(loadBalances).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveBeenCalledTimes</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","options":[{"text":"<code dir=\"ltr\">waitFor</code> can’t contain <code dir=\"ltr\">expect</code> calls.","why":"Assertions are exactly what belongs in <code dir=\"ltr\">waitFor</code>; it reruns the callback until they stop throwing."},{"text":"<code dir=\"ltr\">toHaveBeenCalledTimes</code> doesn’t work with async functions.","why":"It counts calls on any mock, sync or async; that line is fine on its own."},{"text":"The click is a side effect inside the retried callback, so it may run several times and inflate the call count.","why":"Correct. Do the click once, outside, then <code dir=\"ltr\">await waitFor(() =&gt; expect(loadBalances).toHaveBeenCalledTimes(2))</code>."}],"answer":2},{"q":"<p>How do you test that the “Loading balances…” message is shown <em>before</em> the data arrives, without racing the promise?</p>","options":[{"text":"Make <code dir=\"ltr\">loadBalances</code> return a promise the test resolves by hand, assert the loading message, then resolve it.","why":"Correct. A deferred promise freezes the component in the loading state for as long as you need, so the test is deterministic."},{"text":"Add a 50 ms delay to the real <code dir=\"ltr\">loadBalances</code> in tests.","why":"Any timing-based approach races: on a slow runner the assertion may still miss or hit at the wrong moment."},{"text":"Use <code dir=\"ltr\">findByRole('status')</code> so it waits for the message.","why":"The message is shown immediately, so finding it isn’t the hard part; the risk is that the data has already replaced it by the time you look."}],"answer":0}],"exercise":{"kind":"web","mode":"test","subject":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n\n// Shows balances loaded with loadBalances() (returns a promise).\n// - While loading: <p role=\"status\">Loading balances…</p>\n// - On success: the \"Balances\" list from renderBalances; the status is gone.\n// - On failure: <p role=\"alert\">Could not load balances.</p> and a\n//   \"Try again\" button that calls loadBalances again.\nfunction mountBalancePanel(container, loadBalances) {\n  async function load() {\n    container.innerHTML = '<p role=\"status\">Loading balances…</p>';\n    try {\n      const balances = await loadBalances();\n      renderBalances(container, balances);\n    } catch {\n      container.innerHTML =\n        '<p role=\"alert\">Could not load balances.</p><button type=\"button\">Try again</button>';\n      container.querySelector('button').addEventListener('click', load);\n    }\n  }\n  load();\n}\n","mutants":[{"id":"no-loading","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n\n// Shows balances loaded with loadBalances() (returns a promise).\n// - While loading: <p role=\"status\">Loading balances…</p>\n// - On success: the \"Balances\" list from renderBalances; the status is gone.\n// - On failure: <p role=\"alert\">Could not load balances.</p> and a\n//   \"Try again\" button that calls loadBalances again.\nfunction mountBalancePanel(container, loadBalances) {\n  async function load() {\n    container.innerHTML = '';\n    try {\n      const balances = await loadBalances();\n      renderBalances(container, balances);\n    } catch {\n      container.innerHTML =\n        '<p role=\"alert\">Could not load balances.</p><button type=\"button\">Try again</button>';\n      container.querySelector('button').addEventListener('click', load);\n    }\n  }\n  load();\n}\n"},{"id":"stale-loading","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n\n// Shows balances loaded with loadBalances() (returns a promise).\n// - While loading: <p role=\"status\">Loading balances…</p>\n// - On success: the \"Balances\" list from renderBalances; the status is gone.\n// - On failure: <p role=\"alert\">Could not load balances.</p> and a\n//   \"Try again\" button that calls loadBalances again.\nfunction mountBalancePanel(container, loadBalances) {\n  async function load() {\n    container.innerHTML = '<p role=\"status\">Loading balances…</p>';\n    try {\n      const balances = await loadBalances();\n      const box = document.createElement('div');\n      container.append(box);\n      renderBalances(box, balances);\n    } catch {\n      container.innerHTML =\n        '<p role=\"alert\">Could not load balances.</p><button type=\"button\">Try again</button>';\n      container.querySelector('button').addEventListener('click', load);\n    }\n  }\n  load();\n}\n"},{"id":"error-hidden","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n\n// Shows balances loaded with loadBalances() (returns a promise).\n// - While loading: <p role=\"status\">Loading balances…</p>\n// - On success: the \"Balances\" list from renderBalances; the status is gone.\n// - On failure: <p role=\"alert\">Could not load balances.</p> and a\n//   \"Try again\" button that calls loadBalances again.\nfunction mountBalancePanel(container, loadBalances) {\n  async function load() {\n    container.innerHTML = '<p role=\"status\">Loading balances…</p>';\n    try {\n      const balances = await loadBalances();\n      renderBalances(container, balances);\n    } catch {\n      container.innerHTML = '';\n    }\n  }\n  load();\n}\n"},{"id":"no-retry","code":"function formatMoney(cents) {\n  const sign = cents < 0 ? '-' : '';\n  const abs = Math.abs(cents);\n  return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;\n}\n\nfunction renderBalances(container, balances) {\n  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));\n  if (names.length === 0) {\n    const empty = document.createElement('p');\n    empty.textContent = 'No expenses yet';\n    container.replaceChildren(empty);\n    return;\n  }\n  const list = document.createElement('ul');\n  list.setAttribute('aria-label', 'Balances');\n  for (const name of names) {\n    const cents = balances[name];\n    const item = document.createElement('li');\n    item.textContent =\n      cents > 0 ? `${name} is owed ${formatMoney(cents)}`\n      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`\n      : `${name} is settled up`;\n    list.append(item);\n  }\n  container.replaceChildren(list);\n}\n\n// Shows balances loaded with loadBalances() (returns a promise).\n// - While loading: <p role=\"status\">Loading balances…</p>\n// - On success: the \"Balances\" list from renderBalances; the status is gone.\n// - On failure: <p role=\"alert\">Could not load balances.</p> and a\n//   \"Try again\" button that calls loadBalances again.\nfunction mountBalancePanel(container, loadBalances) {\n  async function load() {\n    container.innerHTML = '<p role=\"status\">Loading balances…</p>';\n    try {\n      const balances = await loadBalances();\n      renderBalances(container, balances);\n    } catch {\n      container.innerHTML =\n        '<p role=\"alert\">Could not load balances.</p><button type=\"button\">Try again</button>';\n    }\n  }\n  load();\n}\n"}],"starter":{"js":"// ── Tiny stand-ins for Testing Library queries (no npm in the playground) ──\nconst ROLE_SELECTORS = {\n  alert: '[role=\"alert\"]',\n  button: 'button, [role=\"button\"]',\n  list: 'ul, ol, [role=\"list\"]',\n  listitem: 'li, [role=\"listitem\"]',\n  status: '[role=\"status\"]',\n  textbox: 'input:not([type]), input[type=\"text\"], textarea, [role=\"textbox\"]',\n};\nfunction accessibleName(el) {\n  if (el.getAttribute('aria-label')) return el.getAttribute('aria-label');\n  if (el.labels && el.labels.length) return el.labels[0].textContent.trim();\n  return el.textContent.trim();\n}\nfunction queryAllByRole(root, role, { name } = {}) {\n  return [...root.querySelectorAll(ROLE_SELECTORS[role])]\n    .filter((el) => !el.closest('[hidden]'))\n    .filter((el) => name === undefined || accessibleName(el) === name);\n}\nfunction queryByRole(root, role, options) {\n  return queryAllByRole(root, role, options)[0] ?? null;\n}\nfunction getAllByRole(root, role, options = {}) {\n  const found = queryAllByRole(root, role, options);\n  if (!found.length) throw new Error(`Unable to find role \"${role}\"${options.name ? ` named \"${options.name}\"` : ''}`);\n  return found;\n}\nfunction getByRole(root, role, options = {}) {\n  const found = getAllByRole(root, role, options);\n  if (found.length > 1) throw new Error(`Found ${found.length} elements with role \"${role}\"`);\n  return found[0];\n}\nfunction queryByText(root, text) {\n  return [...root.querySelectorAll('*')].find((el) =>\n    !el.closest('[hidden]') &&\n    el.textContent.trim() === text &&\n    ![...el.children].some((child) => child.textContent.trim() === text)) ?? null;\n}\nfunction getByText(root, text) {\n  const el = queryByText(root, text);\n  if (!el) throw new Error(`Unable to find text \"${text}\"`);\n  return el;\n}\n// ────────────────────────────────────────────────────────────────────────────\n\n// ── Stand-ins for waitFor and findBy (poll every 20 ms, give up after 500 ms) ──\nasync function waitFor(callback, { timeout = 500, interval = 20 } = {}) {\n  const start = Date.now();\n  for (;;) {\n    try {\n      return await callback();\n    } catch (error) {\n      if (Date.now() - start > timeout) throw error;\n      await new Promise((resolve) => setTimeout(resolve, interval));\n    }\n  }\n}\nconst findByRole = (root, role, options) => waitFor(() => getByRole(root, role, options));\nconst findByText = (root, text) => waitFor(() => getByText(root, text));\n// ────────────────────────────────────────────────────────────────────────────\n\n// ── Stand-ins for user-event (synchronous here, so no await) ──\nfunction typeInto(el, text) {\n  clickOn(el);\n  for (const key of text) {\n    el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));\n    el.value += key;\n    el.dispatchEvent(new InputEvent('input', { data: key, inputType: 'insertText', bubbles: true }));\n    el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));\n  }\n}\nfunction clickOn(el) {\n  if (el.disabled) return;\n  el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));\n  el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));\n  el.focus();\n  el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));\n  el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));\n  el.click();\n}\n// ────────────────────────────────────────────────────────────────────────────\n\nlet container;\n\nbeforeEach(() => {\n  container = document.createElement('div');\n  document.body.append(container);\n});\n\nafterEach(() => {\n  container.remove();\n});\n\ndescribe('balance panel', () => {\n  test('eventually shows the balances', async () => {\n    const loadBalances = vi.fn().mockResolvedValue({ Ana: 500, Ben: -500 });\n\n    mountBalancePanel(container, loadBalances);\n\n    expect(await findByRole(container, 'list', { name: 'Balances' })).toBeTruthy();\n  });\n\n  // Add: the loading status (and that it goes away),\n  // the error alert, and the \"Try again\" button.\n});\n"},"solution":{"js":"// ── Tiny stand-ins for Testing Library queries (no npm in the playground) ──\nconst ROLE_SELECTORS = {\n  alert: '[role=\"alert\"]',\n  button: 'button, [role=\"button\"]',\n  list: 'ul, ol, [role=\"list\"]',\n  listitem: 'li, [role=\"listitem\"]',\n  status: '[role=\"status\"]',\n  textbox: 'input:not([type]), input[type=\"text\"], textarea, [role=\"textbox\"]',\n};\nfunction accessibleName(el) {\n  if (el.getAttribute('aria-label')) return el.getAttribute('aria-label');\n  if (el.labels && el.labels.length) return el.labels[0].textContent.trim();\n  return el.textContent.trim();\n}\nfunction queryAllByRole(root, role, { name } = {}) {\n  return [...root.querySelectorAll(ROLE_SELECTORS[role])]\n    .filter((el) => !el.closest('[hidden]'))\n    .filter((el) => name === undefined || accessibleName(el) === name);\n}\nfunction queryByRole(root, role, options) {\n  return queryAllByRole(root, role, options)[0] ?? null;\n}\nfunction getAllByRole(root, role, options = {}) {\n  const found = queryAllByRole(root, role, options);\n  if (!found.length) throw new Error(`Unable to find role \"${role}\"${options.name ? ` named \"${options.name}\"` : ''}`);\n  return found;\n}\nfunction getByRole(root, role, options = {}) {\n  const found = getAllByRole(root, role, options);\n  if (found.length > 1) throw new Error(`Found ${found.length} elements with role \"${role}\"`);\n  return found[0];\n}\nfunction queryByText(root, text) {\n  return [...root.querySelectorAll('*')].find((el) =>\n    !el.closest('[hidden]') &&\n    el.textContent.trim() === text &&\n    ![...el.children].some((child) => child.textContent.trim() === text)) ?? null;\n}\nfunction getByText(root, text) {\n  const el = queryByText(root, text);\n  if (!el) throw new Error(`Unable to find text \"${text}\"`);\n  return el;\n}\n// ────────────────────────────────────────────────────────────────────────────\n\n// ── Stand-ins for waitFor and findBy (poll every 20 ms, give up after 500 ms) ──\nasync function waitFor(callback, { timeout = 500, interval = 20 } = {}) {\n  const start = Date.now();\n  for (;;) {\n    try {\n      return await callback();\n    } catch (error) {\n      if (Date.now() - start > timeout) throw error;\n      await new Promise((resolve) => setTimeout(resolve, interval));\n    }\n  }\n}\nconst findByRole = (root, role, options) => waitFor(() => getByRole(root, role, options));\nconst findByText = (root, text) => waitFor(() => getByText(root, text));\n// ────────────────────────────────────────────────────────────────────────────\n\n// ── Stand-ins for user-event (synchronous here, so no await) ──\nfunction typeInto(el, text) {\n  clickOn(el);\n  for (const key of text) {\n    el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));\n    el.value += key;\n    el.dispatchEvent(new InputEvent('input', { data: key, inputType: 'insertText', bubbles: true }));\n    el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));\n  }\n}\nfunction clickOn(el) {\n  if (el.disabled) return;\n  el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));\n  el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));\n  el.focus();\n  el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));\n  el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));\n  el.click();\n}\n// ────────────────────────────────────────────────────────────────────────────\n\nlet container;\n\nbeforeEach(() => {\n  container = document.createElement('div');\n  document.body.append(container);\n});\n\nafterEach(() => {\n  container.remove();\n});\n\ndescribe('balance panel', () => {\n  test('shows a loading status, then the balances', async () => {\n    const loadBalances = vi.fn().mockResolvedValue({ Ana: 500, Ben: -500 });\n\n    mountBalancePanel(container, loadBalances);\n\n    expect(getByRole(container, 'status').textContent).toMatch('Loading balances');\n    expect(await findByRole(container, 'list', { name: 'Balances' })).toBeTruthy();\n    expect(queryByRole(container, 'status')).toBeNull();\n  });\n\n  test('explains a failure and retries on request', async () => {\n    const loadBalances = vi.fn().mockRejectedValue(new Error('Offline'));\n\n    mountBalancePanel(container, loadBalances);\n    expect((await findByRole(container, 'alert')).textContent).toBe('Could not load balances.');\n\n    loadBalances.mockResolvedValue({ Ana: 0 });\n    clickOn(getByRole(container, 'button', { name: 'Try again' }));\n\n    expect(await findByText(container, 'Ana is settled up')).toBeTruthy();\n    expect(loadBalances).toHaveBeenCalledTimes(2);\n  });\n});\n"},"checks":[{"id":"passes","suite":"subject"},{"id":"catches-no-loading","suite":"no-loading"},{"id":"catches-stale-loading","suite":"stale-loading"},{"id":"catches-error-hidden","suite":"error-hidden"},{"id":"catches-no-retry","suite":"no-retry"}],"title":"Loading, error and retry","prompt":"<p><code dir=\"ltr\">mountBalancePanel(container, loadBalances)</code> shows <code dir=\"ltr\">role=&quot;status&quot;</code> “Loading balances…” while <code dir=\"ltr\">loadBalances()</code> is pending, then the <strong>Balances</strong> list. If the promise rejects, it shows <code dir=\"ltr\">role=&quot;alert&quot;</code> “Could not load balances.” and a <strong>Try again</strong> button that loads again.</p>\n<p>The starter adds <code dir=\"ltr\">waitFor</code>, <code dir=\"ltr\">findByRole</code> and <code dir=\"ltr\">findByText</code> to the stand-ins (they poll for up to 500 ms). Control <code dir=\"ltr\">loadBalances</code> with <code dir=\"ltr\">vi.fn()</code> and add tests until your suite catches four hidden bugs: one <strong>never shows</strong> the loading status, one <strong>leaves</strong> it on screen after the data arrives, one <strong>shows nothing</strong> on failure, and one whose <strong>Try again</strong> button does nothing.</p>\n","hints":["The loading status is set synchronously when the panel mounts, so <code dir=\"ltr\">getByRole(container, 'status')</code> right after <code dir=\"ltr\">mountBalancePanel(...)</code> finds it. After <code dir=\"ltr\">await findByRole(... 'list' ...)</code>, <code dir=\"ltr\">queryByRole(container, 'status')</code> must be <code dir=\"ltr\">null</code>.","For the failure: <code dir=\"ltr\">vi.fn().mockRejectedValue(new Error('Offline'))</code>, then <code dir=\"ltr\">await findByRole(container, 'alert')</code>.","For the retry: after the alert appears, call <code dir=\"ltr\">loadBalances.mockResolvedValue({ Ana: 0 })</code>, click Try again with <code dir=\"ltr\">clickOn</code>, then <code dir=\"ltr\">await findByText(container, 'Ana is settled up')</code>."],"explanation":"<p>Each state is one assertion about what the user sees. Checking the status immediately after mounting catches the panel with no loading message; checking that it’s gone after <code dir=\"ltr\">findByRole</code> resolves catches the one that leaves it behind. The failure test awaits the alert with <code dir=\"ltr\">findByRole</code>, which times out if the error is swallowed. Switching the mock to resolve before clicking Try again turns the retry into a deterministic story: if the button isn’t wired up, the settled-up text never appears and the test fails. No sleeps anywhere, so the suite is as fast as the code.</p>\n","checkLabels":{"passes":"All your tests pass against the real panel","catches-no-loading":"Catches the version with no loading status","catches-stale-loading":"Catches the version that leaves the loading status on screen","catches-error-hidden":"Catches the version that shows nothing on failure","catches-no-retry":"Catches the version whose Try again button does nothing"}},"words":676,"readMinutes":3}