Skip to content

spec: tap-to-node-testย #318

@AugustinMauroy

Description

@AugustinMauroy

Description

Since the tap test runner can be replaced by the built-in node:test module available in Node.js v18 and later, we should provide a codemod to migrate existing tests.

  • The codemod should replace tap imports/requires with node:test and node:assert/strict equivalents.
  • The codemod should convert t.test() blocks to test() while preserving names, options, and async handling.
  • The codemod should map tap assertion helpers (t.equal, t.same, t.ok, t.rejects, etc.) to assert/strict equivalents.
  • The codemod should migrate hook APIs (t.before, t.after, t.beforeEach, t.afterEach, t.teardown) to test.before/after and per-test cleanup via t.after().
  • The codemod should transform tap-specific flags (only, skip, todo, timeout) to their node:test counterparts.
  • The codemod should update nested subtests and callback-style tests (t.end()) to the node:test pattern.
  • The codemod should adjust t.plan() usage to the node:test context plan while keeping assertion counts intact.

Important points

  • node:test ships with Node.js (stable since v20) and uses the node:assert/strict API for assertions by default.
  • Test discovery is handled by node --test instead of the tap CLI; suite-level configuration (reporters, coverage) can be updated in a follow-up workflow step that edits package.json or config files.
  • The t object from node:test exposes similar helpers (test, plan, after, diagnostic) but not tap-specific reporters or snapshots.
  • t.teardown() in tap maps to t.after() inside node:test to register per-test cleanup.
  • Tap's t.pass() or t.fail() should be replaced with explicit assertions (e.g., assert.ok(true) or assert.fail(err)).
  • Snapshot features (t.matchSnapshot) and tap plugins are out of scope and should be replaced manually or with third-party utilities (for now).

CLI migration guidance

  • Replace npm/yarn scripts invoking tap ... with node --test plus equivalent flags: --test-only (tap --only), --test-name-pattern (tap --grep), --test-timeout (tap --timeout), --test-concurrency (tap --jobs), and --test-reporter/--test-reporter-destination (tap reporters and --reporter-file).
  • Translate tap's include/exclude globs (--include/--exclude, --files) to node --test file globs or test runner patterns; remove .taprc/package.json "tap" config blocks once mapped.
  • Coverage and reporter flags (--coverage-report, --output-file/dir) are not provided by node:test; use c8 node --test or external reporters as a separate workflow step.
  • Plugin-driven flags (filter/only, snapshot, fixture, typescript) have no direct node:test analog and should be replaced manually or with dedicated tooling.

Examples

Example 1: Basic CommonJS test

Before:

const t = require("tap");

function add(a, b) {
  return a + b;
}

t.test("adds numbers", t => {
  t.equal(add(1, 2), 3);
  t.end();
});

After:

const { test } = require("node:test");
const assert = require("node:assert/strict");

function add(a, b) {
  return a + b;
}

test("adds numbers", t => {
  assert.strictEqual(add(1, 2), 3);
  t.end();
});

Example 2: ESM with async assertions

Before:

import t from "tap";
import { fetchUser } from "./user.js";

t.test("loads user", async t => {
  const user = await fetchUser(7);
  t.same(user, { id: 7, active: true });
});

After:

import test from "node:test";
import assert from "node:assert/strict";
import { fetchUser } from "./user.js";

test("loads user", async t => {
  const user = await fetchUser(7);
  assert.deepStrictEqual(user, { id: 7, active: true });
});

Example 3: Global setup and teardown hooks

Before:

const t = require("tap");
const db = createDatabase();

t.before(async () => {
  await db.connect();
});

t.after(async () => {
  await db.close();
});

t.test("reads records", async t => {
  const rows = await db.list();
  t.ok(rows.length > 0);
});

After:

import test from "node:test";
import assert from "node:assert/strict";
const db = createDatabase();

test.before(async () => {
  await db.connect();
});

test.after(async () => {
  await db.close();
});

test("reads records", async t => {
  const rows = await db.list();
  assert.ok(rows.length > 0);
});

Example 4: Per-test setup with context

Before:

const t = require("tap");
const { openConnection } = require("./db");

t.beforeEach(async t => {
  t.context.conn = await openConnection();
});

t.afterEach(async t => {
  await t.context.conn.close();
});

t.test("stores item", async t => {
  const saved = await t.context.conn.save({ id: 1 });
  t.ok(saved.id);
});

After:

const test = require("node:test");
const assert = require("node:assert/strict");
const { openConnection } = require("./db");

test.beforeEach(async t => {
  t.context.conn = await openConnection();
});

test.afterEach(async t => {
  await t.context.conn.close();
});

test("stores item", async t => {
  const saved = await t.context.conn.save({ id: 1 });
  assert.ok(saved.id);
});

Example 5: Nested subtests

Before:

const t = require("tap");

t.test("api", t => {
  t.test("GET /status", async t => {
    const res = await requestStatus();
    t.equal(res.statusCode, 200);
  });
  t.end();
});

After:

const { test } = require("node:test");
const assert = require("node:assert/strict");

test("api", async t => {
  await t.test("GET /status", async t => {
    const res = await requestStatus();
    assert.strictEqual(res.statusCode, 200);
  });
});

Example 6: Focused, skipped, and todo tests

Before:

const t = require("tap");

t.test("skipped", { skip: true }, t => {
  t.pass("not run");
  t.end();
});

t.only("focus this", async t => {
  await doWork();
});

t.todo("future case", async t => {
  await maybeImplement();
});

After:

const test = require("node:test");

test("skipped", { skip: true }, t => {
  t.end();
});

test.only("focus this", async t => {
  await doWork();
});

test.todo("future case", async t => {
  await maybeImplement();
});

Example 7: Promise rejection assertions

Before:

import t from "tap";
import { failing } from "./service.js";

await t.rejects(failing(), { message: /boom/ });
await t.resolves(Promise.resolve("ok"));

After:

import assert from "node:assert/strict";
import { failing } from "./service.js";

await assert.rejects(failing(), /boom/);
await assert.doesNotReject(Promise.resolve("ok"));

Example 8: Teardown per test

Before:

const t = require("tap");
const server = createServer();

t.test("serves", t => {
  t.teardown(() => server.close());
  t.equal(server.listening, true);
  t.end();
});

After:

const { test } = require("node:test");
const assert = require("node:assert/strict");
const server = createServer();

test("serves", t => {
  t.after(() => server.close());
  assert.strictEqual(server.listening, true);
  t.end();
});

Example 9: Plan-based tests

Before:

const t = require("tap");

t.test("makes three assertions", t => {
  t.plan(3);
  t.ok(true);
  t.equal(1 + 1, 2);
  t.same({ a: 1 }, { a: 1 });
});

After:

const { test } = require("node:test");
const assert = require("node:assert/strict");

test("makes three assertions", t => {
  t.plan(3);
  assert.ok(true);
  assert.strictEqual(1 + 1, 2);
  assert.deepStrictEqual({ a: 1 }, { a: 1 });
});

Example 10: Callback-style tests

Before:

const t = require("tap");

t.test("uses callback", t => {
  setTimeout(() => {
    t.ok(true);
    t.end();
  }, 10);
});

After:

const { test } = require("node:test");
const assert = require("node:assert/strict");

test("uses callback", t => {
  setTimeout(() => {
    assert.ok(true);
    t.end();
  }, 10);
});

Example 11: package.json script migration

Before:

{
  "scripts": {
    "test": "tap",
    "test:watch": "tap --watch",
    "coverage": "tap --coverage-report=lcov"
  }
}

After:

{
  "scripts": {
    "test": "node --test",
    "test:watch": "node --test --watch",
    "coverage": "c8 node --test"
  }
}

Caveats

  • Tap-specific reporters, coverage integration, and snapshot helpers are not provided by node:test; equivalent functionality must be handled by external tools.
  • Tap plugins and custom assertions may require manual rewrites or replacement libraries..

Refs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status

    ๐Ÿ“‹ Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions