Lesson 21 / 27
Unit and Component Tests
The boundary that separates a component test from a unit test, the split between behavior contract and implementation detail, and how the two test forms behave differently under the same refactor.
Contents
The security topic closed with a sequence of defense layers: output escaping, content security policy, origin restrictions, tokens against request forgery, security headers, and third-party script auditing. The performance topic left behind a budget, the accessibility topic a set of criteria. All of these decisions ask the same question: when what was built breaks, what will report it?
Manual review does not answer this question. Noticing that a defense was removed, a role was dropped, or a budget was exceeded requires a mechanism that sees that change. This topic builds that mechanism from two directions: tests that run before a change and monitoring that runs after a release. The topic’s first question is the narrowest one — how is a component’s correct behavior tested?
The Boundary Under Test
Test types are distinguished not by what they test, but by how far they extend.
A unit test tests a single function or module without touching the outside world. A pure function that applies a filter criterion to a measurement list falls into this category. The format set up in the Test lesson of the Node.js Runtime course applies unchanged here.
A component test sets up a component with its props, sends it a user event, and examines the rendered result. Its boundary is the component’s own tree: the network outside, the address bar, and storage lie beyond that boundary.
An integration test tests several components together, or a component together with the data layer. An end-to-end test, in turn, runs the application in a real browser; the third lesson covers it.
What distinguishes a component test is that both its input and its output are in the user’s vocabulary: the input is an event, the output is what appears on screen. The test does not need to look inside the component — and not looking is the actual subject of this lesson.
A Small Component Model
Seeing the logic of component testing does not require a framework. The following twenty-five lines give the essence of a component model: state, the tree produced from that state, and the event that advances the state.
// component-model.mjs — a small component model: state, render, event export function o(type, props = {}, ...children) { return { type, props, children: children.flat() }; } export function mount(component) { let state = component.init(); const instance = { state: () => state, tree: () => component.render(state), dispatch(event) { state = component.reduce(state, event); return instance; }, }; return instance; } export function* walk(node) { if (node === null || typeof node !== 'object') return; yield node; for (const child of node.children ?? []) yield* walk(child); } export function text(node) { if (typeof node === 'string') return node; return (node.children ?? []).map(text).join(' ').trim(); }
mount brings a component up for testing: it holds the state, calls the reducer when an
event arrives, and produces the current tree whenever it is asked for. The declarative
model from the Component-Based Interface Development course is at its plainest here;
actually writing the tree to the document is not necessary for testing.
The component under test is the measurement table of the North Slope interface: a filter field and a table showing the matching measurements.
// measurement-table.mjs — version 1 import { o } from './component-model.mjs'; const MEASUREMENTS = [ { station: 'north-slope-01', value: 21.4 }, { station: 'north-slope-02', value: 19.8 }, { station: 'east-ridge-01', value: 24.1 }, { station: 'valley-floor-03', value: 17.2 }, ]; export const MeasurementTable = { init: () => ({ filterText: '', measurements: MEASUREMENTS }), reduce(state, event) { if (event.type === 'filter-changed') return { ...state, filterText: event.value }; return state; }, render(state) { const visible = state.measurements.filter((x) => x.station.includes(state.filterText)); return o('section', { class: 'measurement-table' }, o('label', { for: 'filter' }, 'Station filter'), o('input', { id: 'filter', type: 'search', value: state.filterText }), o('table', { class: 'table' }, ...visible.map((x) => o('tr', { class: 'row' }, o('td', {}, x.station), o('td', {}, x.value.toFixed(1)))), ), o('p', { class: 'summary' }, `${visible.length} measurements`), ); }, };
Two Test Forms
The same component can be tested in two different ways. The first form looks inside the component: it depends on the state object’s field name and the order of children in the tree. The second form verifies only what is visible from outside.
// measurement-table.test.mjs import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { mount, walk, text } from './component-model.mjs'; import { MeasurementTable } from './measurement-table.mjs'; function rowTexts(tree) { return [...walk(tree)].filter((d) => d.type === 'tr').map(text); } describe('depends on implementation detail', () => { test('filter state holds the typed value', () => { const instance = mount(MeasurementTable); instance.dispatch({ type: 'filter-changed', value: 'north' }); assert.equal(instance.state().filterText, 'north'); }); test('second child is the input element', () => { const instance = mount(MeasurementTable); assert.equal(instance.tree().children[1].type, 'input'); }); }); describe('depends on behavior', () => { test('typing a filter leaves only matching stations', () => { const instance = mount(MeasurementTable); instance.dispatch({ type: 'filter-changed', value: 'north' }); assert.deepEqual(rowTexts(instance.tree()), [ 'north-slope-01 21.4', 'north-slope-02 19.8', ]); }); test('no match empties the table and the summary reads zero', () => { const instance = mount(MeasurementTable); instance.dispatch({ type: 'filter-changed', value: 'summit' }); assert.deepEqual(rowTexts(instance.tree()), []); assert.match(text(instance.tree()), /0 measurements/); }); });
The three files sit in the same directory, and the test runner is part of the runtime itself:
node --test measurement-table.test.mjs
▶ depends on implementation detail ✔ filter state holds the typed value (0.240208ms) ✔ second child is the input element (0.089667ms) ✔ depends on implementation detail (0.887ms) ▶ depends on behavior ✔ typing a filter leaves only matching stations (0.324792ms) ✔ no match empties the table and the summary reads zero (0.081ms) ✔ depends on behavior (0.462792ms) ℹ tests 4 ℹ suites 2 ℹ pass 4 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 32.395708
The duration fields are environment-dependent and change on every run; what is meaningful is the counters. All four pass. At this point, no difference is visible between the two forms.
The Refactoring Test
The difference shows up when the code changes. The version below makes two changes: the
state field’s name became query instead of filterText, and the filter field and its
label were wrapped in a container. The filtering logic moved into a separate function.
Nothing the user sees changed.
// measurement-table.mjs — version 2: field renamed, filter panel wrapped import { o } from './component-model.mjs'; const MEASUREMENTS = [ { station: 'north-slope-01', value: 21.4 }, { station: 'north-slope-02', value: 19.8 }, { station: 'east-ridge-01', value: 24.1 }, { station: 'valley-floor-03', value: 17.2 }, ]; function filter(measurements, query) { return measurements.filter((x) => x.station.includes(query)); } export const MeasurementTable = { init: () => ({ query: '', measurements: MEASUREMENTS }), reduce(state, event) { if (event.type === 'filter-changed') return { ...state, query: event.value }; return state; }, render(state) { const visible = filter(state.measurements, state.query); return o('section', { class: 'measurement-table' }, o('div', { class: 'filter-panel' }, o('label', { for: 'filter' }, 'Station filter'), o('input', { id: 'filter', type: 'search', value: state.query })), o('table', { class: 'table' }, ...visible.map((x) => o('tr', { class: 'row' }, o('td', {}, x.station), o('td', {}, x.value.toFixed(1)))), ), o('p', { class: 'summary' }, `${visible.length} measurements`), ); }, };
The test file is unchanged and rerun with the same command. Some counter lines and the stack-trace lines inside the failure bodies have been removed from the output below.
▶ depends on implementation detail ✖ filter state holds the typed value (0.918916ms) ✖ second child is the input element (0.426417ms) ✖ depends on implementation detail (1.713958ms) ▶ depends on behavior ✔ typing a filter leaves only matching stations (0.33275ms) ✔ no match empties the table and the summary reads zero (0.087541ms) ✔ depends on behavior (0.495792ms) ℹ tests 4 ℹ pass 2 ℹ fail 2 ✖ filter state holds the typed value AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + actual - expected + undefined - 'north' ✖ second child is the input element AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: 'table' !== 'input'
Two tests failed, and neither reports a real defect. What they report is that the code was refactored. This is the most expensive false signal a test can give: red even though the change is safe, so every refactor also brings test maintenance. After a while, the team gives up on refactoring instead of fixing the tests.
The two tests that depend on behavior stayed silent through the same change — because what they test had not changed. The distinction is this: a component’s behavior contract is what the user can observe. The text visible on screen, the roles in the accessibility tree, the external calls it triggers. The implementation detail is the path chosen to fulfill that contract: the state field’s name, the wrappers in the tree, how helper functions are split.
As a rule: a test should not depend on anything the person who wrote the component should be free to change.
Changing What Lies Beyond the Boundary
A component test also has places that open onto the outside: network requests, the clock, randomness, storage. These lie beyond the boundary under test, and a test double is put in their place. Distinguishing two forms is enough: a stub returns a fixed response, a mock additionally records the calls made to it, and the test verifies those records.
The method set up in the Test lesson of the Node.js Runtime course applies here too: the dependency is supplied from outside. If the component reads the clock as a prop instead of from within itself, the test supplies a fixed clock and the output becomes deterministic. The same rule works for the network — when the function that fetches the measurement list is supplied from outside, the component test never touches the network.
A mock has a cost: what it verifies is not the real dependency’s behavior, but an assumption about that behavior. If the assumption drifts, the test stays green while the application breaks. This is why a test’s value rises as the number of mocks falls; pushing the boundary as far out as possible is the reason integration tests exist.
Summary
- Test types are distinguished not by what they test but by the boundary they extend to; a component test’s boundary is the component’s own tree.
- A component test’s input is an event, its output is what appears on screen; both are in the user’s vocabulary.
- The behavior contract is what is observable; the implementation detail is the path that fulfills it. A test should depend only on the former.
- In a refactor that changed a field name and the tree structure, the two tests that depended on implementation detail failed and the two that depended on behavior passed; the failures did not report a real defect.
- Dependencies beyond the boundary, such as the network, the clock, and randomness, are supplied from outside; a stub returns a fixed response, a mock also records the calls.
Next Step
Writing a behavior-dependent test leaves one question open: how will the element under test be found inside the test? The helper function in this lesson searched by node type, which still depended on an implementation detail — the tree’s choice of tag. A selector based on a class name carries the same problem. Neither is how the user recognizes the element: the user finds the button because it is a button and because it reads “Save”. The next lesson turns these two pieces of information — the accessible role and the accessible name — into a query criterion.
To keep your progress and take notes, Log in
My notes
Log in to take notes.