DOM Testing
Testing the layer every front-end framework produces — queries by role and accessible name, event simulation, jsdom's limits, and why selector choice decides maintainability.
2 min read · updated 19 September 2026
Every front-end framework eventually produces DOM. Tests that address that DOM the way a user perceives it survive framework migrations; tests that address it structurally do not.
#The query hierarchy
In descending order of preference:
// 1. Role + accessible name. What a screen reader announces.
screen.getByRole('button', { name: 'Add to cart' });
screen.getByRole('heading', { level: 1, name: /field notes/i });
screen.getByRole('textbox', { name: 'Card number' });
// 2. Label, placeholder, or displayed text
screen.getByLabelText('Card number');
screen.getByText('Order confirmed');
// 3. An explicit test contract
screen.getByTestId('order-total');
// 4. Structure. Breaks on the next redesign.
container.querySelector('.card > div:nth-child(2) input');The first is not merely a style preference. getByRole('button', { name: 'Add to cart' }) fails if the element stops being a button, loses its
label, or becomes hidden from assistive technology — all of which are real
defects that a querySelector on a class name would sail past. It is the
cheapest accessibility check available and
it comes free with a maintainable test.
#The accessible name
Worth understanding, because it is where most getByRole confusion comes
from. The name is computed in priority order:
<!-- aria-labelledby wins -->
<button aria-labelledby="lbl"><span id="lbl">Pay</span></button>
<!-- then aria-label -->
<button aria-label="Close dialog">×</button>
<!-- then the associated <label> -->
<label for="card">Card number</label><input id="card">
<!-- then the element's own text content -->
<button>Add to cart</button>
<!-- an icon-only button with no label has NO accessible name.
getByRole('button', { name: ... }) cannot find it — and neither
can a screen-reader user. The test failure is correct. -->
<button><svg aria-hidden="true">…</svg></button>#Events
Simulate the whole interaction, not the single event the handler happens to listen for.
import userEvent from '@testing-library/user-event';
const user = userEvent.setup();
// Fires pointerdown, mousedown, focus, pointerup, mouseup, click — the
// sequence a real click produces.
await user.click(screen.getByRole('button', { name: 'Save' }));
// Fires keydown/keypress/input/keyup per character
await user.type(screen.getByLabelText('Card number'), '4242424242424242');
await user.selectOptions(screen.getByRole('combobox', { name: 'Country' }), 'GB');
await user.tab();
await user.keyboard('{Escape}');// The low-level alternative, which dispatches exactly one event.
// Useful occasionally; misleading as a default, because real users do not
// produce isolated change events.
fireEvent.change(input, { target: { value: '4242' } });#Asynchrony
// findBy* = getBy* + waitFor. Use it for anything that appears later.
expect(await screen.findByText('Order confirmed')).toBeInTheDocument();
// Asserting absence needs queryBy* — getBy* throws when nothing matches.
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// Waiting for disappearance
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));getBy throws, queryBy returns null, findBy returns a promise. Getting
these three straight removes most of the confusion people have with
Testing Library.
#What jsdom cannot do
jsdom implements the DOM and not a browser. It has no layout engine, which means:
getBoundingClientRect()returns zerosoffsetWidth,offsetHeight,scrollHeightare 0- CSS is parsed but not cascaded or applied —
getComputedStylereturns inline styles and little else - nothing is ever "hidden because a parent has
overflow: hidden" IntersectionObserver,ResizeObserverandmatchMedianeed polyfills
// Common setup for the last of those
Object.defineProperty(window, 'matchMedia', {
value: (query: string) => ({
matches: false, media: query, addEventListener: () => {}, removeEventListener: () => {}
})
});If an assertion is about size, position, overlap or computed style, jsdom cannot answer it honestly. Use a real browser — Playwright component testing, Cypress component testing, or Vitest browser mode.
#Web components and no framework at all
The same queries work with no framework involved, which is the point:
// A custom element, tested through its rendered DOM.
customElements.define('price-tag', PriceTag);
document.body.innerHTML = '<price-tag cents="1200" was="1500"></price-tag>';
await customElements.whenDefined('price-tag');
const tag = document.querySelector('price-tag')!;
expect(tag.shadowRoot!.textContent).toContain('£12.00');Shadow DOM is the one place Testing Library's queries need help — they do
not pierce shadow roots by default, so query from the shadowRoot directly.
#Why this transfers
A test written against roles and accessible names does not know whether the DOM came from React, Angular, Vue, Knockout or a template string. That is the single most valuable property a front-end test can have, and it is the reason Testing Library has bindings for all of them over the same query API.
Common questions
- What is the best selector to use in a test?
- The accessible role plus name — getByRole('button', { name: 'Save' }). It describes the element the way a user perceives it, survives restyling and refactoring, and fails when the accessibility of the element breaks, which is a bug worth failing on.
- Is jsdom good enough, or do I need a real browser?
- jsdom is good enough for logic, text, state and event handling, which is most of what component tests assert. It has no layout engine, so anything involving size, position, visibility by overflow, or computed CSS needs a real browser.
- Should I use data-testid?
- As a third choice, after role and label. It is an explicit contract between the app and the suite, which makes it stable; it also tells you nothing about whether the element works for a user, and a suite made entirely of test ids can pass while the page is unusable.
Runnable samples for this page
last test results ↗- TypeScript
typescript/src/web-frameworks/dom-testing
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Testing LibraryThe query API shared across React, Vue, Angular and Svelte — its guiding principle, the queries, user-event, and the mistakes it is designed to prevent.
- Testing ReactComponents, hooks, context, server components and async state — what to test in a React application, what to leave alone, and how to avoid act warnings.
- The Page Object ModelEncapsulating a screen behind a class so tests describe intent rather than selectors — what it fixes, the god-object failure mode, and modern alternatives.
- Accessibility TestingWhat axe and similar tools genuinely catch, the roughly two-thirds they cannot, and how accessible markup makes your test suite more maintainable at the same time.
- JestThe default JavaScript test runner — configuration, projects, snapshot testing, fake timers, and the choices that keep a large Jest suite fast.
- Android App TestingJUnit and Robolectric for local tests, Espresso and Compose for instrumented UI, and the emulator strategy that keeps an Android suite affordable in CI.
- Browser TestingWhat a browser test can prove that nothing below it can, the three engines that matter, headless versus headed, and the cost model that should shape your suite.
- Cross-Browser TestingWhich browsers actually need testing in 2026, what still differs between engines, and a strategy that catches real defects without tripling your pipeline.