Ch. 22 · Software Testing

Parallel Test Isolation

Reproduce a shared temp file, a fixed port and module-level state breaking parallel node:test runs, then give each test its own resources.

~7 min readadvancedupdated Oct 19, 2026

A suite that takes eight minutes serially might take two with four workers. That speed-up is why every modern runner runs tests in parallel by default or with one flag. It’s also why a suite that was green for months starts failing “randomly” the week someone turns parallelism on, or adds a test file. The failures aren’t random. Two tests are sharing something they each assume they own.

This article reproduces the three most common kinds of sharing with Node’s built-in test runner, shows the real failures, and fixes each one.

How Node’s runner parallelises

When you run node --test, each test file runs in its own child process, and several files run at the same time. Inside a file, tests run one after another unless you opt in to concurrency with the concurrency option on test or describe.

That has a practical consequence. Two files can’t share a JavaScript variable, because they’re in different processes. But they share everything the operating system provides: the file system, network ports, environment-wide services like a local database or Redis. Inside a single file with concurrency on, tests also share module-level variables. Each kind of sharing produces a different failure.

Two files, one temp file, one port

Here are two test files written the way people write them when tests run one at a time. They’re identical except for the word orders or invoices:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { writeFile, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { startServer } from './server.js';

const exportPath = join(tmpdir(), 'export.csv');

test('orders export writes its own rows', async () => {
  await writeFile(exportPath, 'id\norders-1\n');
  await sleep(50); // stands in for the rest of the export job
  assert.equal(await readFile(exportPath, 'utf8'), 'id\norders-1\n');
});

test('orders API responds', async () => {
  const server = await startServer(3000);
  try {
    const res = await fetch('http://127.0.0.1:3000/orders');
    assert.equal(res.status, 200);
  } finally {
    server.close();
  }
});
JavaScript

startServer(port) creates a tiny HTTP server that answers every request with JSON and resolves once it’s listening. Run each file alone and both tests pass. Run both together on Node 22.23 with the default settings:

✔ invoices export writes its own rows (58.014166ms)
✔ invoices API responds (28.966042ms)
✖ orders export writes its own rows (59.707041ms)
✖ orders API responds (7.272042ms)

✖ orders export writes its own rows (59.707041ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  + actual - expected
  + 'id\ninvoices-1\n'
  - 'id\norders-1\n'

✖ orders API responds (7.272042ms)
  Error: listen EADDRINUSE: address already in use 127.0.0.1:3000
Text

Two different collisions, both through the operating system. The export tests wrote to the same path in the shared temp directory; the invoices write landed during the orders test’s pause, so orders read the other file’s content. The API tests both tried to listen on port 3000; whichever started second got EADDRINUSE. Run 20 times, this pair failed 20 times. In a real suite the timing is less tidy, so the same bug shows up as an occasional failure, which is far more confusing.

Add --test-concurrency=1 and all four pass. That’s useful for confirming the diagnosis: if tests pass serially and fail in parallel, they share something. It isn’t a fix. It throws away the speed and leaves the coupling in place for the next person who removes the flag.

Giving each test its own resources

The fix is to stop using fixed names for things that should be per test:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { startServer } from './server.js';

test('orders export writes its own rows', async (t) => {
  const dir = await mkdtemp(join(tmpdir(), 'orders-export-'));
  t.after(() => rm(dir, { recursive: true, force: true }));
  const exportPath = join(dir, 'export.csv');

  await writeFile(exportPath, 'id\norders-1\n');
  await sleep(50);
  assert.equal(await readFile(exportPath, 'utf8'), 'id\norders-1\n');
});

test('orders API responds', async (t) => {
  const server = await startServer(0);
  t.after(() => server.close());
  const { port } = server.address();

  const res = await fetch(`http://127.0.0.1:${port}/orders`);
  assert.equal(res.status, 200);
});
JavaScript

With the same change in the invoices file, both files pass together, and they passed 20 runs out of 20.

mkdtemp creates a directory with a random suffix, so no two tests can pick the same one, and t.after removes it even if the assertion fails. Listening on port 0 asks the operating system for any free port; server.address().port tells the test which one it got. The production code needed one small change to allow that: startServer takes the port as a parameter instead of hard-coding it. That’s a pattern worth noticing. Code that’s hard to isolate in tests usually has a hidden fixed name (a path, a port, a queue name, a database) that should be configuration.

The same idea applies to every shared resource:

  • Databases. Give each worker its own database or schema (for example, suffix the name with the worker’s ID), or run each test inside a transaction that’s rolled back at the end. Tests that insert a user named test@example.com into a shared database collide on the unique constraint.
  • Keys in shared stores. Prefix Redis keys or S3 object names with a per-test random ID, and clean up by prefix.
  • Global process state. Environment variables, the current working directory, process.exitCode and global fake timers are per process. They’re safe across files in Node’s runner but not across concurrent tests in one file, and not in runners that share a process across files.

Module state inside one file

Concurrency within a file is opt-in in Node, and it’s where module-level state bites. A shared cart array, reset before each test:

import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { setTimeout as sleep } from 'node:timers/promises';

const cart = [];

describe('cart', { concurrency: true }, () => {
  beforeEach(() => {
    cart.length = 0;
  });

  it('holds one item', async () => {
    cart.push('tee');
    await sleep(10);
    assert.equal(cart.length, 1);
  });

  it('holds two items', async () => {
    cart.push('mug', 'cap');
    await sleep(10);
    assert.equal(cart.length, 2);
  });
});
JavaScript
  ✖ holds one item (13.544208ms)
  ✖ holds two items (15.057416ms)

  3 !== 1
  3 !== 2
Text

Both beforeEach hooks ran, then both tests pushed into the same array, so each saw three items. The beforeEach gave a feeling of isolation without any: resetting shared state only works if nothing else touches it in the meantime. The fix is to create the state inside the test (const cart = [] in each it, or a makeCart() factory), so there’s nothing shared to reset. The same applies to singletons in application code, such as a module that exports one cache or one database client, which tests should be able to replace or construct per test.

Making hidden coupling visible

Sharing problems hide when tests always run in the same order on the same number of workers. A few habits flush them out early:

  • Run in a random order. Jest has --randomize and Vitest has --sequence.shuffle. Node’s runner gained --test-randomize in v26.1 (marked early development), so on Node 22 you’d shuffle file order yourself in CI. Log the seed so a failure can be replayed.
  • Run a new or changed test file many times in CI, in parallel with the rest of the suite. Twenty repeats catch most timing-dependent collisions.
  • Treat “passes alone, fails together” as a bug report about the tests, not as flakiness to retry. Retries hide the cause and let more coupling accumulate.
  • Look for fixed names in review: hard-coded ports, paths under /tmp, IDs like user-1, shared queue names.

Interview question to try

After a CI upgrade from two to eight cores, about one pipeline run in five fails with duplicate key value violates unique constraint "users_email_key" in different integration tests each time. The tests all run against one PostgreSQL database and each starts with DELETE FROM users. What’s happening and how do you fix it?

Answer

With more workers, more test files run at the same time against the same database. Each test cleans the users table and then inserts fixture users with fixed emails. When two files overlap, one inserts alice@example.com while the other’s fixture already holds it, or one file’s DELETE wipes rows another file is in the middle of using, so the failure moves between tests depending on timing. The DELETE at the start is the same trap as the beforeEach above: resetting shared state doesn’t isolate tests that run concurrently.

I’d give each worker its own database or schema, created from the migrations at the start of the run and named with the worker index, so files never share tables. Within a worker, tests run in a transaction rolled back afterwards, or clean up only rows they created. I’d also make fixture data unique (emails with a random suffix) so even accidental sharing doesn’t collide. Turning parallelism down to two would make the symptom rarer, but the tests would still be coupled, and the next infrastructure change would bring it back.

See also

More in Software Testing

read ✓Software Testing · hard

Testing Time with an Injected Clock

Pass a clock into time-dependent code, test expiry boundaries to the millisecond with node:test, and see why sleep-based tests flake.

~7 min readread →
read ✓Software Testing · mid

Flaky Tests and Root-Cause Evidence

Diagnose flaky tests with evidence: measure the failure rate, compare a test alone and in the suite, and replace sleeps that fail 23 runs in 100.

~7 min readread →
read ✓Software Testing · mid

Code Coverage and Its Limits

See a 100% covered function ship a boundary bug, read line and branch numbers from Node's coverage report, and set thresholds that help.

~6 min readread →
esc