Node.js / Intermediate: application boundaries / Lesson 9
Testing behavior with core tools
Choose useful assertions, isolate side effects, and test asynchronous failure paths without external services.
On this page
Lesson goals
- Explain the concepts and trade-offs behind testing behavior with core tools.
- Predict and verify both examples, including their outputs and cleanup behavior.
- Diagnose the three common failures and apply the worked solutions to related inputs.
Test a public behavior
A useful test states what should happen for a meaningful input rather than repeating the implementation line by line. Start with a pure business rule, cover normal cases, boundaries, and rejected inputs, then add integration tests where components meet. An assertion checks an observable contract. A test that only verifies that a private helper was called can fail after harmless refactoring while missing an incorrect result. Name tests after the behavior they protect so a failure tells the next developer what requirement was violated.
Use assertions precisely
node:assert/strict offers equal, deepEqual, throws, and rejects for different contracts. Compare structured data structurally, and test stable error names or codes rather than an entire stack trace. The first example checks an empty cart, a real total, and an invalid count. It prints its summary only after all assertions pass. That output is a simple demonstration; a project normally uses node:test to group cases and report failures through the process status. Keep expected values independently calculated so the test can expose a mistake in the implementation.
Wait for the tested operation
An asynchronous test must return or await the promise whose outcome matters. Otherwise the test can finish before the assertion or failure occurs. assert.rejects expects an asynchronous rejection; assert.throws covers synchronous throwing. The second example checks both a fulfilled promise and a typed rejection. Avoid replacing await with an arbitrary sleep: sleeping makes a test slower and still does not establish that the correct work finished. Use an explicit event, returned promise, or controlled dependency. Set a deadline for operations that could otherwise hang indefinitely.
import { run } from 'node:test';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const dir = await mkdtemp(join(tmpdir(), 'test-'));
try {
const file = join(dir, 'cart.test.mjs');
await writeFile(file, `
import test from 'node:test';
import assert from 'node:assert/strict';
function total(lines) {
return lines.reduce((sum, line) => {
if (!Number.isInteger(line.count) || line.count < 0) throw new RangeError('Invalid count');
return sum + line.price * line.count;
}, 0);
}
test('empty cart', () => assert.equal(total([]), 0));
test('valid total', () => assert.equal(total([{ price: 3, count: 2 }, { price: 4, count: 1 }]), 10));
test('negative count', () => assert.throws(() => total([{ price: 3, count: -1 }]), RangeError));
`);
let passed = 0;
let failed = 0;
for await (const event of run({ files: [file] })) {
if (event.type === 'test:pass') passed++;
if (event.type === 'test:fail') failed++;
}
if (failed || passed !== 3) throw new Error('Test suite failed');
console.log(`${passed} tests passed`);
} finally { await rm(dir, { recursive: true, force: true }); }
3 tests passed
The program creates an isolated test fixture and uses node:test to run three named behavioral cases. It observes the runner events, prints a summary only if all three pass, and removes the temporary files in finally.
Own fixtures and cleanup
Temporary directories and ephemeral ports let tests run without competing for shared files or a fixed listener. Create resources inside the test and register cleanup immediately, using finally or a test cleanup hook. Never use real credentials or production data. Inject a clock, repository, or upstream client when its behavior must be controlled, but keep some integration coverage for the real boundary. Too many mocks can verify only a fictional system. A small real HTTP request to a local self-closing server often catches wiring mistakes that a mocked response object cannot.
import assert from 'node:assert/strict';
async function lookup(id) {
if (id !== 1) { const error = new Error('Missing'); error.code = 'NOT_FOUND'; throw error; }
return { id: 1, name: 'Ada' };
}
assert.deepEqual(await lookup(1), { id: 1, name: 'Ada' });
await assert.rejects(() => lookup(2), { code: 'NOT_FOUND' });
console.log('async success and failure passed');
async success and failure passed
The test awaits the rejection assertion, so it cannot report completion before the failure path is examined.
Run a useful test suite
Save a test file with a .test.mjs suffix, import test from node:test, and register named cases containing the same assertions. Run node --test to discover and execute them under your pinned runtime. The exact report includes timing and can vary, so assert behavior rather than copying report text. Coverage highlights code that did not execute; it does not prove the assertions were meaningful. Add regression tests for bugs with a concrete reproduction, and keep tests deterministic enough that a failure deserves investigation instead of an automatic rerun.
Common errors and fixes
A test passes before failure occurs
- Why it happens
- The test launched asynchronous work without awaiting it.
- How to fix it
- Return or await the operation and its assertions.
- Prevent it next time
- Review async test callbacks for detached promises.
Tests fail only when run together
- Why it happens
- They share mutable state, files, ports, or environment changes.
- How to fix it
- Create isolated fixtures and restore any deliberate mutation.
- Prevent it next time
- Design resource ownership before enabling concurrency.
Coverage is high but bugs escape
- Why it happens
- Assertions do not distinguish correct behavior from wrong results.
- How to fix it
- Assert concrete outcomes and boundary cases.
- Prevent it next time
- Ask whether the test would fail for a plausible regression.
Check your understanding
1. Select an assertion for a function that returns a rejected promise with code DENIED.
Show worked solution
Use await assert.rejects(() => operation(), { code: "DENIED" }). assert.throws would inspect only synchronous throwing. Awaiting the assertion ensures the rejection is observed and makes a missing rejection fail the test rather than disappear after completion.
2. A test opens a local server and an assertion fails. How does the suite avoid hanging?
Show worked solution
Start the server on port zero, capture its actual address, and register closing in finally or the test cleanup hook before asserting. Cleanup must execute on failure as well as success. Add a bounded client timeout so an incomplete response is also reported predictably.
Key takeaways
- A useful test states what should happen for a meaningful input rather than repeating the implementation line by line.
- node:assert/strict offers equal, deepEqual, throws, and rejects for different contracts.
- Save a test file with a .test.mjs suffix, import test from node:test, and register named cases containing the same assertions.