Repository navigation
testing library no node access
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
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.
Replace DOM traversal with semantic queries. This requires adding accessible identifiers to the component markup.
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 });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');// 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 });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();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-testidand has noaria-label/rolethat makesgetByLabelText/getByRoleviable (Carbon wrappers often have neither),getByTestId('some-other-testid')throws because that id is not on a reachable node. InAdvancedFilterClientInput/index.unit.test.tsxtheActiveMultiSelectwrapper had no queryable id, sodata-testid="as-client-multi-select"was added tosrc/components/Form/ActiveMultiSelect/index.tsxbefore the test could query it. Do not guess an existing testid — confirm it renders, or add one.
Key changes:
-
Import
screen— importscreenfrom@testing-library/reactalongsiderender. -
Replace CSS selectors with semantic queries — use
getByRolefor accessible elements (forms witharia-label) andgetByTestIdfor elements identified bydata-testid. -
Split compound assertions —
expect(a && b).toBeDefined()becomes two separateexpect(a).toBeTruthy()/expect(b).toBeTruthy()calls. Testing one element per assertion improves error messages when something fails. -
Use
.toBeTruthy()not.toBeInTheDocument()— the project uses Vitest, not Jest, so@testing-library/jest-dommatchers like.toBeInTheDocument()are unavailable. Assert with.toBeTruthy()instead.
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*returnsHTMLElementdirectly — no cast needed. -
queryBy*returnsHTMLElement | null— when you know the element exists (because you are assertingtoBeTruthy), narrow with a guard or a non-null assertion if the linter complains. For negative assertions (expect(el).toBeNull()), thenulltype is exactly what you want.
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.
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 (notgetByTestId('district-combobox'), which returns the wrapper<div>and is not focusable). - Validation is usually async (
onBlurAsync) — wrap the assertion inwaitFor. - 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 inReportingUnitCreate/index.tsx— its blur test asserts no error rather than an error.)
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.)
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-labelordata-testid -
getByRole('form')needs an accessible name — forms withoutaria-labeloraria-labelledbywon't be found
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.
This wiki documents NR Waste Plus for users and developers. Verify security and deployment guidance against the current application configuration before applying it elsewhere.
- Report an issue or ask a question in the GitHub repository.
- Follow the Contributing Guidelines to propose an update.
- Read the Code of Conduct.
Collection indexes support human browsing and machine retrieval. Keep both current when adding or moving wiki pages.
NR Waste Plus Wiki | GitHub Repository