{"id":"l-tj-3-2","courseId":"testing-javascript","lang":"en","kind":"lesson","summary":"Drive forms and buttons in tests the way a person does, choose user-event over fireEvent, and assert on what the user sees and what the code reports.","html":"<p>The expense form is where Splitwise-lite meets real people, and real people do messy things: they tab between fields, paste &quot; 45.50 &quot;, click “Add” with the amount still empty, double-click. A unit test of <code dir=\"ltr\">parseAmount</code> can’t tell you whether the form wires all of that up correctly. A test that uses the form like a person can.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">fireEvent versus user-event</h2>\n<p>Testing Library ships two ways to interact.</p>\n<p><strong><code dir=\"ltr\">fireEvent</code></strong> (from <code dir=\"ltr\">@testing-library/dom</code>) dispatches exactly one DOM event. <code dir=\"ltr\">fireEvent.click(button)</code> sends a <code dir=\"ltr\">click</code>. <code dir=\"ltr\">fireEvent.change(input, { target: { value: '45.50' } })</code> sets the value and sends a <code dir=\"ltr\">change</code>. It’s fast and precise, and it’s not what happens when a person clicks or types.</p>\n<p><strong><code dir=\"ltr\">user-event</code></strong> (from <code dir=\"ltr\">@testing-library/user-event</code>) simulates the interaction. <code dir=\"ltr\">user.click(button)</code> produces pointer and mouse events, moves focus, then fires <code dir=\"ltr\">click</code>, and refuses to click a disabled button or one hidden under <code dir=\"ltr\">pointer-events: none</code>, like a real browser. <code dir=\"ltr\">user.type(input, '45.50')</code> clicks the field, then for each character sends <code dir=\"ltr\">keydown</code>, <code dir=\"ltr\">keypress</code>, <code dir=\"ltr\">input</code> and <code dir=\"ltr\">keyup</code>.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 210\" role=\"img\" aria-labelledby=\"l-tj-3-2-t1\"><defs><marker id=\"arrow-l-tj-3-2-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-2-t1\">fireEvent.click sends only a click event. user.click sends pointerdown, mousedown, focus, pointerup, mouseup and then click.</title>\n  <text class=\"d-code\" x=\"20\" y=\"48\">fireEvent.click</text>\n  <rect class=\"d-box-warn\" x=\"200\" y=\"26\" width=\"90\" height=\"34\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"245\" y=\"48\" text-anchor=\"middle\">click</text>\n  <text class=\"d-code\" x=\"20\" y=\"138\">user.click</text>\n  <rect class=\"d-box-accent\" x=\"140\" y=\"116\" width=\"100\" height=\"34\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"190\" y=\"138\" text-anchor=\"middle\">pointerdown</text>\n  <rect class=\"d-box-accent\" x=\"250\" y=\"116\" width=\"90\" height=\"34\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"295\" y=\"138\" text-anchor=\"middle\">mousedown</text>\n  <rect class=\"d-box-primary\" x=\"350\" y=\"116\" width=\"60\" height=\"34\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"380\" y=\"138\" text-anchor=\"middle\">focus</text>\n  <rect class=\"d-box-accent\" x=\"420\" y=\"116\" width=\"80\" height=\"34\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"460\" y=\"138\" text-anchor=\"middle\">pointerup</text>\n  <rect class=\"d-box-accent\" x=\"510\" y=\"116\" width=\"74\" height=\"34\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"547\" y=\"138\" text-anchor=\"middle\">mouseup</text>\n  <rect class=\"d-box-success\" x=\"594\" y=\"116\" width=\"70\" height=\"34\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"629\" y=\"138\" text-anchor=\"middle\">click</text>\n  <text class=\"d-label-muted\" x=\"340\" y=\"190\" text-anchor=\"middle\">handlers on any of these events run, as in a browser</text>\n</svg><figcaption>One event versus the sequence a person causes</figcaption></figure>\n<p>Default to user-event. Reach for <code dir=\"ltr\">fireEvent</code> only for events user-event doesn’t model, such as a custom event or <code dir=\"ltr\">scroll</code>.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">Testing the expense form</h2>\n<p>The form has two labelled fields, an “Add expense” button and an alert area. Here is the happy path, written the way you’d describe it to a colleague:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">src/expense-form.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:#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\"> { mountExpenseForm } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> './expense-form.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\">'adds a valid expense in cents and clears the form'</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:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> onAdd</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>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  mountExpenseForm</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, onAdd);</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\">'Description'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }), </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Dinner'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#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\">'Amount'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }), </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'45.50'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#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\">'Add expense'</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\">(onAdd).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveBeenCalledWith</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:#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\">'textbox'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { name: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Amount'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }).value).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBe</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">''</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>Three things to notice. <code dir=\"ltr\">userEvent.setup()</code> comes first and gives you a <code dir=\"ltr\">user</code> session that tracks keyboard and pointer state across calls. <strong>Every interaction is awaited</strong>: user-event methods are asynchronous, and without <code dir=\"ltr\">await</code> the assertions run before the typing has finished. And the assertions are about <strong>outcomes</strong>: what <code dir=\"ltr\">onAdd</code> received (the contract with the rest of the app) and what the user now sees.</p>\n<p>Keyboard users matter too. <code dir=\"ltr\">await user.tab()</code> moves focus like the Tab key, and <code dir=\"ltr\">await user.keyboard('{Enter}')</code> presses Enter, so you can test that the form works without a mouse.</p>\n<h3 id=\"sec-3\" tabindex=\"-1\">What user-event won’t do for you</h3>\n<p>user-event simulates the browser’s default behaviour for the interaction, but it can’t see your CSS layout. It won’t notice that a modal covers the button visually, or that the “Add expense” button is scrolled off-screen on a phone. jsdom has no layout engine, so sizes and positions are all zero. Those are jobs for end-to-end tests in a real browser, which is where section 4 goes. In unit and component tests, user-event gives you the right <em>event sequence</em> and <em>focus behaviour</em>, which covers the large majority of form bugs.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">The unhappy path is most of the work</h2>\n<p>Most form bugs live in the error handling, so give it at least as many tests. Typing <code dir=\"ltr\">abc</code> as the amount and clicking “Add expense” should:</p>\n<ol>\n<li>show the reason in an element with <code dir=\"ltr\">role=&quot;alert&quot;</code>, which screen readers announce;</li>\n<li><strong>not</strong> call <code dir=\"ltr\">onAdd</code>;</li>\n<li><strong>keep</strong> what the user typed, so they can fix one character instead of retyping.</li>\n</ol>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">JavaScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"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\">'explains an invalid amount and keeps the input'</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:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> onAdd</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>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">  mountExpenseForm</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, onAdd);</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\">'Description'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }), </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Taxi'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#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\">'Amount'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }), </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'abc'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  await</span><span style=\"color:#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\">'Add expense'</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\">getByRole</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'alert'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).textContent).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toMatch</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'Not a valid amount'</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\">(onAdd).not.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveBeenCalled</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\">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\">'Amount'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }).value).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toBe</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'abc'</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>Each assertion guards a different bug. Drop the alert check and a silent failure passes. Drop the <code dir=\"ltr\">not.toHaveBeenCalled</code> and a form that saves garbage passes. Drop the last one and a form that wipes the user’s input passes.</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\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Asserting on component internals after an interaction</span></p>\n<p>After clicking, it is tempting to check a private variable, a CSS class like <code dir=\"ltr\">.has-error</code>, or how many times an internal <code dir=\"ltr\">validate</code> function ran. None of those are things a user perceives, and they all change during refactors. Assert the visible message, the field values and the callback; that’s the contract.</p>\n</aside>\n<h2 id=\"sec-5\" tabindex=\"-1\">In the playground</h2>\n<p>There is no user-event package in the sandbox, so the exercise starter includes two small helpers: <code dir=\"ltr\">typeInto(element, text)</code>, which focuses the field and sends key and input events for each character, and <code dir=\"ltr\">clickOn(element)</code>, which sends the pointer and mouse sequence, focuses and clicks (and does nothing on a disabled button). They are synchronous, so you don’t <code dir=\"ltr\">await</code> them. The sandbox also blocks real form submission, so the widget listens for the button’s click instead of a <code dir=\"ltr\">submit</code> event; in your app, a real <code dir=\"ltr\">&lt;form&gt;</code> with a submit handler is better, because it gives you Enter-to-submit for free.</p>\n<p>Next, the hardest part of UI testing: things that happen later.</p>\n","toc":[{"id":"sec-1","text":"fireEvent versus user-event","level":2},{"id":"sec-2","text":"Testing the expense form","level":2},{"id":"sec-3","text":"What user-event won’t do for you","level":3},{"id":"sec-4","text":"The unhappy path is most of the work","level":2},{"id":"sec-5","text":"In the playground","level":2}],"takeaways":["<code dir=\"ltr\">fireEvent</code> dispatches one DOM event; <code dir=\"ltr\">user-event</code> plays the whole sequence a real person causes (pointer, focus, keydown, input, keyup, click).","Create a session with <code dir=\"ltr\">userEvent.setup()</code> and <code dir=\"ltr\">await</code> every interaction, because user-event methods are asynchronous.","Test a form through its outcomes: the callback’s arguments, the error a user would read, and the state of the fields afterwards.","Test the invalid path as carefully as the valid one; “nothing happened” is a behaviour to assert."],"further":[{"title":"Testing Library — user-event introduction","url":"https://testing-library.com/docs/user-event/intro"},{"title":"Testing Library — Firing events","url":"https://testing-library.com/docs/dom-testing-library/api-events"},{"title":"Testing Library — user-event setup","url":"https://testing-library.com/docs/user-event/setup"}],"quiz":[{"q":"<p>The amount field formats input on each <code dir=\"ltr\">keydown</code>. A test uses <code dir=\"ltr\">fireEvent.change(input, { target: { value: '45.50' } })</code>, and the formatting code never runs. Why?</p>","options":[{"text":"<code dir=\"ltr\">fireEvent.change</code> sets the value and dispatches a single <code dir=\"ltr\">change</code> event, with no key events.","why":"Correct. Nothing pressed a key, so <code dir=\"ltr\">keydown</code> handlers never fire. <code dir=\"ltr\">await user.type(input, '45.50')</code> produces key events for every character."},{"text":"<code dir=\"ltr\">fireEvent</code> only works with React components.","why":"<code dir=\"ltr\">fireEvent</code> comes from <code dir=\"ltr\">@testing-library/dom</code> and works with any DOM; the issue is which events it sends."},{"text":"Change events don’t bubble in jsdom.","why":"Bubbling isn’t the issue; the <code dir=\"ltr\">keydown</code> handler isn’t waiting for a change event at all."}],"answer":0},{"q":"<p>What’s wrong with this test?</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\">test</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'adds the expense'</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\">  mountExpenseForm</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(document.body, onAdd);</span></span>\n<span class=\"line\"><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\">'Amount'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }), </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'12'</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\">'Add expense'</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\">(onAdd).</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toHaveBeenCalled</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\">userEvent.setup()</code> must be called inside <code dir=\"ltr\">beforeEach</code>.","why":"Calling <code dir=\"ltr\">setup()</code> at the start of each test is the recommended pattern; there’s no need for a hook."},{"text":"<code dir=\"ltr\">getByRole</code> can’t find inputs; it should use <code dir=\"ltr\">getByTestId</code>.","why":"Inputs have the role <code dir=\"ltr\">textbox</code>, and with a label they have an accessible name, so <code dir=\"ltr\">getByRole</code> is the right query."},{"text":"The interactions aren’t awaited, so the assertion runs before typing and clicking have finished.","why":"Correct. user-event methods return promises; make the test <code dir=\"ltr\">async</code> and <code dir=\"ltr\">await</code> each call."}],"answer":2},{"q":"<p>Typing <code dir=\"ltr\">'abc'</code> in the amount field and clicking “Add expense” should show an error. Which set of assertions tests that path best?</p>","options":[{"text":"The alert is visible.","why":"It’s a start, but a version that shows the alert <em>and</em> still calls <code dir=\"ltr\">onAdd</code> with garbage would pass."},{"text":"The alert shows the message, <code dir=\"ltr\">onAdd</code> was not called, and the typed text is still in the field.","why":"Correct. Each assertion guards a different bug: a silent failure, a bad expense saved, and wiping the user’s input."},{"text":"<code dir=\"ltr\">onAdd</code> was not called.","why":"A version that silently ignores the click passes this too; the user would be left guessing."}],"answer":1}],"exercise":{"kind":"web","mode":"test","subject":"function parseAmount(input) {\n  const text = String(input).trim();\n  if (!/^\\d+(\\.\\d{1,2})?$/.test(text)) throw new Error(`Not a valid amount: \"${input}\"`);\n  const [whole, fraction = ''] = text.split('.');\n  const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));\n  if (cents === 0) throw new Error('Amount must be greater than zero');\n  return cents;\n}\n\n// Mounts the \"Add expense\" form into container.\n// - Text fields labelled \"Description\" and \"Amount\", and an\n//   \"Add expense\" button.\n// - On click, if valid: calls onAdd({ description, amountCents }) with the\n//   trimmed description and the amount in integer cents, hides any error,\n//   then clears both fields.\n// - If invalid: shows the reason in the element with role=\"alert\",\n//   does NOT call onAdd, and keeps what the user typed.\n//   Blank description -> \"Add a description\"; bad amount -> parseAmount's message.\n// (The playground blocks real form submission, so this reacts to the click.)\nfunction mountExpenseForm(container, onAdd) {\n  container.innerHTML = `\n    <div>\n      <label>Description <input name=\"description\"></label>\n      <label>Amount <input name=\"amount\" inputmode=\"decimal\"></label>\n      <button type=\"button\">Add expense</button>\n      <p role=\"alert\" hidden></p>\n    </div>`;\n  const [description, amount] = container.querySelectorAll('input');\n  const alert = container.querySelector('[role=\"alert\"]');\n  container.querySelector('button').addEventListener('click', () => {\n    let amountCents;\n    try {\n      if (!description.value.trim()) throw new Error('Add a description');\n      amountCents = parseAmount(amount.value);\n    } catch (error) {\n      alert.textContent = error.message;\n      alert.hidden = false;\n      return;\n    }\n    alert.hidden = true;\n    alert.textContent = '';\n    onAdd({ description: description.value.trim(), amountCents });\n    description.value = '';\n    amount.value = '';\n  });\n}\n","mutants":[{"id":"adds-invalid","code":"function parseAmount(input) {\n  const text = String(input).trim();\n  if (!/^\\d+(\\.\\d{1,2})?$/.test(text)) throw new Error(`Not a valid amount: \"${input}\"`);\n  const [whole, fraction = ''] = text.split('.');\n  const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));\n  if (cents === 0) throw new Error('Amount must be greater than zero');\n  return cents;\n}\n\n// Mounts the \"Add expense\" form into container.\n// - Text fields labelled \"Description\" and \"Amount\", and an\n//   \"Add expense\" button.\n// - On click, if valid: calls onAdd({ description, amountCents }) with the\n//   trimmed description and the amount in integer cents, hides any error,\n//   then clears both fields.\n// - If invalid: shows the reason in the element with role=\"alert\",\n//   does NOT call onAdd, and keeps what the user typed.\n//   Blank description -> \"Add a description\"; bad amount -> parseAmount's message.\n// (The playground blocks real form submission, so this reacts to the click.)\nfunction mountExpenseForm(container, onAdd) {\n  container.innerHTML = `\n    <div>\n      <label>Description <input name=\"description\"></label>\n      <label>Amount <input name=\"amount\" inputmode=\"decimal\"></label>\n      <button type=\"button\">Add expense</button>\n      <p role=\"alert\" hidden></p>\n    </div>`;\n  const [description, amount] = container.querySelectorAll('input');\n  const alert = container.querySelector('[role=\"alert\"]');\n  container.querySelector('button').addEventListener('click', () => {\n    let amountCents;\n    try {\n      if (!description.value.trim()) throw new Error('Add a description');\n      amountCents = parseAmount(amount.value);\n    } catch (error) {\n      alert.textContent = error.message;\n      alert.hidden = false;\n    }\n    alert.hidden = true;\n    alert.textContent = '';\n    onAdd({ description: description.value.trim(), amountCents });\n    description.value = '';\n    amount.value = '';\n  });\n}\n"},{"id":"keeps-values","code":"function parseAmount(input) {\n  const text = String(input).trim();\n  if (!/^\\d+(\\.\\d{1,2})?$/.test(text)) throw new Error(`Not a valid amount: \"${input}\"`);\n  const [whole, fraction = ''] = text.split('.');\n  const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));\n  if (cents === 0) throw new Error('Amount must be greater than zero');\n  return cents;\n}\n\n// Mounts the \"Add expense\" form into container.\n// - Text fields labelled \"Description\" and \"Amount\", and an\n//   \"Add expense\" button.\n// - On click, if valid: calls onAdd({ description, amountCents }) with the\n//   trimmed description and the amount in integer cents, hides any error,\n//   then clears both fields.\n// - If invalid: shows the reason in the element with role=\"alert\",\n//   does NOT call onAdd, and keeps what the user typed.\n//   Blank description -> \"Add a description\"; bad amount -> parseAmount's message.\n// (The playground blocks real form submission, so this reacts to the click.)\nfunction mountExpenseForm(container, onAdd) {\n  container.innerHTML = `\n    <div>\n      <label>Description <input name=\"description\"></label>\n      <label>Amount <input name=\"amount\" inputmode=\"decimal\"></label>\n      <button type=\"button\">Add expense</button>\n      <p role=\"alert\" hidden></p>\n    </div>`;\n  const [description, amount] = container.querySelectorAll('input');\n  const alert = container.querySelector('[role=\"alert\"]');\n  container.querySelector('button').addEventListener('click', () => {\n    let amountCents;\n    try {\n      if (!description.value.trim()) throw new Error('Add a description');\n      amountCents = parseAmount(amount.value);\n    } catch (error) {\n      alert.textContent = error.message;\n      alert.hidden = false;\n      return;\n    }\n    alert.hidden = true;\n    alert.textContent = '';\n    onAdd({ description: description.value.trim(), amountCents });\n\n  });\n}\n"},{"id":"dollars-not-cents","code":"function parseAmount(input) {\n  const text = String(input).trim();\n  if (!/^\\d+(\\.\\d{1,2})?$/.test(text)) throw new Error(`Not a valid amount: \"${input}\"`);\n  const [whole, fraction = ''] = text.split('.');\n  const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));\n  if (cents === 0) throw new Error('Amount must be greater than zero');\n  return cents;\n}\n\n// Mounts the \"Add expense\" form into container.\n// - Text fields labelled \"Description\" and \"Amount\", and an\n//   \"Add expense\" button.\n// - On click, if valid: calls onAdd({ description, amountCents }) with the\n//   trimmed description and the amount in integer cents, hides any error,\n//   then clears both fields.\n// - If invalid: shows the reason in the element with role=\"alert\",\n//   does NOT call onAdd, and keeps what the user typed.\n//   Blank description -> \"Add a description\"; bad amount -> parseAmount's message.\n// (The playground blocks real form submission, so this reacts to the click.)\nfunction mountExpenseForm(container, onAdd) {\n  container.innerHTML = `\n    <div>\n      <label>Description <input name=\"description\"></label>\n      <label>Amount <input name=\"amount\" inputmode=\"decimal\"></label>\n      <button type=\"button\">Add expense</button>\n      <p role=\"alert\" hidden></p>\n    </div>`;\n  const [description, amount] = container.querySelectorAll('input');\n  const alert = container.querySelector('[role=\"alert\"]');\n  container.querySelector('button').addEventListener('click', () => {\n    let amountCents;\n    try {\n      if (!description.value.trim()) throw new Error('Add a description');\n      amountCents = parseAmount(amount.value);\n    } catch (error) {\n      alert.textContent = error.message;\n      alert.hidden = false;\n      return;\n    }\n    alert.hidden = true;\n    alert.textContent = '';\n    onAdd({ description: description.value.trim(), amountCents: Number(amount.value) });\n    description.value = '';\n    amount.value = '';\n  });\n}\n"},{"id":"silent-error","code":"function parseAmount(input) {\n  const text = String(input).trim();\n  if (!/^\\d+(\\.\\d{1,2})?$/.test(text)) throw new Error(`Not a valid amount: \"${input}\"`);\n  const [whole, fraction = ''] = text.split('.');\n  const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));\n  if (cents === 0) throw new Error('Amount must be greater than zero');\n  return cents;\n}\n\n// Mounts the \"Add expense\" form into container.\n// - Text fields labelled \"Description\" and \"Amount\", and an\n//   \"Add expense\" button.\n// - On click, if valid: calls onAdd({ description, amountCents }) with the\n//   trimmed description and the amount in integer cents, hides any error,\n//   then clears both fields.\n// - If invalid: shows the reason in the element with role=\"alert\",\n//   does NOT call onAdd, and keeps what the user typed.\n//   Blank description -> \"Add a description\"; bad amount -> parseAmount's message.\n// (The playground blocks real form submission, so this reacts to the click.)\nfunction mountExpenseForm(container, onAdd) {\n  container.innerHTML = `\n    <div>\n      <label>Description <input name=\"description\"></label>\n      <label>Amount <input name=\"amount\" inputmode=\"decimal\"></label>\n      <button type=\"button\">Add expense</button>\n      <p role=\"alert\" hidden></p>\n    </div>`;\n  const [description, amount] = container.querySelectorAll('input');\n  const alert = container.querySelector('[role=\"alert\"]');\n  container.querySelector('button').addEventListener('click', () => {\n    let amountCents;\n    try {\n      if (!description.value.trim()) throw new Error('Add a description');\n      amountCents = parseAmount(amount.value);\n    } catch (error) {\n      alert.textContent = error.message;\n      return;\n    }\n    alert.hidden = true;\n    alert.textContent = '';\n    onAdd({ description: description.value.trim(), amountCents });\n    description.value = '';\n    amount.value = '';\n  });\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 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('expense form', () => {\n  test('has the fields and the button', () => {\n    mountExpenseForm(container, vi.fn());\n\n    expect(getByRole(container, 'textbox', { name: 'Description' })).toBeTruthy();\n    expect(getByRole(container, 'textbox', { name: 'Amount' })).toBeTruthy();\n    expect(getByRole(container, 'button', { name: 'Add expense' })).toBeTruthy();\n  });\n\n  // Add: the happy path (what onAdd receives, the fields afterwards)\n  // and the invalid-amount path (alert, onAdd, the typed text).\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 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('expense form', () => {\n  const description = () => getByRole(container, 'textbox', { name: 'Description' });\n  const amount = () => getByRole(container, 'textbox', { name: 'Amount' });\n  const addButton = () => getByRole(container, 'button', { name: 'Add expense' });\n\n  test('has the fields and the button', () => {\n    mountExpenseForm(container, vi.fn());\n\n    expect(description()).toBeTruthy();\n    expect(amount()).toBeTruthy();\n    expect(addButton()).toBeTruthy();\n  });\n\n  test('adds a valid expense in cents and clears the form', () => {\n    const onAdd = vi.fn();\n    mountExpenseForm(container, onAdd);\n\n    typeInto(description(), 'Dinner');\n    typeInto(amount(), '45.50');\n    clickOn(addButton());\n\n    expect(onAdd).toHaveBeenCalledWith({ description: 'Dinner', amountCents: 4550 });\n    expect(description().value).toBe('');\n    expect(amount().value).toBe('');\n  });\n\n  test('explains an invalid amount and keeps the input', () => {\n    const onAdd = vi.fn();\n    mountExpenseForm(container, onAdd);\n\n    typeInto(description(), 'Taxi');\n    typeInto(amount(), 'abc');\n    clickOn(addButton());\n\n    expect(getByRole(container, 'alert').textContent).toMatch('Not a valid amount');\n    expect(onAdd).not.toHaveBeenCalled();\n    expect(amount().value).toBe('abc');\n  });\n});\n"},"checks":[{"id":"passes","suite":"subject"},{"id":"catches-adds-invalid","suite":"adds-invalid"},{"id":"catches-keeps-values","suite":"keeps-values"},{"id":"catches-dollars-not-cents","suite":"dollars-not-cents"},{"id":"catches-silent-error","suite":"silent-error"}],"title":"Use the expense form like a person","prompt":"<p><code dir=\"ltr\">mountExpenseForm(container, onAdd)</code> renders fields labelled <strong>Description</strong> and <strong>Amount</strong> and an <strong>Add expense</strong> button. A valid click calls <code dir=\"ltr\">onAdd({ description, amountCents })</code> and clears the fields; an invalid one shows the reason in <code dir=\"ltr\">role=&quot;alert&quot;</code>, skips <code dir=\"ltr\">onAdd</code> and keeps the typed text.</p>\n<p>The starter includes the query stand-ins from the last exercise plus <code dir=\"ltr\">typeInto(el, text)</code> and <code dir=\"ltr\">clickOn(el)</code>, which mimic user-event (they’re synchronous: no <code dir=\"ltr\">await</code>). Add tests until your suite catches four hidden bugs: one calls <code dir=\"ltr\">onAdd</code> even when the amount is <strong>invalid</strong>, one <strong>doesn’t clear</strong> the fields, one passes the amount in <strong>dollars</strong> instead of cents, and one <strong>never shows</strong> the alert.</p>\n","hints":["Happy path: type ‘Dinner’ and ‘45.50’, click the button, then <code dir=\"ltr\">expect(onAdd).toHaveBeenCalledWith({ description: 'Dinner', amountCents: 4550 })</code>.","After a successful add, read the fields again and check that <code dir=\"ltr\">.value</code> is <code dir=\"ltr\">''</code> for both.","Invalid path: type ‘abc’ as the amount, click, then check <code dir=\"ltr\">getByRole(container, 'alert').textContent</code>, <code dir=\"ltr\">expect(onAdd).not.toHaveBeenCalled()</code> and that the amount field still holds ‘abc’. The query stand-ins skip hidden elements, just like the real <code dir=\"ltr\">getByRole</code>."],"explanation":"<p>Two tests, six assertions, four bugs. <code dir=\"ltr\">toHaveBeenCalledWith({ description: 'Dinner', amountCents: 4550 })</code> pins the contract with the rest of the app, so the dollars-for-cents bug fails it (it sends 45.5). Checking the fields after the add catches the form that forgets to clear. On the invalid path, <code dir=\"ltr\">getByRole(container, 'alert')</code> fails when the alert stays hidden, because hidden elements aren’t in the accessibility tree, and <code dir=\"ltr\">not.toHaveBeenCalled()</code> catches the form that saves garbage anyway. Every assertion is about something the user sees or the app receives; none mentions a variable inside the widget.</p>\n","checkLabels":{"passes":"All your tests pass against the real form","catches-adds-invalid":"Catches the version that calls onAdd with an invalid amount","catches-keeps-values":"Catches the version that doesn’t clear the fields","catches-dollars-not-cents":"Catches the version that sends dollars instead of cents","catches-silent-error":"Catches the version that never shows the alert"}},"words":675,"readMinutes":3}