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 kind of UI testSay when a DOM test, a Playwright test in a real browser, or an accessibility check by hand is the best way to find a problem.
- 02Find things the way a user doesFind buttons and fields by their role, label, and visible text, not by CSS classes or hidden test IDs.
- 03Catch accessibility problems earlyRun simple automatic checks, then try the page with a keyboard and a screen reader for what tools cannot check.
Test what users do, not what components hide
You have already met three kinds of tests:
- Unit testing checks small pieces of logic, such as one function.
- Mocks, spies & fakes replace time, the network, and random numbers with things your test controls.
- Testing async code makes tests wait for promises, timers, and events.
The UI (user interface) is the part of the app people see and use: text, buttons, and form fields. UI tests ask a new question. Can a person see the right thing, use the right control, and get the right result?
A DOM test builds a piece of the page and uses it the way a person would. It finds things by their role, label, or text, and it sends events such as clicks.
An end-to-end test (E2E test) controls a real browser. It goes through a whole task from start to finish, such as signing in and paying.
An accessibility check makes sure the same page works for everyone. That includes people who use only a keyboard or a screen reader (a program that reads the page aloud).
A theatre team does not wait for opening night to find a forgotten line. They practise in the cheapest way that can show the problem. A full show with lights is saved for the things only a full show can show.
- In real life: An actor practises one line
- In JavaScript: A unit test checks one function
- In real life: Actors rehearse one scene with props
- In JavaScript: A DOM test checks one piece of the page
- In real life: The whole show runs with stage, lights, and audience
- In JavaScript: An E2E test checks the whole app in a browser
- In real life: A helper walks every route a guest in a wheelchair would take
- In JavaScript: An accessibility review checks the keyboard and screen-reader paths
This lesson uses ideas from earlier lessons: DOM tree, modifying the DOM, events, form elements, keyboard and focus, and form validation.
The table below compares three ways to check a UI. Each row is one way. Read across to see what it is good at and what it cannot check.
| Way to check | What it is best at | What it can and cannot check |
|---|---|---|
| jsdom plus Testing Library | Quick checks of the page, run in Node.js. | It checks how one part of the page behaves: labels, events, and what shows up. It cannot check real layout, how CSS looks, loading new pages, or differences between browsers. |
| Playwright in a real browser | Whole tasks, done the way a user does them. | It checks moving between pages, saved data, the network, focus, how the page looks, screenshots, and traces. It is slower, and it fails at random more often than DOM tests. |
| Checking accessibility by hand | What real people and screen readers experience. | It checks keyboard order, what a screen reader says, zoom, and visible focus. It catches problems that no automatic rule can know about. |
jsdom and DOM tests
VERIFY THE ENVIRONMENTA test runner is the program that runs your tests and reports which pass. Jest and Vitest are two popular ones. They run in Node.js, which has no browser page. So for DOM tests they often use jsdom.
jsdom is a pretend browser page written in JavaScript. It runs inside Node.js. It can:
- read HTML and build elements from it,
- send events such as clicks,
- let your test look at the page that results.
It does all this without opening a real browser, so it is fast.
// 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);Line 4 builds a page from a string of HTML. Line 8 takes the document out of it. Line 9 reads the button's text, so it would print Save.
Now look at a link that every page has: a label and its input. The label's for value is the same as the input's id. That match connects them. A DOM test can check this link without looking at any CSS class.
document.querySelector("#app").innerHTML =
'<label for="email">Email</label><input id="email">';
const input = document.querySelector("#email");
console.log(input.labels[0].textContent);Lines 1 and 2 add a label and an input to the page. Line 3 finds the input. Line 4 reads the input's label through input.labels, so it prints Email.
This project does not install jsdom, so you cannot run its setup example here. The lesson runs its other examples in your real browser instead.
jsdom has limits. Its own docs say it does not do layout (working out where each box sits on the screen) or navigation (loading a new page). So:
- elements have no real size or position on the screen,
- measurements such as
offsetTop(how far an element is from the top) are not real, - clicking a link does not load a new page.
A DOM test is great for "typing a valid email turns on the Submit button." It is the wrong tool for these:
- "The dropdown is not cut off on a 320px-wide screen."
- "This CSS grid wraps onto a new row correctly."
- "The browser blocks a popup when the page changes."
For those, use Playwright (a tool that controls a real browser) or check by hand.
Find things by role, label, and text
STEP THROUGHTesting Library is a popular set of tools for DOM tests. Its main rule is: "the more your tests resemble the way your software is used, the more confidence they can give you." In other words, a test should find things the way a person finds them.
A person does not look for a CSS class. They look for:
- a role: what kind of thing it is, such as a button or a text box (
textbox), - a name: what it is called, such as the text on a button or the label of a field (this is called the accessible name, because screen readers read it aloud),
- visible text, such as a message.
Testing Library has a function for each: getByRole, getByLabelText, and getByText. It also has two other families. findBy* waits for something to appear. queryBy* is for checking that something is not there. (The * means "any ending", such as queryByText.)
The table below lists the main queries. Read each row as: this query, use it for this, and this is why it is better than a CSS class or a test ID.
| Query | Use it for | Why it is better than a CSS class or test ID |
|---|---|---|
getByRole('button', { name: 'Submit' }) | A button, link, or field that a user can use. | It fails when a change removes the name. Users would miss that name too. |
getByLabelText('Email') | A form field that has a label. | It finds the field through its label, not through a class that might change. |
getByText('Saved') | A message, heading, or other text. | It checks the words the user actually reads. |
queryByText('Error') | Checking that something is not there. | It returns null instead of throwing, so a "not there" check reads clearly. |
findByText('Saved') | Text that shows up a little later. | It keeps looking until the text appears or time runs out. |
This project does not install Testing Library or its partner user-event (which acts out clicks and typing). So this lesson writes a tiny copy of a few of its functions that you can run. The copy knows only the roles and names that the small form below uses. It still works the same way as the real thing.
First, here is what a test looks like with the real Testing Library:
// 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();
});Line 9 types Ava into the text box named "name". Line 10 clicks the Submit button. Line 12 waits for the message Thanks, Ava! to show up.
Next is the tiny copy that this lesson runs. You do not need to read every line.
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 })); } },};The highlighted lines are the important parts:
- Line 8:
accessibleNameworks out an element's name. - Line 12: for a form field, the name comes from its label.
- Line 20:
implicitRoleworks out the role from the tag, so abuttontag has the rolebutton. - Line 34:
getByRolekeeps only the elements with the right role and name. - Lines 47 and 53:
user.clickanduser.typesend the same events a real click or key press sends.
This is the small form that the tests use:
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 + "!"; });}Line 4 adds the label Name. Line 8 adds the Submit button. Line 14 finds the status line, the paragraph that shows messages. When you submit, line 17 uses the typed name, or friend if the box is empty. Line 18 shows the message.
A query that should find one element must fail when it finds two. Two buttons both named Save are not one clear target. Either search inside one part of the page, or give the buttons different names.
document.querySelector("#app").innerHTML =
'<button>Save</button><button>Save</button>';
try {
getByRole(document, "button", { name: "Save" });
} catch (error) {
console.log(error.message);
}Lines 1 and 2 add two Save buttons. Line 4 asks for one button named Save. The helper finds two, so it throws. The catch prints Found 2 elements with role "button". The real Testing Library also throws when a one-element query finds more than one.
Now step through a full DOM test. It finds the text box, types a name, clicks Submit, and checks the message.
This DOM test follows what a user does: build the form, find the text box by its label, type, click Submit, and read the 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);Compare this with a CSS selector such as .signup input.primary. That selector still finds the input after its label is gone, so the test stays green. A role-and-name query fails as soon as the field loses its name. That failure is useful: the field has also lost its name for screen-reader users. One test now checks both behavior and accessibility.
Live DOM test lab
EDIT & RUNRun the test while the label is there. Then remove the label and run it again. The input is still on the page, but the test can no longer find a text box named Name. A real user has the same problem: the field has lost its 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 there.
Run the test while the label is there.
Before you submit, the status line is on the page but holds no message. The check below prints true.
installGreetingApp(document.querySelector("#app"));
const status = document.querySelector('[role="status"]');
console.log(status.textContent === "");Line 1 builds the form. Line 2 finds its status line. Line 3 checks that its text is empty.
Two notes about messages that change later:
- If a message appears after some waiting, use Testing Library's
findByRoleor Playwright'stoHaveText. Do not pause for a fixed time and hope. queryBy*checks only this moment. If a message must disappear later, use Testing Library'swaitForElementToBeRemoved, which waits until it is gone.
installGreetingApp(document.querySelector("#app"));
user.click(getByRole(document, "button", { name: "Submit" }));
console.log(getByText(document, "Thanks, friend!").textContent);Line 2 clicks Submit without typing. The form uses friend when the box is empty, so line 3 prints Thanks, friend!. This checks a different path from the step-through, where the user typed Ava.
installGreetingApp(document.querySelector("#app"));
document.querySelector("label").remove();
getByRole(document, "textbox", { name: "Name" });Line 2 removes the label. Line 3 looks for a text box named Name. There is none, so it throws No element found with role "textbox" and name "Name".
Name, so the query finds it. Right: the input is still there, but it has no name, so the same query fails.installGreetingApp(document.querySelector("#app"), { withLabel: false });
document.querySelector("#visitor-name").setAttribute("aria-label", "Name");
console.log(getByRole(document, "textbox", { name: "Name" }).id);Line 1 builds the form without a label. Line 2 gives the input a name with aria-label, an attribute that names an element for screen readers. Line 3 uses the same query as before and prints visitor-name.
A visible label is usually better than aria-label here. People who can see the page also need to know what the field is for.
Our tiny user helper is much simpler than the real user-event. Here is how the real tools differ:
user-eventacts out a whole action, the way a person does it. It also checks that the element can really be used, for example that it is not disabled.fireEventsends exactly one event, such as oneclickevent, and nothing else.
Use user-event first. Use fireEvent only when you need to test what happens for one exact event.
Playwright end-to-end tests
REAL BROWSERPlaywright is a tool that controls a real browser from your code. It can open pages, type, click, and read what is on the screen.
It has three main ideas:
- A locator, such as
page.getByRole("button"), describes how to find an element. Playwright looks for the element each time you use the locator. So it still works after the page redraws that element. - An action, such as
click(), waits until the element is visible and turned on before it acts. - An assertion checks a result. A web-first assertion such as
toHaveTextchecks again and again until the text matches, or until time runs out.
A locator on its own checks nothing. Only an assertion makes the test pass or fail.
// 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();Here is what the script does:
- Line 4 starts Chrome without a visible window.
- Line 5 opens a new page.
- Line 6 fills the page with a small search form. The
setContentmethod puts this HTML into the page directly. - Line 20 finds the text box named City and types
Pune. - Line 21 finds the Search button and clicks it.
- Line 22 reads the status line and prints
Searching for Pune.
Pune, and clicks Search. The page then shows Searching for Pune in its status line.This project installs playwright-core, the part of Playwright that controls browsers. So the lesson test runs this exact script in Chrome. It does not install @playwright/test, Playwright's own test runner. So the runner example below is for reading only.
In a real app, open your page with page.goto instead of setContent. That also tests your page addresses and your server. The runner gives each test a fresh browser context, a clean set of cookies and storage. Data saved on your server is not reset for you, so reset it yourself or give each test its own data.
// 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");
});Line 5 opens the search page. Lines 6 and 7 type and click, as before. Line 9 checks that the status line says Searching for Pune, and it keeps checking until it does.
CI (continuous integration) is a server that runs your tests every time code changes. This is a typical command for running Playwright tests there:
# Typical CI command when @playwright/test is installed
npx playwright test --trace on-first-retryA Playwright trace is a recording of a test run. It saves the actions, snapshots of the page, console messages, network requests, and screenshots.
With trace: "on-first-retry", a normal run records nothing, so it stays fast. If a test fails and runs again (a retry), that second run is recorded. Then you have the evidence you need to find the problem.
Accessibility checks: tools plus a person
CHECK ITTools such as axe-core and Lighthouse check a page against a list of rules. You can also run axe inside Playwright tests. These tools find many problems that follow a clear rule, such as an image with no description.
They cannot tell you:
- whether a task is easy to understand,
- whether the keyboard focus moves in a sensible order,
- whether a screen reader says the right thing at the right time.
No single score covers every app. Use the tools as an early warning. Then still try the page with a keyboard yourself.
Here is a tiny checker with three rules. It returns a list of issues, one sentence per problem.
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;}- Line 3 checks every image. An image needs an
altattribute, a short text that describes it. - Line 6 checks every button. A button needs a name.
- Line 9 checks every form field. Line 11 adds an issue when the field has no label or other name.
A small automatic checker finds missing names and labels quickly. It adds to checking by hand; it does not replace it.
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 found by these three simple rules.
Run the checks. Then fix the HTML and run them again. You still need to check with a keyboard by hand.
document.querySelector("#audit-fixture").innerHTML =
'<img src="data:," alt="Chart"><button aria-label="Close"></button><label for="email">Email</label><input id="email">';
console.log(checkBasicA11y(document.querySelector("#audit-fixture")).length);Line 2 gives the image alt text, gives the button a name, and adds a label for the input. Line 3 counts the issues and prints 0.
3. Right: after the fix, these three simple checks print 0.0 means only that these three rules pass. It does not check color contrast, focus order, or whether Chart describes the image well.
- Can the Tab key reach every button, link, and field, in a sensible order?
- Can you always see which element has focus, in both themes and on small screens?
- Do Enter and Space press buttons? Does Space tick checkboxes? Do custom controls use the keys people expect?
- When a form has an error, does focus move to it, or is it read aloud? And can the user still leave the field?
Pick the right kind of test, and avoid flaky ones
SORT ITPeople often draw the mix of tests as a shape:
- The testing pyramid has many cheap unit tests at the bottom and only a few slow E2E tests at the top.
- The testing trophy puts more weight on the middle: tests where several pieces work together, such as DOM tests. Users care that the pieces work together.
Neither shape tells you an exact number of tests. A DOM test may catch a broken label sooner than an E2E test. But only a browser test can show that moving between pages or real layout works. Choose by what could go wrong and by how slow the test is.
- Unit tests: fastest. They tell you least about the real browser.
- DOM tests: fairly fast. They tell you a lot about how one part of the page behaves.
- E2E tests: slowest. They tell you most about a whole task.
- Accessibility checks by hand: slow, but you need a person to judge some things.
`priceWithDiscount(100, 0.2)` returns `80`.- Typing a name and pressing Submit shows
Thanks, Ava!in one form. - A visitor adds a product, signs in, pays with a test 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 live site's menu without getting stuck.
For each case, pick the smallest kind of test that can really check it.
A flaky test passes on some runs and fails on others, even though the code did not change. The table below lists common causes in E2E tests. Read each row as: the cause, what goes wrong, and what to do instead.
| Cause | What goes wrong | What to do instead |
|---|---|---|
| Fixed pauses | The test guesses how long the app needs, and sometimes the guess is too short. | Let locators and web-first assertions wait. Do not use setTimeout guesses. |
| Shared state | An earlier test leaves storage, cookies, data, or server records behind. | Give each test a fresh browser context, reset test data, and use different data per test. |
| Selectors tied to the HTML | A CSS class or nesting changes, but the page works the same for users. | Find things by role, label, and text. |
| Animations and async work | The page is halfway between two states when the check runs. | Wait for what the user can see, not for an inside promise you hope has finished. |
Running a test again, or waiting longer, can hide a timing problem without fixing it. Here is a better plan for a checkout test that talks to a server:
- Wait until the receipt shows on the screen.
- Reset the test order before each run.
- To test one screen state, you can fake the server's reply.
- Keep at least one test that uses the real server. Otherwise an old fake reply can hide a server that has changed or broken.
Common misconceptions
- "If all E2E tests pass, the page is accessible." Passing tests show only that the steps and checks you wrote work. You still need to try the page with a keyboard and a screen reader.
- "jsdom is a browser." It is a pretend page that runs in Node.js. It does not work out layout or load new pages.
- "CSS selectors are more exact." They depend on how the page is built inside. Role and label queries check what users rely on, so they break only when users would notice.
- "Test IDs are not allowed." A test ID (a
data-testidattribute) is fine when no role, label, or text fits. It should not be your first choice for buttons and fields. - "More E2E tests always means more confidence." Too many slow, wide tests make you wait longer and fail at random more often. Cover the most important tasks on purpose.
- "Accessibility tools catch everything." They catch problems that follow a clear rule. They cannot judge what a person experiences.
The table below compares three tools that are easy to mix up. Read each row as a question, and each column as one tool's answer.
| Question | jsdom + Testing Library | Playwright | Checking by hand |
|---|---|---|---|
| What does it run? | A pretend page inside Node.js. | A real browser page. | A person uses the page with a keyboard, zoom, or a screen reader. |
| What does it check best? | Roles, labels, text, and events inside one part of the page. | What the page shows when you move between pages, save data, use the network, and draw the page. | Whether a person can understand and finish the task. |
| What does it cost or miss? | It can miss layout, CSS, browser, and page-loading problems. | It is slower, and it can fail because of the computer or the test data. | It takes time and skill, and you need to write down what you checked. |
Practice exercises
5 EXERCISESRun the code in your head. Which id does it print?
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. Then it prints the input's id, visitor-name.
The label is removed. Which true or false value is printed?
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 throws, because no text box is named Name any more.
You want to check 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 finding nothing is what you expect. It returns null instead of throwing.
How many issues does the tiny checker report?
document.querySelector("#audit-fixture").innerHTML =
'<img src="data:,"><button></button><input id="email">';
console.log(checkBasicA11y(document.querySelector("#audit-fixture")).length);The tiny checker prints 3: one issue for the image, one for the button, and one for the input.
Which kind of test should check the whole task: add to cart, sign in, pay, and see the receipt?
The whole checkout task needs one or two Playwright E2E tests. Most small cases should still go in 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's small form?
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 use to check that an error message is not there?
Choose an answer to see the explanation.
Question 4 of 8What does the broken-label code 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 use locators and web-first assertions?
Choose an answer to see the explanation.
Question 6 of 8Which sentence about jsdom is true?
Choose an answer to see the explanation.
Question 7 of 8How many issues does the tiny accessibility checker 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 checks by hand with the keyboard?
Choose an answer to see the explanation.
Key takeaways
- A DOM test should find and use the page the way a person does: by role, label, text, and real events.
jsdomis fast and useful, but it cannot check layout, page loading, or differences between browsers. Use a real browser for those.- Playwright actions wait until an element can be used. Web-first assertions keep checking until the page shows what you expect.
- Accessibility tools catch problems that follow a clear rule. Checking by hand with a keyboard and a screen reader catches the rest.
- Use the smallest kind of test that can catch the problem. Save E2E tests for the most important tasks.
One-line summary: Test the page through what users rely on (roles, labels, and text), then use a real browser and a person for what a pretend page cannot see.
Up next: Advanced debugging. There, a failing test becomes your starting point for breakpoints, logpoints, traces, and tracking down async bugs.