Start with a small asynchronous contract

A promise-based function has two observable outcomes: it fulfills with a value or rejects with a reason. A useful test checks the outcome the caller actually receives, rather than merely checking that the function was called. The examples here use Node.js 20 or newer, ES modules, and the built-in node:test and node:assert/strict modules. No third-party packages are needed.

Create a file named promises.test.mjs. Begin it with these imports: import test from 'node:test'; import assert from 'node:assert/strict'; Then add this function to the same file: async function parsePort(input) { if (typeof input !== 'string') throw new TypeError('port must be text'); const port = Number(input); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new RangeError('invalid port'); return port; }

Although parsePort performs no network or timer work, the async keyword makes its return value a promise. A valid string fulfills with a number. Invalid input rejects because an error thrown inside an async function becomes a rejection. Keeping this example free of external services makes the behavior easy to reproduce and the failures easy to interpret.

Await the fulfilled result

Add a test for the successful path: test('parses a valid port', async () => { const result = await parsePort('8080'); assert.equal(result, 8080); }); Run the file with node --test promises.test.mjs. The async test callback lets the test runner wait for its returned promise. If parsePort rejects unexpectedly, the awaited expression throws and the test fails.

Awaiting matters even when a promise usually settles quickly. This test does not inspect the Promise object itself; it inspects the value delivered to callers. If the function’s contract includes a particular type or shape, assert that too. Keep assertions focused on meaningful behavior rather than implementation details that callers cannot observe.

Assert that invalid input rejects

For the failure path, use assert.rejects and await it: test('rejects invalid text', async () => { await assert.rejects(() => parsePort('oops'), RangeError); }); The test passes only if the supplied function produces a promise that rejects with a RangeError. If parsePort starts accepting 'oops', assert.rejects fails instead of silently passing.

You can also pass a promise directly: await assert.rejects(parsePort('oops'), RangeError); Passing a function is often convenient when you want the assertion to initiate the operation. In either form, awaiting assert.rejects is essential: it returns a promise representing the assertion’s completion. Without await or return, the test callback can finish before the rejection is checked.

Choose an error assertion that reflects the contract. For example, test('requires text input', async () => { await assert.rejects(() => parsePort(42), { name: 'TypeError', message: /port/ }); }); This checks both the error name and a relevant part of its message. Exact full-message matches can make tests unnecessarily fragile if wording is not part of the public contract.

Avoid false positives and unclear failures

A manual try/catch can accidentally pass when no rejection occurs: if the code only asserts inside catch, fulfillment leaves nothing to fail the test. assert.rejects handles that case for you. When you do need a manual catch, explicitly fail if the awaited operation fulfills, and take care not to catch that failure as though it came from the function under test.

Test the rejection reason, not just the presence of a rejection, when different errors would signal different bugs. For instance, a test intended to verify invalid input should not pass because an unrelated dependency crashed. Prefer rejecting with Error objects in your own APIs; although promises can reject with other values, consistent error types make failures easier to diagnose.

Finally, keep asynchronous tests independent. Avoid relying on which test runs first, and reset shared state when a test changes it. If a function uses a timer, network request, or database, arrange a controlled test dependency so the result is predictable. The core pattern stays the same: await the value for fulfillment, and await an explicit rejection assertion for failure.

Back to category ↗