Emmanuel Akinfulubi

Quality all 2 written

Testing components with the test runner that ships in Node, and accessibility in React.

34 Testing components with node:test

A component is a function that returns what to show, which makes it easy to test: render it, do what a user would do, and check what is on the screen. You do not need to install anything to try it, because Node has a test runner built in. Tests for components use the same pieces as every panel in this course: a DOM, createRoot, and act.

A component and its test

The test below renders Hello into the stage, checks the text, clicks the button inside act, and checks the text again. It unmounts the root at the end, so that the next test starts with an empty page. test and assert come from node:test and node:assert; the Node.js course covers the test runner in full.

The command has one option you have not met. By default node --test runs each test file in a child process, and each child would have to build its own DOM. --test-isolation=none runs the file in the same process, so the DOM is built once. The command writes its report to report.txt, then the panel prints the file and counts the lines that say pass 1 and fail 0: grep -c prints how many lines matched, and exits 1 if there were none, which would stop the build.

Running a component test

ran at build time · terminal · React dev

Hello.jsx

import { useState } from 'react';

export function Hello({ name }) {
  const [clicks, setClicks] = useState(0);
  return (
    <div>
      <p>{'Hello, ' + name}</p>
      <button onClick={() => setClicks(clicks + 1)}>{'clicked ' + clicks}</button>
    </div>
  );
}

hello.test.jsx

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { Hello } from './Hello.jsx';

test('renders hello', async () => {
  const root = createRoot(stage);
  await act(async () => root.render(<Hello name="Ada" />));
  assert.equal(stage.querySelector('p').textContent, 'Hello, Ada');

  await act(async () => stage.querySelector('button').click());
  assert.equal(stage.querySelector('button').textContent, 'clicked 1');

  await act(async () => root.unmount());
});

The terminal (React 19.2.7, development build), as the build ran it

node --test --test-isolation=none hello.test.jsx > report.txt
cat report.txt
✔ renders hello (<n>ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms <n>
grep -c 'ℹ pass 1' report.txt
1
grep -c 'ℹ fail 0' report.txt
1

NODE_OPTIONS loads the course preload into every node this terminal runs, so each one records which React build it loaded.

The run exited 0, which is what a test runner is for: any failing test makes it exit 1, and that is what a script or a continuous integration server looks at. The two durations in the report are shown as <n> because they change every run, and a page that rebuilt with different numbers each time could not be compared with the last one.

act, and what a failure looks like

The test clicked inside act for the reason from the first lessons on events: React batches its work, and act does not return until the work and the effects it caused are finished. When it returns, the DOM is up to date, so the next line can look at it without waiting. An assertion that fails throws an AssertionError; the runner catches it, marks the test failed and carries on. The panel below does the same catching by hand with assert.throws, to show the error.

act, and an assertion that fails

ran at build time · in happy-dom 20.11.6 · React dev

assertion.jsx

import assert from 'node:assert/strict';
import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

function Toggle() {
  const [on, setOn] = useState(false);
  return <button onClick={() => setOn(!on)}>{on ? 'on' : 'off'}</button>;
}

const root = createRoot(stage);
await act(async () => root.render(<Toggle />));
const button = stage.querySelector('button');
const seen = [button.textContent];
await act(async () => button.click());
seen.push(button.textContent);
console.log(seen);

let failure = null;
try {
  assert.equal(button.textContent, 'off');
} catch (error) {
  failure = error;
}
assert.throws(() => assert.equal(button.textContent, 'off'), { code: 'ERR_ASSERTION' });
console.log(failure.name, failure.code);

check('the click was already applied when act returned', seen, ['off', 'on']);
check('a failed assertion is an AssertionError', failure.name, 'AssertionError');
check('with a code a runner can read', failure.code, 'ERR_ASSERTION');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node assertion.jsx

[ 'off', 'on' ]
AssertionError ERR_ASSERTION
ok   the click was already applied when act returned
ok   a failed assertion is an AssertionError
ok   with a code a runner can read

The library most projects use

Many projects write tests with Testing Library, which wraps the steps above in functions such as render and screen.getByRole, and finds elements the way a user would, by their role and label. It is worth learning once you have written a few tests this way, because it is the same idea with a nicer surface.

The same test with Testing Library

shown, not run
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Hello } from './Hello.jsx';

test('counts clicks', async () => {
  render(<Hello name="Ada" />);
  await userEvent.click(screen.getByRole('button'));
  expect(screen.getByRole('button')).toHaveTextContent('clicked 1');
});

needs Testing Library, which is not vendored. The tests above use only what Node and this course already have.

  • node --test is a test runner; any failing test makes it exit 1.
  • Render into the stage, act on it inside act, then assert on what is on the screen.
  • --test-isolation=none keeps the tests in one process, so the DOM is built once.
  • A failed assertion is an AssertionError with the code ERR_ASSERTION.

35 Accessibility in React

An interface that only works with a mouse, or that a screen reader cannot describe, is broken for the people who use it that way. React does not make this harder or easier than HTML does: it writes the elements you describe. What it changes is the spelling of a few attributes (htmlFor, tabIndex, aria-* stay close to HTML) and the habit of building everything out of divs.

Use the element that already does the job

A button can be reached with the Tab key, and Enter or Space press it. A div with an onClick looks the same on screen, and has none of that: it has no role, it is skipped by Tab, and a key press does nothing. The panel renders both and reads what the browser would. It then presses Enter on the div by dispatching a keydown, and nothing happens.

A button, and a div that looks like one

ran at build time · in happy-dom 20.11.6 · React dev

elements.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

function Both() {
  const [log, setLog] = useState([]);
  return (
    <div>
      <button onClick={() => setLog([...log, 'button'])}>save</button>
      <div onClick={() => setLog([...log, 'div'])}>save</div>
      <p>{log.join(',')}</p>
    </div>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Both />));
const button = stage.querySelector('button');
const fake = stage.querySelectorAll('div')[1];
const press = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
await act(async () => {
  fake.dispatchEvent(press);
});
console.log(button.tagName, button.tabIndex, fake.tagName, fake.tabIndex, fake.getAttribute('role'));
console.log('after Enter on the div:', JSON.stringify(stage.querySelector('p').textContent));

check('the real button is a button', button.tagName, 'BUTTON');
check('and Tab can reach it', button.tabIndex, 0);
check('the div is not a button', fake.getAttribute('role'), null);
check('and Tab skips it', fake.tabIndex, -1);
check('Enter on the div did nothing', stage.querySelector('p').textContent, '');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node elements.jsx

BUTTON 0 DIV -1 null
after Enter on the div: ""
ok   the real button is a button
ok   and Tab can reach it
ok   the div is not a button
ok   and Tab skips it
ok   Enter on the div did nothing

If you must build a control out of something else, you have to put back everything the button gave you: a role, tabIndex={0}, and the key handling. That is three things to remember where there was one element, which is the argument for the element.

Putting back what the button gave

ran at build time · in happy-dom 20.11.6 · React dev

rebuilt.jsx

import { useState, act } from 'react';
import { createRoot } from 'react-dom/client';

function Rebuilt() {
  const [count, setCount] = useState(0);
  return (
    <div
      role="button"
      tabIndex={0}
      onClick={() => setCount(count + 1)}
      onKeyDown={(e) => {
        if (e.key === 'Enter' || e.key === ' ') setCount(count + 1);
      }}
    >
      {'pressed ' + count}
    </div>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Rebuilt />));
const control = stage.firstChild;
await act(async () => {
  control.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
});
console.log(control.getAttribute('role'), control.tabIndex, control.textContent);

check('it announces itself as a button', control.getAttribute('role'), 'button');
check('Tab can reach it', control.tabIndex, 0);
check('and Enter works', control.textContent, 'pressed 1');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node rebuilt.jsx

button 0 pressed 1
ok   it announces itself as a button
ok   Tab can reach it
ok   and Enter works

Labels, and names for controls without text

A field needs a name that a screen reader can read out, and clicking the name should move to the field. A label does both when its htmlFor matches the field's id. A component may be used twice on one page, so a fixed id would repeat; generate it with useId (lesson 30). A button that holds only a symbol has no text to read, so give it an aria-label.

Labels with useId, and aria-label

ran at build time · in happy-dom 20.11.6 · React dev

labels.jsx

import { useId, act } from 'react';
import { createRoot } from 'react-dom/client';

function Field({ name }) {
  const id = useId();
  return (
    <p>
      <label htmlFor={id}>{name}</label>
      <input id={id} />
    </p>
  );
}

const root = createRoot(stage);
await act(async () =>
  root.render(
    <div>
      <Field name="Title" />
      <Field name="Author" />
      <button aria-label="Close">x</button>
    </div>,
  ),
);
const labels = Array.from(stage.querySelectorAll('label'));
const inputs = Array.from(stage.querySelectorAll('input'));
console.log(labels.map((l) => l.textContent + ' for ' + l.htmlFor));
console.log(stage.querySelector('button').getAttribute('aria-label'));

check('each label points at its own input', labels.every((l, i) => l.htmlFor === inputs[i].id), true);
check('the ids are not empty', inputs.every((input) => input.id.length > 0), true);
check('and they differ', inputs[0].id !== inputs[1].id, true);
check('the symbol button has a name', stage.querySelector('button').getAttribute('aria-label'), 'Close');
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node labels.jsx

[ 'Title for _r_0_', 'Author for _r_1_' ]
Close
ok   each label points at its own input
ok   the ids are not empty
ok   and they differ
ok   the symbol button has a name

Telling a screen reader that something changed

A sighted user sees a message appear. A screen reader user hears nothing unless the message is in a live region, an element marked aria-live (or given a role such as status, which implies it). The region must be in the page before its text changes. Moving the focus is the other tool: after an action that changes the page, such as a new view loading, put the focus on the heading, so the screen reader starts reading there. A heading is not focusable by default, so it gets tabIndex={-1}, which lets a script focus it without adding it to the Tab order.

A live region, and moving the focus

ran at build time · in happy-dom 20.11.6 · React dev

live.jsx

import { useRef, useState, act } from 'react';
import { createRoot } from 'react-dom/client';

function Saver() {
  const [message, setMessage] = useState('');
  const heading = useRef(null);
  return (
    <div>
      <h1 tabIndex={-1} ref={heading}>Settings</h1>
      <button
        onClick={() => {
          setMessage('Saved');
          heading.current.focus();
        }}
      >
        save
      </button>
      <p role="status" aria-live="polite">{message}</p>
    </div>
  );
}

const root = createRoot(stage);
await act(async () => root.render(<Saver />));
const region = stage.querySelector('p');
const before = region.textContent;
await act(async () => stage.querySelector('button').click());
console.log(JSON.stringify(before), JSON.stringify(region.textContent), region.getAttribute('aria-live'));
console.log(document.activeElement.tagName);

check('the region was there, and empty', before, '');
check('its text changed in place', region.textContent, 'Saved');
check('it is still a polite live region', region.getAttribute('aria-live'), 'polite');
check('the focus moved to the heading', document.activeElement === stage.querySelector('h1'), true);
await act(async () => root.unmount());

What node printed (React 19.2.7, development build), when the build ran it

node live.jsx

"" "Saved" polite
H1
ok   the region was there, and empty
ok   its text changed in place
ok   it is still a polite live region
ok   the focus moved to the heading

Passing these checks is not the same as being accessible. They read the markup and the focus. Whether the page is understandable with a screen reader, usable with a keyboard alone, and readable at the size and contrast a person needs can only be learned by using it that way. Test with a keyboard, with a screen reader, and, where you can, with the people who rely on them. A page can pass a contrast check and still be unusable.

  • Prefer the element that already does the job: button, not div onClick.
  • Connect a label to its field with htmlFor and an id from useId.
  • Give controls with no text an aria-label.
  • Announce changes with a live region that is in the page before it changes; move focus after navigation-like actions.
  • Checks on markup are a start; assistive-technology testing is required.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.