Performant, Parallelizable, Framework Agnostic Node.js Integration Testing

Ethan Arrowood · Node.js Interactive @ Render ATL 2026

Testing is hard

"It works on my machine."

"That test's just flaky, re-run it."

"You can't run those locally."

"It only runs in CI."

"Don't touch it! It's finally green."

What am I testing?

Define "integration"

Unit

A single unit of code; everything else mocked/stubbed.

too little

Integration

The complete application. No hooking into internals.

just right

End-to-end

The full production stack. Like end users experience.

too much
Goldilocks and The Three Bears illustration
Illustration by Arthur Rackham, 1918, in English Fairy Tales by Flora Annie Steel for “The Story of the Three Bears”en.wikipedia.org/wiki/Goldilocks_and_the_Three_Bears

Application is a black box

Inputs you control
  • Requests & operations
  • Config, env, CLI flags
  • stdin & signals
Application internals off-limits
Outputs you observe
  • Responses & status
  • Logs, stdout/stderr
  • Files & exit codes

No mocking, no stubbing, no reaching inside. Assert on behavior, never on implementation.

Application = Process

Integration testing an application means managing the lifecycle of the application process

Harper is an application platform

Harper can run all sorts of applications

How do we…

isolate each test in its own process?

make the suite run everywhere?

test every kind of application?

do it all performantly?

Early decision: use node:test test runner API

When running multiple complex processes

how much parallelism is actually possible?

Measuring instead of guessing

node:test "concurrency" is overloaded

Test runner concurrency = file-level process parallelism

node --test \
  --test-concurrency=5 \
  "*.test.js"

Test suite concurrency = in-process async concurrency

suite('x', { concurrency: true }, () => {
  test('a', async () => { … });
  test('b', async () => { … });
});

Node's default concurrency is built for cheap tests

node --test --test-concurrency
os.availableParallelism() // 12 on my laptop

If each test process spawns an application process, we realistically only have half the available parallelism.

But computers are really good at doing multiple things at once.

The experiment

https://github.com/ethan-arrowood/node-test-runner-parallelization-analysis

The gain plateaus, then reverses

Total median suite duration (shorter is faster)

Concurrency Median duration (ms)
1 9,939 ms 2 5,776 3 4,582 4 3,904 5 3,759 6 3,597 7 3,468 ← best 8 3,671 9 3,601 10 3,534 11 3,724 12 3,952

All the data

20 test files · 25 samples per level · concurrency 1–12 · duration in ms

ConcurrencyMedianStd devp95Worst run
19,93936310,60710,885
25,7763076,1646,876
34,5823655,2525,729
43,9043684,6485,238
53,7593324,2644,739
63,5974594,7514,874
73,4682943,9904,520
83,6714724,5755,106
93,6014734,8045,177
103,5346545,3395,466
113,7246905,0965,717
123,9524884,7925,055

The median flattens after 7. The real story is in the tail — std dev, p95, worst run.

Past the plateau, variance more than doubles

ConcurrencyMedianStd devp95Worst run
19,93936310,60710,885
25,7763076,1646,876
34,5823655,2525,729
43,9043684,6485,238
53,7593324,2644,739
63,5974594,7514,874
73,4682943,9904,520
83,6714724,5755,106
93,6014734,8045,177
103,5346545,3395,466
113,7246905,0965,717
123,9524884,7925,055

Std dev jumps from 294 at the optimum to 654 and 690 just past it
The run-to-run spread more than doubles.

The real developer experience is in the tail

ConcurrencyMedianStd devp95Worst run
19,93936310,60710,885
25,7763076,1646,876
34,5823655,2525,729
43,9043684,6485,238
53,7593324,2644,739
63,5974594,7514,874
73,4682943,9904,520
83,6714724,5755,106
93,6014734,8045,177
103,5346545,3395,466
113,7246905,0965,717
123,9524884,7925,055

p95 at concurrency 10 (5,339) is worse than a typical run at 4 (3,904)
2.5× the processes for worse overall performance.

Calculated default

When each test spawns its own application process, the ideal concurrency is
half the available parallelism, plus one

concurrency = floor(availableParallelism / 2) + 1
            = floor(12 / 2) + 1
            = 7   // the measured optimum

This might not be perfect, but its a better starting point than max parallelism, and remains configurable

2. Networking contention

Everything wants port 9925

Dynamic ports would mean core changes

The easy fix: let the OS hand out a free port.

server.listen(0, () => {
  const { port } = server.address();   // the OS picked a free one
});

But two problems:

How can we solve this without modifying core?

Not a real fix: get-port

// what get-port does, roughly:
async function getPort() {
  const server = net.createServer();
  await listen(server, 0);          // OS assigns a free port
  const { port } = server.address();
  await close(server);              // ← the port is released here
  return port;
}

const port = await getPort();
const harper = await createHarper(port);   // ← nothing reserved it in between

Between the release and the reuse, the OS can hand that port to any other process — especially with many test processes racing at once.

If we can't reliably reserve the ports
what about the address?

Introducing loopback addresses

All of 127.0.0.0/8 (127.0.0.1 through 127.255.255.255) loops back to localhost

// every instance keeps its default ports
test/application 1 → http://127.0.0.2:9925
test/application 2 → http://127.0.0.3:9925
test/application 3 → http://127.0.0.4:9925

One caveat: only Linux enables by default

Platform127.0.0.1127.0.0.2-127.255.255.255
Linux (Ubuntu)Enabled by defaultEnabled by default
macOSEnabled by defaultMust be added per address
WindowsEnabled by defaultMust be added per address
# macOS: register each address as a loopback alias (once per machine)
sudo ifconfig lo0 alias 127.0.0.2 up
sudo ifconfig lo0 alias 127.0.0.3 up

How do we manage which process gets which address?

Loopback Address Pool: JSON file with an array of PIDs, shared by every test process.

[42, 43, null, 45, null, null]

index 0 → 127.0.0.2 (PID 42) · index 1 → 127.0.0.3 (PID 43) · …

Cross-process mutex to avoid conflicts

// 'wx' fails if the file already exists — atomically
const handle = await open(LOCK_PATH, 'wx');
await handle.close();   // we hold the lock

One of many options (daemon, orchestrator, atomics).

File lock was simplest. Plus a stale-lock timeout for crashed processes.

Crashed tests would leak addresses forever

// signal 0 checks liveness without signalling
try {
  process.kill(pid, 0);       // alive — leave it allocated
} catch {
  loopbackPool[index] = null;  // dead — reclaim it
}

A Ctrl+C mid-run doesn't break the pool

Allocated is not the same as usable

// bind to port 0 just to prove the address exists
server.listen(0, loopbackAddress, () => {
  server.close(() => resolve(loopbackAddress));
});

1. Verify the whole pool at startup (ensure setup script was run)

2. Verify again per allocation to avoid collisions

Still not immune to race condition with another app, but loopback allocation collides far less often than dynamic ports.

The API is shaping up

// ctx is a general options object

await startHarper(ctx);
// 1. allocate a loopback address from the pool
// 2. create a temporary install directory
// 3. spawn Harper, wait until it is actually ready
// 4. populate ctx.harper

ctx.harper.httpURL            // 'http://127.0.0.3:9926'
ctx.harper.operationsAPIURL   // 'http://127.0.0.3:9925'
ctx.harper.rootpath           // '/var/folders/js/abc123_def456/T/harper-test-789'

await teardownHarper(ctx);    // kill, release the address, remove the directory

3. Framework agnostic

The API imports nothing from a test runner

startHarper(ctx, options?)
setupHarperWithFixture(ctx, fixturePath, options?)
killHarper(ctx)
teardownHarper(ctx)
createHarperContext(name?)   // for non-node:test frameworks

ctx is any object with an optional name and a harper property to write to.

Deliberately loose, so a node:test context and a plain object both satisfy it.

Keep it flexible

// src/run.ts, line 1:
// Important! This script should not be required
// to execute integration tests.

https://github.com/HarperFast/integration-testing/blob/main/src/run.ts

The convenience runner (configures a smarter concurrency default) holds no state.

Every test file still runs under plain node --test, and parallelization still works.

Two runners, same lifecycle

node:test - file per process

suite('install', (ctx: ContextWithHarper) => {
  before(async () => {
    await startHarper(ctx);
  });
  after(async () => {
    await teardownHarper(ctx);
  });

  test('serves', async () => {
    const res = await fetch(ctx.harper.httpURL);
    strictEqual(res.status, 200);
  });
});

Playwright - worker-scoped fixture

const test = base.extend<
  {}, { harper: HarperContext }
>({
  harper: [makeHarperFixture(name),
           { scope: 'worker' }],
});

test('home page renders',
  async ({ page, harper }) => {
    await page.goto(harper.httpURL);
    await expect(page.locator('h1'))
      .toHaveText('Next.js v16');
  });

Playwright integration

export function makeHarperFixture(fixtureName: string) {
  return async ({}, use) => {
    const ctx = createHarperContext(fixtureName);
    const started = await setupHarperWithFixture(ctx, fixturePath, {
      harperBinPath: getHarperBinPath(),
      startupTimeoutMs: 120_000,   // a Next.js build is slow
    });

    await use(started.harper);

    await teardownHarper(started);
  };
}

https://github.com/HarperFast/nextjs/blob/main/integrationTests/fixtures.ts

Discovering core testing values for high quality tests

4. Surviving CI

How do you think a M3 Macbook compares to a default GitHub CI runner?

One default runner can't run all the tests

Parallelize across runners: a matrix that shards the suite.

# .github/workflows/integration-tests.yml
strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: node --test --test-shard=${{ matrix.shard }}/4

--test-shard=1/3 always runs the same group of tests, on any machine
https://github.com/HarperFast/harper/blob/main/.github/workflows/integration-tests.yml

Ethan Arrowood on a ski slope

Thank you

Ethan Arrowood · Head of Open Source Engineering, Harper · ethanarrowood.com

Contribute to Node.js!

Everyone of any coding ability is welcome.

  • (Tomorrow) Aug 13 · 2:00–5:00 PM
  • Join renderatl.com/discord#nodejs-interactive-code-and-learn
  • Complete the mandatory prerequisite steps beforehand!
RenderATL rocket-turtle mascot riding a Node.js and OpenJS Foundation rocket