A test checks a claim about your code, and to do that it has to own two things: the asynchronous work it starts, and the state it touches. When it doesn’t own the work, the test finishes before the thing it was checking has happened, and you get passes that mean nothing. When it doesn’t own the state, the result depends on which tests ran before it, and you get failures that disappear when you rerun one test on its own.
Both problems are behind most “flaky test” complaints, and interviewers at the advanced level like to show you a test and ask why it’s green. All examples below use Node 22’s built-in node:test runner and were run with node --test.
The test that passes without checking anything
Here’s a function that charges a card and throws on a decline, plus a test that gives it a gateway which approves, so the assertion should fail:
export async function chargeCard(gateway, amount) {
if (amount <= 0) throw new RangeError('amount must be positive');
const res = await gateway.charge(amount);
if (res.status === 'declined') throw new Error(`card declined: ${res.reason}`);
return res.id;
}import { test } from 'node:test';
import assert from 'node:assert/strict';
const approving = { charge: async () => ({ status: 'ok', id: 'ch_1' }) };
test('declined card rejects (assert.rejects not awaited)', () => {
assert.rejects(chargeCard(approving, 50), /declined/);
});
test('declined card rejects (assertion inside .then, not returned)', () => {
chargeCard(approving, 50).then(() => assert.fail('expected rejection'), () => {});
});
test('declined card rejects (awaited)', async () => {
await assert.rejects(chargeCard(approving, 50), /declined/);
});With the spec reporter:
✔ declined card rejects (assert.rejects not awaited) (0.615041ms)
✔ declined card rejects (assertion inside .then, not returned) (0.116416ms)
✖ declined card rejects (awaited) (0.147667ms)
ℹ Error: Test "declined card rejects (assert.rejects not awaited)" at unawaited.test.mjs:8:1 generated asynchronous activity after the test ended. This activity created the error "AssertionError [ERR_ASSERTION]: Missing expected rejection." and would have caused the test to fail, but instead triggered an unhandledRejection event.assert.rejects returns a promise. It can’t throw synchronously, because it doesn’t know the answer yet. The first test function returns undefined, so the runner marks it passed after 0.6 ms, and the assertion fails later, into nothing. The awaited test fails with Missing expected rejection., which is the correct result. The second test has the same shape with a .then that nobody returns. Only the awaited version actually tests the code.
To its credit, node:test notices the late failure: the run exits with code 1 and prints that “asynchronous activity after the test ended” line. But the two tests are still reported with a tick, the failure is attached to the file, and a reader skimming a CI log sees green next to the test name. Other runners and older setups may only print an unhandled rejection warning, or nothing. The rule doesn’t depend on the runner: return or await every promise the test starts. A lint rule such as @typescript-eslint/no-floating-promises catches most of these mechanically.
State shared between tests in a file
node --test runs each test file in its own child process by default, so files don’t share memory. Tests within a file do. Here’s an HTTP app with an in-memory store, and a test file that sets it up once at the top:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from './app.mjs';
const store = { items: [] }; // shared by every test in the file
const server = createApp(store).listen(0);
const url = () => `http://localhost:${server.address().port}`;
test('adds an item', async () => {
const res = await fetch(`${url()}/apple`, { method: 'POST' });
assert.equal(res.status, 201);
});
test('starts empty', async () => {
const items = await (await fetch(url())).json();
assert.deepEqual(items, []);
});Run with a timeout so it can’t hang forever:
$ node --test --test-reporter=spec --test-timeout=8000 leaky.test.mjs
✔ adds an item (29.941125ms)
✖ starts empty (5.507208ms)
✖ leaky.test.mjs (8008.53175ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ [
+ 'apple'
+ ]
- []
'test timed out after 8000ms'Two separate bugs. “starts empty” fails because the first test left an apple in the shared store. Run it alone with --test-name-pattern="starts empty" and it passes, which is the signature of an order-dependent test: it fails in CI and passes on the developer’s machine when they run just that one.
The second bug is the timeout on the file itself. The server was never closed, so the child process has a listening socket keeping its event loop alive, and it never exits. The default --test-timeout is infinity, so without the flag this file simply hangs the CI job.
The fix is to give each test its own world and tear it down whether the test passes or fails:
import { test, beforeEach, afterEach } from 'node:test';
import { once } from 'node:events';
let server, url;
beforeEach(async () => {
server = createApp({ items: [] }).listen(0); // fresh state and port per test
await once(server, 'listening');
url = `http://localhost:${server.address().port}`;
});
afterEach(async () => {
server.closeAllConnections(); // drop sockets a test left mid-response
await new Promise((resolve) => server.close(resolve));
});✔ adds an item (27.839834ms)
✔ starts empty (6.615833ms)
ℹ tests 2
ℹ pass 2
ℹ duration_ms 99.188542Three details matter. listen(0) asks the OS for a free port, so tests (and parallel CI jobs) don’t fight over port 3000 with EADDRINUSE. Awaiting 'listening' removes a race between starting the server and the first request. And afterEach runs even when the test’s assertion throws, which a server.close() at the end of the test body would not. For cleanup that belongs to a single test, t.after(() => ...) inside the test does the same job.
Replace sleeps with signals you control
Timing guesses are the other big source of flakiness. Suppose a loader deduplicates concurrent requests for the same user, and must not cache a failure:
export function createLoader(fetchUser) {
const inFlight = new Map();
return function load(id) {
if (inFlight.has(id)) return inFlight.get(id);
const p = fetchUser(id).finally(() => inFlight.delete(id));
inFlight.set(id, p);
return p;
};
}The tempting test uses a fake fetchUser that resolves after setTimeout(..., 50) and hopes both calls arrive inside those 50 ms. Instead, hand the test the promise’s controls. Promise.withResolvers() (Node 22) returns { promise, resolve, reject }:
test('concurrent loads share one request, and a failure is not cached', async () => {
const pending = [];
const fetchUser = mock.fn(() => {
const d = Promise.withResolvers();
pending.push(d);
return d.promise;
});
const load = createLoader(fetchUser);
const a = load(7);
const b = load(7);
assert.equal(fetchUser.mock.callCount(), 1);
pending[0].reject(new Error('upstream 503')); // the test decides when, not a timer
await assert.rejects(a, /upstream 503/);
await assert.rejects(b, /upstream 503/);
const c = load(7); // retry after failure must refetch
assert.equal(fetchUser.mock.callCount(), 2);
pending[1].resolve({ id: 7, name: 'Ada' });
assert.deepEqual(await c, { id: 7, name: 'Ada' });
});✔ concurrent loads share one request, and a failure is not cached (2.047458ms)Two milliseconds, and no ordering is left to chance. Every state the loader can be in is something the test creates on purpose: two callers waiting, a rejection, a retry.
Code that uses timers on purpose is the other case. A withTimeout(promise, 2000) helper tested against the real clock:
✔ slow dependency times out (real clock) (2005.465583ms)
✔ slow dependency times out (mock clock) (1.5025ms)
ℹ duration_ms 5052.99025The real-clock test took two seconds, and the whole file took five. The test used sleep(5000) as its slow dependency, and that timer kept running after the test finished, holding the process open until it fired. That’s a small version of the same ownership problem as the unclosed server. The mock-clock version enables fake timers with t.mock.timers.enable({ apis: ['setTimeout'] }), advances them with t.mock.timers.tick(1999) and tick(1), and finishes in 1.5 ms. Using t.mock rather than the global mock also means the fake timers are restored when that test ends, so they can’t leak into the next one.
Interview practice
A test suite passes locally but fails about one run in ten in CI. The failing test is 'returns cached profile', and when it fails, the assertion shows a profile with a different name than expected. Looking at the file, you see a module-level const cache = new Map() imported from the service, a test above it named 'refreshes profile after update' that calls service.updateProfile(...) without await, and CI runs with --test-concurrency greater than one. How do you reason about this?
Answer
The different name suggests the cached profile was written by another test, so I’d look for shared state and work that outlives its test. Both are present. The cache is module state shared by every test in the file. The refresh test starts updateProfile without awaiting it, so the test can finish while the update is still in flight, and the update’s write to the cache lands during the next test. Whether it lands before or after the next test’s assertion depends on timing, which explains one failure in ten. Concurrency across files isn’t the cause, since each file runs in its own process; within the file, tests run in order, so the late write is the cross-test channel.
I’d fix both causes rather than just adding await. Await updateProfile so the refresh test owns its work (and actually verifies the refresh). Then remove the shared cache: either have the service take the cache as a dependency so each test passes a fresh Map, or clear it in beforeEach. To confirm the diagnosis, I’d make the problem deterministic: put the update behind a deferred, resolve it after the cached-profile test starts, and watch that test fail every time before the fix and pass after.