Skip to content

testing library no node access

Paulo Gomes da Cruz Junior edited this page Sep 16, 2026 · 7 revisions

title: "Fix Guide — testing-library/no-node-access" type: fix-guide domain: frontend framework: react testing: "vitest + @testing-library/react" lint-rule: "testing-library/no-node-access" severity: warning tags: [testing-library, lint, react, vitest] created: 2026-07-14 updated: 2026-07-14

Fix Guide — testing-library/no-node-access

Problem

Using .closest('form'), document.querySelector(), or other DOM traversal methods directly on elements triggers the testing-library/no-node-access lint warning. Testing Library discourages direct Node access in favor of queries.

Fix

Replace DOM traversal with semantic queries. This requires adding accessible identifiers to the component markup.

Option A — getByRole (preferred)

Add aria-label to the target element in the component:

// Component (index.tsx)
<form
  aria-label="Create reporting unit"
  onSubmit={handleSubmit}
>

Then query by role in the test:

const form = screen.getByRole('form', { name: /create reporting unit/i });

Option B — getByTestId

Add data-testid to the target element in the component:

// Component (index.tsx)
<form
  data-testid="create-ru-form"
  onSubmit={handleSubmit}
>

Then query by test ID in the test:

const form = screen.getByTestId('create-ru-form');

Full Example

// Component (index.tsx)
<form
  aria-label="Create reporting unit"
  data-testid="create-ru-form"
  onSubmit={handleSubmit}
>

// Test (index.unit.test.tsx)
// Before
const form = screen.getByTestId('submit-btn').closest('form');

// After
const form = screen.getByRole('form', { name: /create reporting unit/i });

Trigger — document.querySelector() with CSS selectors

document.querySelector() with ID (#id) or class (.class) selectors also triggers testing-library/no-node-access. Replace with screen.getByRole() or screen.getByTestId().

Before (anti-pattern):

import { render } from '@testing-library/react';

// Multiple tests using document.querySelector
const form = document.querySelector('form');
expect(form).toBeDefined();

const districtField = document.querySelector('#create-ru-district');
const samplingField = document.querySelector('#as-sampling-multi-select');
expect(districtField && samplingField).toBeDefined();

const button = document.querySelector('.create-ru-submit-button');
expect(button).toBeDefined();

After (fixed):

import { render, screen } from '@testing-library/react';

const form = screen.getByRole('form', { name: /create reporting unit/i });
expect(form).toBeTruthy();

const districtField = screen.getByTestId('district-combobox');
const samplingField = screen.getByTestId('sampling-combobox');
expect(districtField).toBeTruthy();
expect(samplingField).toBeTruthy();

const button = screen.getByTestId('create-ru-submit-button');
expect(button).toBeTruthy();

Trigger — document.getElementById()

document.getElementById('id') also triggers testing-library/no-node-access. Same fix as the CSS-selector case: add a data-testid to the component and query with getByTestId.

Before (anti-pattern):

const wrapper = document.getElementById('as-client-multi-select');
expect(wrapper).toBeTruthy();

After (fixed):

Component (index.tsx) — add data-testid to the wrapper element:

<div className="active-multi-select-wrapper" data-testid="as-client-multi-select">

Test (index.unit.test.tsx):

const wrapper = screen.getByTestId('as-client-multi-select');
expect(wrapper).toBeTruthy();

Note — when no queryable id exists, you must edit the component. If the component does not expose a data-testid and has no aria-label/role that makes getByLabelText/getByRole viable (Carbon wrappers often have neither), getByTestId('some-other-testid') throws because that id is not on a reachable node. In AdvancedFilterClientInput/index.unit.test.tsx the ActiveMultiSelect wrapper had no queryable id, so data-testid="as-client-multi-select" was added to src/components/Form/ActiveMultiSelect/index.tsx before the test could query it. Do not guess an existing testid — confirm it renders, or add one.

Key changes:

  1. Import screen — import screen from @testing-library/react alongside render.
  2. Replace CSS selectors with semantic queries — use getByRole for accessible elements (forms with aria-label) and getByTestId for elements identified by data-testid.
  3. Split compound assertions — expect(a && b).toBeDefined() becomes two separate expect(a).toBeTruthy() / expect(b).toBeTruthy() calls. Testing one element per assertion improves error messages when something fails.
  4. Use .toBeTruthy() not .toBeInTheDocument() — the project uses Vitest, not Jest, so @testing-library/jest-dom matchers like .toBeInTheDocument() are unavailable. Assert with .toBeTruthy() instead.

When to use queryBy* vs getBy*

getBy* and queryBy* behave differently when the element is missing. Choose based on whether the assertion expects the element to exist or not.

Query Element found Element missing Return type Use when
getBy* Returns element Throws TestingLibraryElementError HTMLElement Element must exist
queryBy* Returns element Returns null HTMLElement | null Asserting element does not exist

Scenario: Element should NOT exist (assert toBeNull)

Use queryBy* so the query returns null instead of throwing before the assertion runs.

// Before (anti-pattern) — document.querySelector + toBeNull
await waitFor(() => {
  const gradeGroup = document.querySelector('#create-ru-grade');
  expect(gradeGroup).toBeNull();
});

// After (fixed) — screen.queryByTestId + toBeNull
await waitFor(() => {
  const gradeGroup = screen.queryByTestId('grade-radio-group');
  expect(gradeGroup).toBeNull();
});

If you used getByTestId here, Testing Library would throw Unable to find an element before reaching the expect, masking the real failure message.

Scenario: Element MUST exist (assert toBeTruthy)

Use getBy* so a missing element fails fast with a descriptive error.

// Before (anti-pattern) — document.querySelector + as HTMLElement cast
const districtComboBox = document.querySelector('#create-ru-district') as HTMLElement;
await act(async () => {
  districtComboBox?.dispatchEvent(event);
});

// After (fixed) — screen.getByTestId, no cast needed
const districtComboBox = screen.getByTestId('district-combobox');
await act(async () => {
  districtComboBox.dispatchEvent(event);
});

> **Warning:** This pattern assumes the selected node is the ComboBox input. Carbon's `getByTestId('district-combobox')` returns the wrapper `<div>`, not the input, so a synthetic `CustomEvent` dispatched on it will not select the district. See the Carbon ComboBox section below for the working fix.

No more as HTMLElement casts

  • getBy* returns HTMLElement directly — no cast needed.
  • queryBy* returns HTMLElement | null — when you know the element exists (because you are asserting toBeTruthy), narrow with a guard or a non-null assertion if the linter complains. For negative assertions (expect(el).toBeNull()), the null type is exactly what you want.

Carbon ComboBox: getByTestId returns the wrapper, not the input

Swapping document.querySelector('#create-ru-district') for screen.getByTestId('district-combobox') clears the lint warning, but it can silently break a Carbon ComboBox test.

The gotcha. Carbon puts the id prop on the inner <input> and data-testid on the root wrapper <div>. querySelector('#create-ru-district') returns the input, so a synthetic CustomEvent('change') reaches the ComboBox handler. getByTestId('district-combobox') returns the wrapper, so the same synthetic event never triggers selection. The district stays unselected and the dependent grade field never renders.

Why the simple swap fails. If you keep the dispatchEvent(new CustomEvent('change', ...)) call and only change the locator, the selection never happens. In additional-coverage.unit.test.tsx this broke 8 tests.

The sanctioned fix: drive the ComboBox for real. Replace the synthetic dispatch with real Testing Library interaction:

import userEvent from '@testing-library/user-event';

const districtInput = await screen.findByTestId('district-combobox');
await userEvent.clear(districtInput);
await userEvent.type(districtInput, 'DKM');

const districtText = await screen.findByText(/Dease Lake/i);
districtText.click();

The ComboBox onChange fires with the selected item, and the grade field renders as expected. See Fix Guide — Interacting with Carbon ComboBox in Tests for the matching caveat.

The real risk of the synthetic pattern is not the thrown error, which only appears when the event fails to reach the handler. It is the false green: even when the CustomEvent appears to fire, weak or absent assertions let the test pass without validating real behavior. See False Green Warning — synthetic events + weak assertions for why real userEvent is a correctness requirement.

Bonus. Using findByTestId and findByText also satisfies testing-library/prefer-find-by, so you avoid waitFor(() => getByX(...)) patterns.

Blur validation: trigger the real onBlur

A FocusEvent('blur') dispatched on a wrapper (or even the input) is a synthetic event that often does not reach the form library's onBlur handler — and even when it does, a toBeTruthy() afterwards is a false green. Drive a real blur instead.

Native focus() + blur() on the input reliably fires React's delegated focusout (React onBlur) in jsdom, so the validator actually runs:

const districtInput = screen.getByRole('combobox', { name: /district/i });
districtInput.focus();
districtInput.blur();

await waitFor(() => {
  expect(screen.getByText(/You must select a district to proceed/i)).toBeTruthy();
});
  • Use getByRole('combobox', { name: /…/i }) to get the input (not getByTestId('district-combobox'), which returns the wrapper <div> and is not focusable).
  • Validation is usually async (onBlurAsync) — wrap the assertion in waitFor.
  • If the field has a default value, blurring it validates as valid and produces NO error. In that case assert the error is absent: expect(screen.queryByText(/… required/i)).toBeNull(). (Example: samplingCode: 'AVG' default in ReportingUnitCreate/index.tsx — its blur test asserts no error rather than an error.)

Non-focusable / hidden targets

If the onBlur handler is wired to a type="hidden" input (common in test mocks), element.focus() is a no-op in jsdom and element.blur() never fires focusout — so the handler never runs (a silent false green). Dispatch a real bubbling focusout event instead. That is exactly what the browser fires on blur, and what React's delegated onBlur listens for:

import { fireEvent } from '@testing-library/react';

fireEvent(clientBlurInput, new FocusEvent('focusout', { bubbles: true }));

await waitFor(() => {
  expect(screen.getByText(/Please select a client/i)).toBeTruthy();
});

(See should validate client field on blur in coverage.unit.test.tsx — the hidden client-blur-input cannot be focus()ed, so a bubbling focusout is required.)

Trade-offs

Pros:

  • Tests reflect how users and assistive technology interact with the UI
  • No fragile DOM traversal
  • Encourages accessible markup (aria-label)

Cons:

  • Requires modifying the component source to add aria-label or data-testid
  • getByRole('form') needs an accessible name — forms without aria-label or aria-labelledby won't be found

Multiple Identical Test IDs

When a mock component renders multiple instances with the same data-testid, getByTestId throws because it finds multiple elements. Use getAllByTestId and select by index:

const sections = screen.getAllByTestId("formula-section-area");
expect(sections[0]).toBeTruthy();
expect(sections[1]).toBeTruthy();

See Test Patterns — Formula Create Screen for details.

Waste Plus Wiki

Home

Architecture

Codes

Decisions

Knowledge Base

Development

Changes

Clone this wiki locally