Start with an observable behavior

A component test is most useful when it describes an action and its visible result: “When someone activates Add one, the displayed count increases.” That statement leaves room to change how the component stores its count or updates the DOM. A test that inspects a private variable instead may pass even when the page shows the wrong value.

The example below runs in a modern browser’s developer console on a page where you can modify the document. It uses the browser’s DOM APIs and needs no library, build step, or test runner. Paste the component code first, then the test code. If you run it again, the test removes its own elements, so it will not leave duplicate counters on the page.

Build a small component with a clear contract

This counter starts at zero. Its button has a visible label, and its output is ordinary text that can be read from the page. The mount function returns the component’s root element so each test can work with its own instance.

function mountCounter() { let count = 0; const root = document.createElement('div'); const button = document.createElement('button'); const output = document.createElement('p'); button.type = 'button'; button.textContent = 'Add one'; output.textContent = 'Count: 0'; button.addEventListener('click', () => { count += 1; output.textContent = `Count: ${count}`; }); root.append(button, output); document.body.append(root); return root; }

The contract is intentionally small: the initial text is “Count: 0,” and each activation of the button adds one. The tests do not need access to count. They can find the control and read the output within the returned root, just as the component’s visible behavior suggests.

Act, inspect, and clean up

The following checks create a fresh counter, verify its initial state, click its button twice, and inspect the text after each action. The helper throws an error when an expectation fails. The finally block removes the fixture even if a check fails.

function expectText(element, expected) { if (element.textContent !== expected) { throw new Error(`Expected "${expected}", got "${element.textContent}"`); } } function testCounter() { const root = mountCounter(); try { const button = root.querySelector('button'); const output = root.querySelector('p'); if (!button || !output) throw new Error('Counter controls are missing'); expectText(button, 'Add one'); expectText(output, 'Count: 0'); button.click(); expectText(output, 'Count: 1'); button.click(); expectText(output, 'Count: 2'); console.log('Counter test passed'); } finally { root.remove(); } } testCounter();

This test checks an initial state and a sequence of actions, rather than only the final result. That makes a failure easier to locate: if the first click shows the wrong text, the test stops there. Keeping queries inside root also prevents another button or paragraph elsewhere on the page from being mistaken for part of the counter.

Know what the test proves—and what it does not

Calling button.click() exercises the click handler and verifies the resulting DOM text. It does not prove that a person can find the button, reach it with a keyboard, or understand the output. For this example, make a separate manual check: reload the page with a mounted counter, use Tab to reach the button, activate it with the keyboard, and confirm that its label and updated count make sense. The native button element provides useful keyboard behavior, but the console test does not exercise that whole experience.

As components grow, keep the same action-and-result pattern. For a form, enter values and check the displayed error or confirmation. For a disclosure control, activate it and check whether its content is shown. If an update is asynchronous, wait for the promised result or a specific DOM condition before asserting; an arbitrary delay can make tests unreliable. Create a fresh fixture for each case, including error and repeat-action cases, so one test cannot silently depend on another.

Choose assertions that reflect the component’s intended interface, not incidental details such as the exact number of wrapper elements. Rendered text, control labels, and meaningful states are usually more helpful than private variables. A focused test cannot replace broader browser and accessibility checks, but it gives you a repeatable answer to a valuable question: after this user action, did the interface show the expected result?

Back to category ↗