Module: Nodejs
Nodejs·130·5 MIN READ

130: Testing Node Applications — `node:test`, Assertions, Mocks, HTTP Integration, Vitest/Jest, and E2E Boundaries

TOPICS COVERED: Testing Node Applications — `node:test`, Assertions, Mocks, HTTP Integration, Vitest/Jest, and E2E Boundaries

Learning objectives

You will learn to:

  • write tests using Node's built-in test runner;
  • use strict assertions;
  • structure unit, integration, contract, and end-to-end tests;
  • test async code;
  • isolate filesystem/time/environment dependencies;
  • test HTTP applications;
  • understand mocking and fake-timer trade-offs;
  • test shutdown and process behavior;
  • understand when Vitest/Jest are alternatives;
  • integrate Playwright/Cypress at browser-system level;
  • avoid brittle implementation-detail tests.

Test layers

text
pure/unit
component/module integration
HTTP/API integration
database integration
contract
end-to-end

Choose cheapest layer that proves the risk.

node:test

js
import test from 'node:test';
import assert from 'node:assert/strict';

test('normalize title', () => {
  assert.equal(
    normalizeTitle('  Learn Node  '),
    'Learn Node',
  );
});

Run:

bash
node --test

The built-in test runner is capable enough for many Node projects.

Roadmap also lists Jest/Vitest; ecosystem choice depends on full-stack tooling/team.

Nested suites

js
import { describe, it } from 'node:test';

describe('task service', () => {
  it('creates a task', async () => {
    ...
  });
});

Use descriptive behavior names.

Async test

js
test('loads task', async () => {
  const task = await service.get('t1');

  assert.equal(task.id, 't1');
});

If Promise rejects unexpectedly, test fails.

Rejection assertion

js
await assert.rejects(
  () => service.get('missing'),
  (error) => {
    assert.equal(error.code, 'NOT_FOUND');
    return true;
  },
);

Assert stable code/type, not entire stack text.

Pure unit tests

Repository-independent service:

js
const repository = {
  async findById(id) {
    return id === 't1'
      ? { id: 't1', title: 'A' }
      : null;
  },
};

const service = createTaskService({
  repository,
});

This is dependency injection, not necessarily mocking framework magic.

Test doubles

Stub

Returns controlled values.

Spy

Records calls.

Mock

Often means expectation-based fake/test double; terminology varies.

Fake

Working lightweight implementation, such as in-memory repository.

Prefer simplest double.

Do not mock everything.

Built-in mock utilities

Modern node:test provides mocking capabilities.

Example concept:

js
const send = mock.fn(async () => ({ ok: true }));

Check exact API for supported Node release.

Use when call interaction itself matters.

Do not assert private function calls instead of behavior.

Filesystem tests

Use a temporary directory:

js
import {
  mkdtemp,
  rm,
  writeFile,
} from 'node:fs/promises';

import os from 'node:os';
import path from 'node:path';

const dir = await mkdtemp(
  path.join(os.tmpdir(), 'task-test-'),
);

try {
  ...
} finally {
  await rm(dir, {
    recursive: true,
    force: true,
  });
}

Never use production path/database in tests.

Environment variables

Bad test:

js
process.env.PORT = '9999';
// forget to restore

leaks into later tests.

Save/restore or pass config explicitly.

Better application architecture:

js
createApp({
  config: {
    port: 0,
  },
});

rather than deep modules reading process.env repeatedly.

Time

Functions depending on current time should allow injected clock:

js
function createToken(clock = () => new Date()) {
  ...
}

Test:

js
const fixed = () => new Date('2026-01-01T00:00:00Z');

Fake timers can help for timer behavior, but restore them.

Randomness/IDs

Inject ID generator where deterministic test matters:

js
createTaskService({
  idGenerator: () => 't1',
});

Do not mock crypto globally unless required.

For integration tests, random IDs are fine if assertions do not depend on exact value.

HTTP server test

Native:

js
const server = app.listen(0);

await once(server, 'listening');

const address = server.address();

const response = await fetch(
  `http://127.0.0.1:${address.port}/health`,
);

Port 0 lets OS choose free port.

Always close server.

Express testing

Libraries such as Supertest are commonly used to exercise Express app without managing a fixed external port.

Goal:

text
real Express routing/middleware
real serialization
controlled dependencies

Do not unit-test every res.status() call if an HTTP integration test can prove contract.

API test matrix

GET /tasks/:id:

text
valid id → 200
missing → 404
wrong tenant → 404/403 policy
repository unavailable → 500
auth missing → 401
malformed auth → 401

POST:

text
valid → 201
bad JSON → 400
wrong media type → 415
validation → 422
too large → 413
duplicate idempotency → stable result

Contract tests

Contract specifies:

text
method
path
request
status
body
headers
errors

Use OpenAPI/schema if project benefits.

Consumer/provider tests prevent frontend/backend mocks drifting.

Database integration tests

When Mongo arrives, test against:

  • disposable test database;
  • container/local ephemeral instance;
  • dedicated database;
  • transaction/cleanup strategy.

Do not mock database driver for every repository test; then you never prove queries/index assumptions.

Unit-test mapping logic separately, integration-test real database behavior.

Test isolation

Parallel tests can collide if they share:

text
same port
same DB names
same files
same env
same singleton

Use unique resources and cleanup.

Test architecture should support concurrency.

Process-level tests

For CLI/exit behavior, spawn child process:

js
const child = spawn(
  process.execPath,
  ['./src/cli.js', 'bad-command'],
);

Capture stdout/stderr/exit code.

This is better than mocking process.exit.

Signal/shutdown test

Spawn server process, wait ready, send SIGTERM, assert:

text
stops accepting
cleanup log/event
exit 0 within deadline

Platform-specific signal tests may need conditional behavior.

Worker tests

Test worker job result and cancellation.

For pool, test:

  • max concurrency;
  • queue overflow;
  • worker crash replacement;
  • shutdown.

Network mocking

For outbound HTTP, MSW can also work in Node environments or use local test server.

Prefer protocol-level mock over mocking fetch internals when behavior like headers/status/body matters.

Vitest

Strong choice when project already uses Vite/frontend and wants shared tooling.

Supports:

  • modern ESM;
  • mocks;
  • coverage;
  • fast workflow.

Jest

Mature ecosystem with extensive features.

Can require more configuration in ESM-heavy Node projects depending setup.

Do not teach all three runners deeply. Learn testing principles; choose one project standard.

Playwright/Cypress

Browser E2E tools test full web journey:

text
React
→ browser
→ Node API
→ Mongo

Use for critical end-to-end flows.

Do not replace API tests with only browser tests; E2E is slower and harder to diagnose.

Coverage

Use coverage to find untested code paths.

Do not chase 100% as quality metric.

Prioritize:

  • auth;
  • payments;
  • destructive operations;
  • error mapping;
  • concurrency;
  • shutdown;
  • migrations/data.

Flaky async tests

Symptoms:

text
sleep(100)
hope server ready

Bad:

js
await new Promise((r) => setTimeout(r, 100));

Wait for actual condition/event:

js
await once(server, 'listening');

Use polling with deadline for eventual external conditions.

Avoid test order dependency

Every test should create its own prerequisites.

Do not depend on previous test “creates user.”

Suite should pass when reordered/parallelized.

Snapshot tests

Useful for selected stable serialized outputs.

Risk:

  • giant snapshots approved blindly;
  • hidden semantic break.

Prefer explicit assertions for API security/business behavior.

Security tests

Automate abuse cases:

  • oversized body;
  • invalid token;
  • forbidden resource;
  • cross-tenant ID;
  • injection-shaped values;
  • rate limit;
  • path traversal;
  • SSRF allowlist;
  • unsupported content type.

Failure clinic

  • fixed port conflicts;
  • shared DB state;
  • sleeps;
  • over-mocking;
  • asserting internals;
  • env leakage;
  • real external API in unit tests;
  • tests ignore shutdown/resources;
  • browser E2E used for every small validation.

Exercises

  1. Write Node test-runner service tests.
  2. Test rejected NotFoundError.
  3. Test filesystem code with temporary directory.
  4. Spawn CLI and assert stderr/exit code.
  5. Test native/Express health endpoint on ephemeral port.
  6. Build API contract test matrix.
  7. Add auth/tenant security tests.
  8. Test SIGTERM shutdown.
  9. Compare node:test and Vitest in one small module.
  10. Add one Playwright critical journey after frontend integration.

Mastery checklist

Explain:

  • test layers;
  • node:test/assert;
  • async errors;
  • doubles;
  • temp resources;
  • HTTP integration;
  • contract tests;
  • DB integration;
  • process/signal tests;
  • E2E;
  • flake causes;
  • risk-based coverage.

Official references