Start with the behavior a caller can observe

An assertion is most valuable when its failure answers a useful question: what promise did this code break? Begin by describing the input and the result a caller should receive. For a function, that might be a returned value or a thrown error. For an interface, it might be the text a user can read or the state of a control after an action. Internal helper calls, private variables and the order of intermediate calculations usually do not belong in that promise.

Consider a cart summary. A shopper needs the item count and total price; they do not care whether the implementation uses a loop or reduce. A test that checks those two outputs can survive either implementation. A test that expects a particular helper to be called twice may fail during a harmless refactor while missing an incorrect total. Before adding an assertion, ask: would a user or another module notice if this expectation stopped being true?

Make the input and expected result easy to inspect

The following example runs as an ES module in Node.js 20 or later, using the built-in node:test and node:assert/strict modules. Save it as cart.test.mjs and run node --test cart.test.mjs. The function is included so the example can run on its own; in a project, a test would normally import the function it exercises.

import test from 'node:test'; import assert from 'node:assert/strict'; function summarizeCart(items) { return { itemCount: items.reduce((count, item) => count + item.quantity, 0), totalCents: items.reduce((total, item) => total + item.quantity * item.priceCents, 0) }; } test('counts quantities and sums prices in cents', () => { const items = [{ quantity: 2, priceCents: 350 }, { quantity: 1, priceCents: 200 }]; assert.deepEqual(summarizeCart(items), { itemCount: 3, totalCents: 900 }); });

The test name identifies the behavior, the input has only the fields needed to demonstrate it, and the expected object shows both parts of the contract. If the result differs, the assertion displays the expected and actual values. Keeping prices in integer cents also avoids turning this particular test into a test of decimal rounding. If rounding is part of the real contract, give it a separate, explicitly named case.

Assert the right amount of detail

Checking too little can make a test pass when behavior is wrong. For example, assert.ok(summary) only shows that some truthy value came back; it says nothing about the count or total. Checking too much creates a different problem. If a UI test compares an entire rendered page to a fixed string, a harmless wrapper element or copy edit may obscure the behavior the test was meant to protect.

Choose the smallest observation that proves the requirement. For the cart function, a deep comparison is reasonable if its returned object is a defined API. If the API permits additional fields, compare the required fields individually instead. In a browser test, prefer a control's accessible name, visible message or resulting value over a generated CSS class or component-internal state. Assert exact wording only when that wording itself matters; otherwise, check the meaningful state and leave room for editorial changes.

Use test names and failures as debugging clues

A good test name states a condition and an outcome, such as 'an empty cart has zero items and zero total' or 'changing quantity updates the displayed total.' Names like 'works correctly' force the next person to reconstruct the intent from the setup. Keep each test focused enough that a failure points toward one behavior, but avoid splitting every field comparison into a separate test with repeated, complicated setup.

When an assertion library already prints a useful difference, an extra message may add little. A custom message helps when the expected value alone lacks context, such as which input case failed in a table of cases. Include the case or user action rather than restating 'expected true.' For asynchronous behavior, wait for the operation before asserting its result; otherwise, the test may finish before the behavior you meant to check occurs.

Check that the test permits a safe refactor

A practical review is to imagine replacing the implementation while preserving its public behavior. Could you change reduce to a loop, extract a helper or reorganize a component without rewriting the test? If not, determine whether the asserted detail is truly part of the contract. Some details are: an exported function's return shape or a button's accessible name may be relied on by callers or users. Others, such as a private method's call count, often are not.

Finally, make sure the test can fail for the bug it claims to catch. Temporarily consider an incorrect total, a missing item or an unchanged interface after a click. Which assertion would expose it, and would the failure make sense to someone seeing the test for the first time? That question keeps assertions both diagnostic and resilient: specific about outcomes, flexible about how those outcomes are produced.

Back to category ↗