---
title: 'Unit and Component Tests'
source: 'https://academia.sh/en/courses/frontend-quality/unit-and-component-tests'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.

```js
// 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:

```sh
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.

```js
// 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.
