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.
- 01Choose 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.
- 02Query like a userFind controls by role, label, and visible text instead of brittle CSS selectors or private test IDs.
- 03Catch 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?
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.
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.
| Layer | Best signal | Honest limitation |
|---|---|---|
| jsdom plus Testing Library | Fast DOM feedback in Node | Great 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 browser | High-confidence user journeys | Great 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 review | Human and assistive-tech reality | Great 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 ENVIRONMENTTest 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.
// 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.
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 THROUGHTesting 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 | Use it for | Why it beats CSS/test IDs |
|---|---|---|
getByRole('button', { name: 'Submit' }) | A visible control a user can activate | Fails when a refactor removes the name, which is usually a real usability problem. |
getByLabelText('Email') | A form field with a label | Connects the test to label semantics instead of an implementation class. |
getByText('Saved') | A message or non-interactive content | Good for confirmation copy, headings, and status text. |
queryByText('Error') | Checking something is absent | Returns null instead of throwing, so absence assertions read clearly. |
findByText('Saved') | Content that appears later | Retries 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.
// 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();
});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 })); } },};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 that follows the user path: render, find by role and label, type, click, then assert the visible message.
script
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);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 & RUNRun 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.
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);- Run the test while the label is present.
Run the test while the label is present.
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 BROWSERPlaywright 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.
// 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.
// 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");
});# Typical CI command when @playwright/test is installed
npx playwright test --trace on-first-retryA 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 ITTools 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.
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;}A small automated check can catch missing names and labels quickly. It complements, not replaces, manual accessibility testing.
script
'<img src="data:,"><button></button><input id="email">';const issues = checkBasicA11y(document.querySelector("#audit-fixture"));for (const issue of issues) console.log(issue);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);- No issues from these three simple checks.
Run the checks. Then fix the markup and run them again; manual keyboard review still remains.
- 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 ITThe 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.
- Unit tests: fastest, lowest browser confidence.
- DOM/integration tests: medium speed, strong component behavior confidence.
- E2E tests: slowest, highest journey confidence.
- Manual accessibility review: slower but essential for human judgment.
`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.
Sort each scenario by the smallest layer that gives meaningful confidence.
| Cause | What goes wrong | Better move |
|---|---|---|
| Manual sleeps | The test guesses how long the app needs | Use locator auto-waiting and web-first assertions instead of setTimeout guesses. |
| Shared state | A previous test leaves storage, cookies, data, or network records behind | Use isolated contexts, reset fixtures, and unique test data. |
| Over-specific selectors | A CSS class or DOM nesting changes while the user experience stays the same | Prefer role, label, text, and stable public contracts. |
| Animation and async work | The UI is between states when the assertion runs | Wait 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.
| Question | jsdom + Testing Library | Playwright | Manual a11y review |
|---|---|---|---|
| What does it run? | A simulated DOM in Node | A real browser page | A person using keyboard, zoom, screen reader, or other assistive paths |
| Best assertion | Role, label, text, and event behavior inside a component | Visible page state across routing, storage, network, and rendering | Whether the workflow is understandable and operable |
| Main cost | Can miss layout, CSS, browser, and navigation issues | Slower and more sensitive to environment/data | Requires time, skill, and repeatable notes |
Practice exercises
5 EXERCISESRun the code mentally. What id is printed?
installGreetingApp(document.querySelector("#app"));
const input = getByRole(document, "textbox", { name: "Name" });
console.log(input.id);The role query finds the text input through its label and prints visitor-name.
Predict the boolean printed after the label is removed.
installGreetingApp(document.querySelector("#app"));
document.querySelector("label").remove();
try {
getByRole(document, "textbox", { name: "Name" });
} catch (error) {
console.log(error.message.includes("No element"));
}It prints true. The role query fails because the textbox is no longer named Name.
You want to assert that no error message is on the page. Which query family should you use?
expect(screen.queryByText(/error/i)).toBeNull();Use a queryBy* query when zero matches is the expected state.
How many issues does the tiny audit report?
document.querySelector("#audit-fixture").innerHTML =
'<img src="data:,"><button></button><input id="email">';
console.log(checkBasicA11y(document.querySelector("#audit-fixture")).length);The simple audit prints 3: one issue for the image, one for the button, and one for the input.
Which layer should cover a complete add-to-cart, sign-in, payment, and receipt journey?
The full checkout path needs one or two Playwright E2E smoke tests for confidence, while most edge cases should still live in smaller faster unit and DOM tests.
Check your understanding
8 QUESTIONSQuestion 1 of 8What is the main goal of a DOM test in this lesson?
Choose an answer to see the explanation.
Question 2 of 8What does this role query print in the lesson mini app?
Read the code, then predictinstallGreetingApp(document.querySelector("#app")); const input = getByRole(document, "textbox", { name: "Name" }); console.log(input.id);Choose an answer to see the explanation.
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.
Question 4 of 8What does the broken-label snippet print?
Read the code, then predictinstallGreetingApp(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.
Question 5 of 8Why does Playwright prefer locators and web-first assertions?
Choose an answer to see the explanation.
Question 6 of 8Which statement about jsdom is accurate?
Choose an answer to see the explanation.
Question 7 of 8What does the simple accessibility audit count?
Read the code, then predictdocument.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.
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.