cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Testing the DOM & end to end

Test browser UI the way users experience it with role queries, a tiny DOM testing helper, Playwright flows, and accessibility checks.

By the end you can
  • 01
    Choose the right UI test layerExplain when a jsdom-style DOM test, a real-browser Playwright test, or a manual accessibility review gives the best signal.
  • 02
    Query like a userFind controls by role, label, and visible text instead of brittle CSS selectors or private test IDs.
  • 03
    Catch accessibility regressions earlyRun simple automated checks, then add keyboard and assistive-technology review for what tools cannot prove.

Test what users do, not what components hide

Unit tests from Unit testing prove small logic. Mocks, spies & fakes isolate time, network, and randomness. Testing async code keeps promises, timers, and events reliable. UI tests add another question: can a person see the right thing, operate the right control, and get the right result?

Plain definition

DOM testing renders markup and interacts with it through roles, labels, text, and events. End-to-end testing drives a real browser through a full user journey. Accessibility checks verify whether the same UI exposes names, labels, focus, and feedback to more than just a mouse user.

Real-life analogyRehearsal room, dress rehearsal, opening night

A production team does not wait until opening night to learn every line is wrong. They use the cheapest rehearsal that can reveal the risk. UI testing works the same way: use the smallest test that gives confidence, then reserve browser E2E for journeys where the whole stage matters.

In real life: Actors practice one line
In JavaScript: A unit test checks one pure function
In real life: Actors rehearse a scene with props
In JavaScript: A DOM test checks one rendered interaction
In real life: The full stage, lights, and audience run together
In JavaScript: An E2E test checks the app in a browser
In real life: An access coordinator checks every route
In JavaScript: Accessibility review checks keyboard and assistive-tech paths

Where the analogy stops: A theater has one performance at a time. Software runs on many devices, networks, browsers, and assistive technologies, so no single rehearsal proves everything.

This lesson connects the DOM skills from DOM tree, modifying the DOM, events, form elements, keyboard and focus, and form validation to practical tests.

Three UI confidence layers
LayerBest signalHonest limitation
jsdom plus Testing LibraryFast DOM feedback in NodeGreat for component behavior, form labels, event handlers, and conditional rendering. It does not calculate real layout, real CSS painting, full navigation, or browser engine differences.
Playwright in a browserHigh-confidence user journeysGreat for routing, storage, network edges, focus behavior, real rendering, screenshots, traces, and CI smoke flows. Slower and more failure-prone than DOM tests.
Manual accessibility reviewHuman and assistive-tech realityGreat for keyboard order, screen-reader wording, zoom, visible focus, and judgment calls. It catches issues automated rules cannot know.

jsdom and DOM tests

VERIFY THE ENVIRONMENT

Test runners such as Jest and Vitest often run DOM tests in jsdom. jsdom is a JavaScript implementation of many DOM and HTML standards for Node.js. It can parse HTML, build elements, dispatch events, and let your test inspect the resulting document without launching a browser.

What jsdom setup looks likeJavaScript
// Non-runnable here: install jsdom in your test project first.
import { JSDOM } from "jsdom";

const dom = new JSDOM(`<!doctype html><button>Save</button>`, {
  url: "https://example.test/form",
});

const { document } = dom.window;
console.log(document.querySelector("button").textContent);

This workspace does not declare jsdom, so the snippet above is intentionally non-runnable here. The important verified limitation is scope: jsdom documents that navigation and layout are outside its current scope. That means no real CSS layout boxes, no reliable offsetTop measurement, and no full browser navigation when a link changes the page.

Use jsdom for behavior, not pixels

A DOM test is excellent for “typing a valid email enables Submit.” It is the wrong tool for “the dropdown is not clipped at 320px,” “this CSS grid wraps correctly,” or “a browser blocks a popup during navigation.” Use Playwright or manual review for those.

Roles, labels, and text make tests resilient

STEP THROUGH

Testing Library’s guiding principle is: “the more your tests resemble the way your software is used, the more confidence they can give you.” In practice, that means prefer semantic queries: getByRole, getByLabelText, getByText, async findBy* queries, and absence-friendly queryBy* queries.

Query by the user-facing contract first
QueryUse it forWhy it beats CSS/test IDs
getByRole('button', { name: 'Submit' })A visible control a user can activateFails when a refactor removes the name, which is usually a real usability problem.
getByLabelText('Email')A form field with a labelConnects the test to label semantics instead of an implementation class.
getByText('Saved')A message or non-interactive contentGood for confirmation copy, headings, and status text.
queryByText('Error')Checking something is absentReturns null instead of throwing, so absence assertions read clearly.
findByText('Saved')Content that appears laterRetries until the element appears or the timeout expires.

The real Testing Library and user-event packages are not installed in this workspace, so this lesson implements a tiny subset you can run. It only supports the roles and names used by the mini app, but it teaches the shape of the real APIs.

What the real Testing Library version looks likeJavaScript
// Non-runnable here: @testing-library/* is not installed in this lesson.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

test("submits a greeting", async () => {
  const user = userEvent.setup();
  render(<GreetingForm />);

  await user.type(screen.getByRole("textbox", { name: /name/i }), "Ava");
  await user.click(screen.getByRole("button", { name: /submit/i }));

  expect(await screen.findByText("Thanks, Ava!")).toBeVisible();
});
Tiny Testing Library subsetPop out in the code editor (opens in a new tab)JavaScript
function normalizeText(value) {  return (value ?? "").replace(/\s+/g, " ").trim();}function matchesText(value, matcher) {  const text = normalizeText(value);  return typeof matcher === "string" ? text === matcher : matcher.test(text);}function accessibleName(element) {  const aria = normalizeText(element.getAttribute("aria-label"));  if (aria) return aria;  const tag = element.tagName.toLowerCase();  if (["input", "textarea", "select"].includes(tag)) {    const labels = element.labels ? Array.from(element.labels) : [];    const labelText = normalizeText(labels.map((label) => label.textContent).join(" "));    if (labelText) return labelText;  }  if (tag === "img") return normalizeText(element.getAttribute("alt"));  return normalizeText(element.textContent);}function implicitRole(element) {  const role = normalizeText(element.getAttribute("role"));  if (role) return role.split(" ")[0];  const tag = element.tagName.toLowerCase();  if (tag === "button") return "button";  if (tag === "textarea") return "textbox";  if (tag === "input" && ["email", "search", "tel", "text", "url"].includes(element.type)) return "textbox";  if (tag === "input" && ["button", "submit", "reset"].includes(element.type)) return "button";  if (tag === "a" && element.hasAttribute("href")) return "link";  return "";}function allElements(root) {  return root.documentElement ? Array.from(root.querySelectorAll("*")) : [root, ...root.querySelectorAll("*")];}function getByRole(root, role, options = {}) {  const matches = allElements(root).filter((element) => implicitRole(element) === role && (options.name === undefined || matchesText(accessibleName(element), options.name)));  if (matches.length === 1) return matches[0];  if (matches.length === 0) throw new Error('No element found with role "' + role + '"' + (options.name ? ' and name "' + options.name + '"' : '') + ".");  throw new Error('Found ' + matches.length + ' elements with role "' + role + '".');}function getByText(root, text) {  const matches = allElements(root).filter((element) => matchesText(element.textContent, text) && !Array.from(element.children).some((child) => matchesText(child.textContent, text)));  if (matches.length === 1) return matches[0];  if (matches.length === 0) throw new Error('No element found with text "' + text + '".');  throw new Error('Found ' + matches.length + ' elements with text "' + text + '".');}const user = {  click(element) {    element.focus?.();    for (const type of ["mouseover", "mousemove", "mousedown", "mouseup", "click"]) {      element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));    }  },  type(element, value) {    element.focus();    for (const char of value) {      element.dispatchEvent(new KeyboardEvent("keydown", { key: char, bubbles: true }));      element.value += char;      element.dispatchEvent(new InputEvent("input", { data: char, bubbles: true, inputType: "insertText" }));      element.dispatchEvent(new KeyboardEvent("keyup", { key: char, bubbles: true }));    }  },};
Mini app rendered by the testsPop out in the code editor (opens in a new tab)JavaScript
function installGreetingApp(root, options = {}) {  const labelMarkup = options.withLabel === false    ? ""    : '<label for="visitor-name">Name</label>';  root.innerHTML = '<form id="greeting-form">'    + labelMarkup    + '<input id="visitor-name" type="text">'    + '<button type="submit">Submit</button>'    + '<p role="status" aria-live="polite"></p>'    + '</form>';   const form = root.querySelector("form");  const input = root.querySelector("#visitor-name");  const status = root.querySelector('[role="status"]');  form.addEventListener("submit", (event) => {    event.preventDefault();    const name = input.value.trim() || "friend";    status.textContent = "Thanks, " + name + "!";  });}
Step through a DOM test
Step 0 of 6Ready
Your turn: follow the blue line

Step through a DOM test that follows the user path: render, find by role and label, type, click, then assert the visible message.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
const nameInput = getByRole(document, "textbox", { name: "Name" });user.type(nameInput, "Ava");const submit = getByRole(document, "button", { name: "Submit" });user.click(submit);console.log(getByText(document, "Thanks, Ava!").textContent);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

A selector like .signup input.primary can stay green after the label breaks. A role-and-name query fails loudly when the field is no longer named for users of assistive technology. That failure is a feature: the test now guards accessibility and behavior together.

Live DOM test lab

EDIT & RUN

Run the test while the label is present, then remove the label. The visible input still exists, but the test can no longer find a textbox named Name. That mirrors a real user problem: the control lost its accessible name.

Live DOM test lab
DOM test used in the labPop out in the code editor (opens in a new tab)JavaScript
installGreetingApp(document.querySelector("#app"));const nameInput = getByRole(document, "textbox", { name: "Name" });user.type(nameInput, "Ava");const submit = getByRole(document, "button", { name: "Submit" });user.click(submit);console.log(getByText(document, "Thanks, Ava!").textContent);
Real mini applabel present
  1. Run the test while the label is present.
Try it yourself

Run the test while the label is present.

The form is rebuilt with real DOM APIs in an empty ref. Break the label, then run the same test and accessibility check again.
Broken-label proofPop out in the code editor (opens in a new tab)JavaScript
installGreetingApp(document.querySelector("#app"));
document.querySelector("label").remove();
getByRole(document, "textbox", { name: "Name" });

user-event is richer than the tiny helper here. It simulates full interactions and checks whether an element can actually be interacted with. fireEvent dispatches a concrete low-level event. Reach for user-level interactions first; use raw events only when you truly need a specific event edge case.

Playwright end-to-end tests

REAL BROWSER

Playwright launches browsers, creates isolated pages, and offers locators that re-read the current DOM each time they act. page.getByRole follows the same user-facing idea as Testing Library. Its web-first assertions, such as toBeVisible and toHaveText, auto-retry until the visible state appears or the timeout expires.

playwright-core script proven by the lesson testJavaScript
// Node script. This repository has playwright-core, not @playwright/test.
import { chromium } from "playwright-core";

const browser = await chromium.launch({ channel: "chrome", headless: true });
const page = await browser.newPage();
await page.setContent(`<form>
  <label for="city">City</label>
  <input id="city">
  <button type="submit">Search</button>
  <p role="status"></p>
</form>
<script>
  document.querySelector("form").addEventListener("submit", (event) => {
    event.preventDefault();
    document.querySelector("[role=status]").textContent =
      "Searching for " + document.querySelector("#city").value;
  });
</script>`);

await page.getByRole("textbox", { name: "City" }).fill("Pune");
await page.getByRole("button", { name: "Search" }).click();
console.log(await page.getByRole("status").textContent());
await browser.close();

This repository declares playwright-core, so the lesson test runs a real Chrome script with chromium.launch({ channel: "chrome", headless: true }) against a page built with setContent. The @playwright/test runner is not installed here, so runner syntax is shown as reference code rather than a runnable lesson block.

@playwright/test runner shapeJavaScript
// Non-runnable here: @playwright/test is not installed in this lesson.
import { test, expect } from "@playwright/test";

test("searches by city", async ({ page }) => {
  await page.goto("/search");
  await page.getByRole("textbox", { name: "City" }).fill("Pune");
  await page.getByRole("button", { name: "Search" }).click();

  await expect(page.getByRole("status")).toHaveText("Searching for Pune");
});
Trace-on-retry CI commandShell
# Typical CI command when @playwright/test is installed
npx playwright test --trace on-first-retry
Why traces matter

A Playwright trace records actions, DOM snapshots, console output, network activity, and screenshots around a failing test. In CI, trace: "on-first-retry" keeps the normal run lighter and saves the debugging evidence when a retry is needed.

Accessibility checks: automation plus manual review

CHECK IT

Tools such as axe-core, Lighthouse, and Playwright plus axe integrations catch many repeatable rule violations. They do not prove a workflow is understandable, that focus order feels right, or that a screen-reader announcement says the right thing at the right time. Avoid pretending a single percentage covers every app; use automation as an early warning system and still test with a keyboard.

Simple accessibility check functionsPop out in the code editor (opens in a new tab)JavaScript
function checkBasicA11y(root) {  const issues = [];  for (const image of root.querySelectorAll("img")) {    if (!image.hasAttribute("alt")) issues.push("Image is missing an alt attribute.");  }  for (const button of root.querySelectorAll("button")) {    if (!accessibleName(button)) issues.push("Button has no accessible name.");  }  for (const input of root.querySelectorAll("input, textarea, select")) {    if (["hidden", "button", "submit", "reset"].includes(input.type)) continue;    if (!accessibleName(input)) issues.push("Input has no label or accessible name.");  }  return issues;}
Step through a tiny accessibility audit
Step 0 of 3Ready
Your turn: follow the blue line

A small automated check can catch missing names and labels quickly. It complements, not replaces, manual accessibility testing.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  '<img src="data:,"><button></button><input id="email">';const issues = checkBasicA11y(document.querySelector("#audit-fixture"));for (const issue of issues) console.log(issue);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Automated checks are a first pass
Simple accessibility audit snippetPop out in the code editor (opens in a new tab)JavaScript
document.querySelector("#audit-fixture").innerHTML =  '<img src="data:,"><button></button><input id="email">';const issues = checkBasicA11y(document.querySelector("#audit-fixture"));for (const issue of issues) console.log(issue);
Fixturebroken
  • No issues from these three simple checks.
Try it yourself

Run the checks. Then fix the markup and run them again; manual keyboard review still remains.

These checks cover only missing alt attributes, unnamed buttons, and unlabeled inputs. Real teams add tools such as axe or Lighthouse and still review manually.
Manual keyboard checklist
  • Can Tab reach every interactive control in a sensible order?
  • Is focus visible in both themes and at small screen widths?
  • Can Enter and Space activate buttons, checkboxes, menus, and custom controls?
  • Do validation errors move focus or announce themselves without trapping the user?

Testing trophy trade-offs and flaky E2E causes

SORT IT

The testing pyramid emphasizes many cheap unit tests and fewer expensive E2E tests. The testing trophy puts more weight on integration and DOM behavior because users care about pieces working together. Both models teach the same trade-off: speed decreases as confidence across the system increases.

  1. Unit tests: fastest, lowest browser confidence.
  2. DOM/integration tests: medium speed, strong component behavior confidence.
  3. E2E tests: slowest, highest journey confidence.
  4. Manual accessibility review: slower but essential for human judgment.
Unit, DOM, or E2E?
  • `priceWithDiscount(100, 0.2)` returns `80`.
  • Typing a name and pressing Submit shows Thanks, Ava! in one form component.
  • A visitor adds a product, signs in, pays with a sandbox card, and sees a receipt.
  • Removing a label from a text input should make a role query fail.
  • `parseRetryAfter('2s')` returns `2000`.
  • Keyboard focus moves through the deployed navigation without getting trapped.
Try it yourself
0 of 6 correct

Sort each scenario by the smallest layer that gives meaningful confidence.

Choose a category for every card. You can change an answer at any time; Reset clears them all.
Common E2E flake causes
CauseWhat goes wrongBetter move
Manual sleepsThe test guesses how long the app needsUse locator auto-waiting and web-first assertions instead of setTimeout guesses.
Shared stateA previous test leaves storage, cookies, data, or network records behindUse isolated contexts, reset fixtures, and unique test data.
Over-specific selectorsA CSS class or DOM nesting changes while the user experience stays the samePrefer role, label, text, and stable public contracts.
Animation and async workThe UI is between states when the assertion runsWait for the user-visible state, not an internal promise you hope has settled.

Common misconceptions

  • “A passing E2E suite proves the UI is accessible.” It proves only the paths and assertions you wrote. Add keyboard and assistive-tech review.
  • “jsdom is a browser.” It is a useful DOM implementation for Node, but it does not perform real layout or full navigation.
  • “CSS selectors are more precise.” They are often more coupled to implementation. Role and label queries protect the public contract.
  • “Test IDs are forbidden.” They are a fallback when no user-facing query makes sense, not the first choice for controls and labels.
  • “More E2E tests always means more confidence.” Too many broad tests can slow feedback and create flaky noise. Cover critical journeys intentionally.
  • “Automated a11y tools catch everything.” They catch repeatable rules, not every human experience.
Similar tools, different jobs
Questionjsdom + Testing LibraryPlaywrightManual a11y review
What does it run?A simulated DOM in NodeA real browser pageA person using keyboard, zoom, screen reader, or other assistive paths
Best assertionRole, label, text, and event behavior inside a componentVisible page state across routing, storage, network, and renderingWhether the workflow is understandable and operable
Main costCan miss layout, CSS, browser, and navigation issuesSlower and more sensitive to environment/dataRequires time, skill, and repeatable notes

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict the role query

Run the code mentally. What id is printed?

Starter codePop out in the code editor (opens in a new tab)JavaScript
installGreetingApp(document.querySelector("#app"));
const input = getByRole(document, "textbox", { name: "Name" });
console.log(input.id);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeBreak the label

    Predict the boolean printed after the label is removed.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    installGreetingApp(document.querySelector("#app"));
    document.querySelector("label").remove();
    try {
      getByRole(document, "textbox", { name: "Name" });
    } catch (error) {
      console.log(error.message.includes("No element"));
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeChoose the absence query

      You want to assert that no error message is on the page. Which query family should you use?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeCount simple accessibility issues

        How many issues does the tiny audit report?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        document.querySelector("#audit-fixture").innerHTML =
          '<img src="data:,"><button></button><input id="email">';
        console.log(checkBasicA11y(document.querySelector("#audit-fixture")).length);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengePick the right layer for checkout

          Which layer should cover a complete add-to-cart, sign-in, payment, and receipt journey?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            DOM and E2E testing quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What is the main goal of a DOM test in this lesson?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this role query print in the lesson mini app?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              installGreetingApp(document.querySelector("#app"));
              const input = getByRole(document, "textbox", { name: "Name" });
              console.log(input.id);

              Choose an answer to see the explanation.

            3. Question 3 of 8Which Testing Library query family should you reach for when asserting an error message is absent?

              Choose an answer to see the explanation.

            4. Question 4 of 8What does the broken-label snippet print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              installGreetingApp(document.querySelector("#app"));
              document.querySelector("label").remove();
              try {
                getByRole(document, "textbox", { name: "Name" });
              } catch (error) {
                console.log(error.message.includes("No element"));
              }

              Choose an answer to see the explanation.

            5. Question 5 of 8Why does Playwright prefer locators and web-first assertions?

              Choose an answer to see the explanation.

            6. Question 6 of 8Which statement about jsdom is accurate?

              Choose an answer to see the explanation.

            7. Question 7 of 8What does the simple accessibility audit count?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              document.querySelector("#audit-fixture").innerHTML =
                '<img src="data:,"><button></button><input id="email">';
              console.log(checkBasicA11y(document.querySelector("#audit-fixture")).length);

              Choose an answer to see the explanation.

            8. Question 8 of 8Why should accessibility testing include manual keyboard checks?

              Choose an answer to see the explanation.

            Key takeaways

            • DOM tests should read and operate the page through roles, labels, text, and realistic events.
            • jsdom is fast and useful, but it does not replace real-browser checks for layout, navigation, and browser differences.
            • Playwright locators and web-first assertions reduce waiting bugs by retrying against user-visible state.
            • Automated accessibility tools catch repeatable issues; manual keyboard and assistive-tech review catches human workflow problems.
            • Use the smallest test layer that proves the risk, and save E2E for critical journeys.

            One-line summary: Test the DOM through the contract users depend on, then use a real browser and manual accessibility review where the simulated DOM cannot see.

            Up next: Advanced debugging, where failing tests become a map for breakpoints, logpoints, traces, and async stack inspection.

            CompleteFrontend Clear concepts. Working examples.