Unit Testing: Observable Contracts and Fast Feedback
A unit test gives a small piece of behavior a controlled input and checks an observable outcome.
- Author
- absequ
- Published
- Updated
- Category
- Testing
- unit testing
- testing
- practical guide
A unit test gives a small piece of behavior a controlled input and checks an observable outcome. The useful boundary may be a pricing function, a validator or a repository policy. It is not automatically one class or one file. Strong tests make failures informative and refactoring safe without reproducing the implementation line by line.
On this page
The mental model
Separate the value being computed from the environment needed to obtain it. A pure discount function can be tested with ordinary values, while a payment adapter needs a controlled substitute for the external provider. An integration test then checks the adapter's real contract in a suitable test environment. Neither layer makes the other unnecessary.
Arrange the inputs, perform one meaningful action and assert its consequence. Include boundaries where the rule changes, such as zero quantity or an exceeded limit. Assert an error category or public result instead of an incidental internal variable. Test names should explain the business or technical condition that failed.
A practical workflow
Keep time, randomness, filesystem paths and network dependencies explicit. When tests need temporary files, create isolated directories and clean up only those files. Avoid connecting a test runner to production credentials. A test that happens to pass because a developer's machine already contains a file is not reproducible.
Choose fixtures that reveal the rule rather than overwhelming the reader. If a validator promises to reject unknown keys, test a real unknown key through the exported validator. If a repository denies drafts, ask that repository for a draft. A test-only reimplementation can agree with itself while the production path remains broken.
Worked example
// price.test.mjs — run with node --test price.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';
function totalCents(unitCents, quantity) {
if (!Number.isInteger(quantity) || quantity < 0) {
throw new RangeError('quantity');
}
return unitCents * quantity;
}
test('zero quantity has zero total', () => {
assert.equal(totalCents(125, 0), 0);
});
test('negative quantity is rejected', () => {
assert.throws(() => totalCents(125, -1), RangeError);
});This runnable example uses Node's built-in test runner and integer minor currency units to avoid distracting floating-point arithmetic. It is still deliberately incomplete: production pricing needs rules for currency, bounds, rounding and taxes. Add tests from the actual specification before expanding the implementation. Passing two examples is not evidence that all financial requirements have been met.
For UI code, test accessible names, visible results and user actions. Use browser tests for layout, focus behavior and hydration because a string renderer cannot prove element geometry. Keep fast unit checks plentiful and browser checks focused on risks that require a real browser.
Failure modes and security
Code coverage measures execution, not the quality of assertions. A line can run without its important result being checked. Conversely, excessive mocks can make a test brittle by tying it to an internal call sequence. Prefer a small stable contract and add a regression whenever a real defect reveals a missing case.
Do not make asynchronous tests pass by adding arbitrary delays. Wait for an observable state or a controlled promise. If a test intentionally cannot run, report it as skipped or blocked rather than passed. Keep test logs free of tokens, personal records and full email payloads.
Practice and verification
Take one function from a project and write its contract in plain language before reading the implementation. Add normal, boundary and invalid-input cases. Change the implementation while preserving that contract and check whether the tests remain meaningful. Run examples in the project's Node version; this guide targets Node 24, not an unpinned latest runtime. Keep the test command documented so another contributor can repeat the result.
References
Node 24 test runner — Node.js