completefrontend
Open lab My progress
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 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.
  • 02
    Find things the way a user doesFind buttons and fields by their role, label, and visible text, not by CSS classes or hidden test IDs.
  • 03
    Catch 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:

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?

Plain definition

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).

Real-life analogyRehearsal room, dress rehearsal, opening night

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.

Three ways to check a UI
Way to checkWhat it is best atWhat it can and cannot check
jsdom plus Testing LibraryQuick 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 browserWhole 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 handWhat 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 ENVIRONMENT

A 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.

How a test project sets up jsdomJavaScript
// 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.

A label names its inputPop out in the code editor (opens in a new tab)JavaScript
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.
Use jsdom for behavior, not for how things look

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 THROUGH

Testing 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.

Which query to use, and why
QueryUse it forWhy 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:

The same test with the real Testing LibraryJavaScript
// 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.

A tiny copy of a few Testing Library functionsPop 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 }));    }  },};

The highlighted lines are the important parts:

  • Line 8: accessibleName works out an element's name.
  • Line 12: for a form field, the name comes from its label.
  • Line 20: implicitRole works out the role from the tag, so a button tag has the role button.
  • Line 34: getByRole keeps only the elements with the right role and name.
  • Lines 47 and 53: user.click and user.type send the same events a real click or key press sends.

This is the small form that the tests use:

The small form that the tests usePop 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 + "!";  });}

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.

Two buttons with the same namePop out in the code editor (opens in a new tab)JavaScript
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.

Step through a DOM test
Step 0 of 6Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
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);
CallStoreChangeResultBlue line: runs next. 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.

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 & RUN

Run 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.

Live DOM test lab
The DOM test this lab runsPop 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);
The real small formlabel is there
  1. Run the test while the label is there.
Try it yourself

Run the test while the label is there.

The form is built with real DOM code in an empty box. Remove the label, then run the same test and the accessibility check again.

Before you submit, the status line is on the page but holds no message. The check below prints true.

The status line is empty before you submitPop out in the code editor (opens in a new tab)JavaScript
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 findByRole or Playwright's toHaveText. Do not pause for a fixed time and hope.
  • queryBy* checks only this moment. If a message must disappear later, use Testing Library's waitForElementToBeRemoved, which waits until it is gone.
Submit without typing a namePop out in the code editor (opens in a new tab)JavaScript
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.

Remove the label, and the query throwsPop out in the code editor (opens in a new tab)JavaScript
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".

Left: label present. A tag reading Name points at the input, so the query for a textbox named Name finds it: visitor-name. Right: label removed. The input is still on the page but has no name, so the query says No element found.label presentNamenamesinputfound: visitor-namea textbox named Namelabel removedinputno label, so no nameNo element foundthe input is still on the page
Left: the label gives the input the name Name, so the query finds it. Right: the input is still there, but it has no name, so the same query fails.
Give the field its name back, with the same queryPop out in the code editor (opens in a new tab)JavaScript
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-event acts 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.
  • fireEvent sends exactly one event, such as one click event, 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 BROWSER

Playwright 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 toHaveText checks 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.

A Playwright script that the lesson test really runsJavaScript
// 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 setContent method 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.
Step 1: fill the City textbox with Pune. Step 2: click the Search button. Then the status line in the Chrome page reads Searching for Pune.1. typefill("Pune")2. clickclick()ChromestatusSearching for Pune
Playwright finds each control by its role and name, types 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.

The same check with the Playwright test runnerJavaScript
// 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:

Run the tests and record a trace on the first retryShell
# Typical CI command when @playwright/test is installed
npx playwright test --trace on-first-retry
Why traces help

A 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 IT

Tools 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.

A tiny checker with three rulesPop 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;}
  • Line 3 checks every image. An image needs an alt attribute, 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.
Step through a tiny accessibility check
Step 0 of 3Ready
Your turn: follow the blue line

A small automatic checker finds missing names and labels quickly. It adds to checking by hand; it does not replace it.

Running in
  1. script
Next: line 1
  '<img src="data:,"><button></button><input id="email">';const issues = checkBasicA11y(document.querySelector("#audit-fixture"));for (const issue of issues) console.log(issue);
CallStoreChangeResultBlue line: runs next. 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.
Automatic checks are a first step
Run the tiny checker on broken HTMLPop 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);
Test HTMLbroken
  • No issues found by these three simple rules.
Try it yourself

Run the checks. Then fix the HTML and run them again. You still need to check with a keyboard by hand.

These checks look only for images with no alt, buttons with no name, and inputs with no label. Real teams add tools such as axe or Lighthouse, and still check by hand.
Fix the three missing namesPop out in the code editor (opens in a new tab)JavaScript
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.

Left: broken markup. The img has no alt, the button has no name, and the input has no label, so the audit finds 3 issues. Right: fixed markup. The img has alt text, the button has a name, and the input has a label, so the audit finds 0 issues.broken markupimgno altbuttonno nameinputno label3 issuesfixed markupimghas altbuttonhas a nameinputhas a label0 issues
Left: each broken element gives one issue, so the audit prints 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.

Checks to do by hand with the keyboard
  • 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 IT

People 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.

  1. Unit tests: fastest. They tell you least about the real browser.
  2. DOM tests: fairly fast. They tell you a lot about how one part of the page behaves.
  3. E2E tests: slowest. They tell you most about a whole task.
  4. Accessibility checks by hand: slow, but you need a person to judge some things.
Three test layers from left to right. A unit test checks one function and is fastest. A DOM test checks one form and is fast. An E2E test checks the whole app in a real browser and is slowest. Each arrow to the right means more real.unit testmore realDOM testmore realE2E testone functionfastestone formfastwhole app, real browserslowest
Each step to the right tests more of the real app, but runs slower. Pick the smallest layer that can catch the problem you worry about.
Unit, DOM, or E2E?
  • `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.
Try it yourself
0 of 6 correct

For each case, pick the smallest kind of test that can really check it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Why E2E tests become flaky, and what to do instead
CauseWhat goes wrongWhat to do instead
Fixed pausesThe 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 stateAn 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 HTMLA CSS class or nesting changes, but the page works the same for users.Find things by role, label, and text.
Animations and async workThe 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-testid attribute) 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.

Three similar tools and what each one does
Questionjsdom + Testing LibraryPlaywrightChecking 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 EXERCISES
Exercise 1 · Warm-upPredict the role query

Run the code in your head. Which id does it print?

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

    The label is removed. Which true or false value is printed?

    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 · PracticeCheck that something is not there

      You want to check 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 the accessibility issues

        How many issues does the tiny checker 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 test for checkout

          Which kind of test should check the whole task: add to cart, sign in, pay, and see the receipt?

          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's small form?

              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 use to check that an error message is not there?

              Choose an answer to see the explanation.

            4. Question 4 of 8What does the broken-label code 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 use locators and web-first assertions?

              Choose an answer to see the explanation.

            6. Question 6 of 8Which sentence about jsdom is true?

              Choose an answer to see the explanation.

            7. Question 7 of 8How many issues does the tiny accessibility checker 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 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.
            • jsdom is 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.

            CompleteFrontend Clear concepts. Working examples.