Skip to content
End To End Tester

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:

typescript
// 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:

html
<!-- 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.

typescript
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}');
typescript
// 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

typescript
// 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 zeros
  • offsetWidth, offsetHeight, scrollHeight are 0
  • CSS is parsed but not cascaded or applied — getComputedStyle returns inline styles and little else
  • nothing is ever "hidden because a parent has overflow: hidden"
  • IntersectionObserver, ResizeObserver and matchMedia need polyfills
typescript
// 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:

typescript
// 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 ↗
  • TypeScripttypescript/src/web-frameworks/dom-testing

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?