Skip to content

spec: tape-to-node-test-runner #260

Description

@AugustinMauroy

Description

We should provide a codemod to migrate projects from the Tape test framework to the native Node.js test runner ("node:test").

  • The codemod should replace Tape’s import/require with node:test and assert/strict equivalents.
  • The codemod should translate common Tape assertion APIs to assert/strict equivalents (e.g., equality, deep equality, throws/doesNotThrow, error checks, pass/ok).
  • The codemod should preserve test structure, including nested subtests, skip/todo flags, and asynchronous patterns (promise-based and async/await).
  • The codemod should convert teardown hooks and timeouts to node:test options and lifecycle hooks where possible.
  • The codemod should annotate or safely fall back for behaviors that don’t have a one-to-one mapping (e.g., plan-based assertion counts, callback-style t.end semantics).
  • The codemod should be designed with an iterative equivalence-testing method (see “Important points”) and an AST transformation that is maintainable and scoped.
  • The codemod should include a mapping matrix that clearly documents feature coverage and known limitations.

Important points

  • Iterative comparison method:
    • Maintain a maximal Tape test suite that exercises representative features and edge cases.
    • Maintain an equivalent node:test suite that matches behavior and intended outcomes.
    • Use a comparison script that runs both suites in child processes and compares their behavior (e.g., exit codes) to ensure practical parity. Iterate until parity is stable and transformation rules are feasible.
  • Scope:
    • Imports and entry points: replace Tape’s entry with node:test and node:assert/strict.
    • Assertions: map basic and deep equalities, truthiness, throws/doesNotThrow, error checks, array/object deep equality, and pass.
    • Test structure: keep test names, nesting, and subtest hierarchies; preserve skip/todo metadata.
    • Async: preserve async/await and promise-based tests; keep subtest concurrency semantics consistent with node:test where feasible.
    • Lifecycle: convert teardown to node:test lifecycle hooks; translate test-level timeouts to node:test options where possible.
  • Expected deliverables:
    • A codemod that applies the outlined transformations across CommonJS and ESM projects.
    • A documented mapping matrix of Tape APIs to node:test/assert equivalents, indicating coverage and caveats.
    • A parity harness that runs both the Tape and node:test suites and reports parity outcomes (e.g., exit code compatibility).
    • A test fixture set demonstrating typical patterns and edge cases used to validate the codemod.
  • Steps for contributors (high level):
    • Study the mapping matrix and the two canonical suites to understand intended parity.
    • Implement transformations incrementally, validating after each category (imports, assertions, lifecycle, async, metadata).
    • Extend the suites and parity checks when you discover gaps or edge cases; iterate until stable.
    • Add clear annotations or comments for non-trivial cases where automatic migration is partial or requires follow-up by maintainers.

Detection Strategy

The codemod should identify Tape usage and migration targets by analyzing import patterns and assertion/test invocations:

  • Detect Tape entry points:
    • Default and named imports from the Tape package, as well as CommonJS requires.
    • Local aliases of the Tape test function (e.g., renamed bindings).
  • Detect test definitions, nested tests, and metadata:
    • Calls to the Tape test function, including variants with options objects for skip/todo.
    • Nested t.test(...) and its callback parameter binding (e.g., t, st, t1, etc.).
  • Detect assertion calls and map categories:
    • Equality: equal/notEqual/strictEqual/notStrictEqual; deepEqual/notDeepEqual; same/notSame.
    • Truthiness: ok/notOk/true/false; pass; error (ifError).
    • Throws: throws/doesNotThrow; message or regex matching.
  • Detect lifecycle and timeout:
    • Teardown hooks registered on the test object.
    • Timeout calls; distinguish test-level timeouts from other timers.
  • Handle mixed and edge cases:
    • Ensure transformation respects renamed imports/variables.
    • Avoid rewriting unrelated functions named test/assert in other modules.
    • Limit scope to Tape tests in files where Tape is clearly referenced.

Examples

Case 1: Basic equality assertions

Before:

import test from "tape";

test("basic equality", (t) => {
  t.plan(4);
  t.equal(1, 1, "equal numbers");
  t.notEqual(1, 2, "not equal numbers");
  t.strictEqual(true, true, "strict equality");
  t.notStrictEqual("1", 1, "not strict equality");
});

After:

import { test } from "node:test";
import assert from "node:assert/strict";

test("basic equality", () => {
  assert.equal(1, 1, "equal numbers");
  assert.notEqual(1, 2, "not equal numbers");
  assert.strictEqual(true, true, "strict equality");
  assert.notStrictEqual("1", 1, "not strict equality");
});

Case 2: Deep equality and non-equality

Before:

import test from "tape";

test("deep equality", (t) => {
  t.plan(2);
  t.deepEqual({ a: 1 }, { a: 1 }, "objects are deeply equal");
  t.notDeepEqual({ a: 1 }, { a: 2 }, "objects are not deeply equal");
});

After:

import { test } from "node:test";
import assert from "node:assert/strict";

test("deep equality", () => {
  assert.deepEqual({ a: 1 }, { a: 1 }, "objects are deeply equal");
  assert.notDeepEqual({ a: 1 }, { a: 2 }, "objects are not deeply equal");
});

Case 3: Truthiness, falsiness, and explicit booleans

Before:

import test from "tape";

test("truthiness", (t) => {
  t.plan(4);
  t.ok(true, "true is ok");
  t.notOk(false, "false is not ok");
  t.true(true, "explicitly true");
  t.false(false, "explicitly false");
});

After:

import { test } from "node:test";
import assert from "node:assert/strict";

test("truthiness", () => {
  assert.ok(true, "true is ok");
  assert.ok(!false, "false is not ok");
  assert.ok(true, "explicitly true");
  assert.ok(!false, "explicitly false");
});

Case 4: Throws and does-not-throw with message/regex

Before:

import test from "tape";

function throwsError() {
  throw new Error("Expected error");
}

test("throws assertions", (t) => {
  t.plan(4);
  t.throws(() => { throw new Error("test error"); }, /test error/, "function throws with message");
  t.doesNotThrow(() => 42, "function does not throw");
  t.throws(throwsError, Error, "throws specific error type");
  t.doesNotThrow(() => ({ success: true }));
});

After:

import { test } from "node:test";
import assert from "node:assert/strict";

function throwsError() {
  throw new Error("Expected error");
}

test("throws assertions", () => {
  assert.throws(() => { throw new Error("test error"); }, /test error/, "function throws with message");
  assert.doesNotThrow(() => 42, "function does not throw");
  assert.throws(throwsError, Error, "throws specific error type");
  assert.doesNotThrow(() => ({ success: true }));
});

Case 5: Error presence checks

Before:

import test from "tape";

test("error check", (t) => {
  t.plan(1);
  t.error(null, "no error present");
});

After:

import { test } from "node:test";
import assert from "node:assert/strict";

test("error check", () => {
  assert.ifError(null);
});

Case 6: Nested tests and subtests

Before:

import test from "tape";

test("nested tests", (t) => {
  t.plan(1);
  t.test("inner test 1", (st) => {
    st.plan(1);
    st.equal(1, 1, "inner assertion");
  });
});

After:

import { test } from "node:test";
import assert from "node:assert/strict";

test("nested tests", async (t) => {
  await t.test("inner test 1", () => {
    assert.equal(1, 1, "inner assertion");
  });
});

Case 7: Skip and todo metadata

Before:

import test from "tape";

test("skip test", { skip: true }, (t) => {
  t.fail("this should be skipped");
});

test("todo test", { todo: true }, (t) => {
  t.fail("this is a todo");
});

After:

import { test } from "node:test";
import assert from "node:assert/strict";

test("skip test", { skip: true }, () => {
  assert.fail("this should be skipped");
});

test("todo test", { todo: true }, () => {
  assert.fail("this is a todo");
});

Case 8: Async test using promises or async/await

Before:

import test from "tape";

function someAsyncThing() {
  return new Promise((resolve) => setTimeout(() => resolve(true), 50));
}

test("async test with promises", async (t) => {
  t.plan(1);
  const result = await someAsyncThing();
  t.ok(result, "async result is truthy");
});

After:

import { test } from "node:test";
import assert from "node:assert/strict";

function someAsyncThing() {
  return new Promise((resolve) => setTimeout(() => resolve(true), 50));
}

test("async test with promises", async () => {
  const result = await someAsyncThing();
  assert.ok(result, "async result is truthy");
});

Case 9: Lifecycle teardown

Before:

import test from "tape";

let teardownState = 1;

test("teardown registers and runs after test", (t) => {
  t.plan(1);
  t.teardown(() => { teardownState = 0; });
  t.equal(teardownState, 1, "state before teardown");
});

test("teardown verification in subsequent test", (t) => {
  t.plan(1);
  t.equal(teardownState, 0, "teardown ran and updated state");
});

After:

import { test } from "node:test";
import assert from "node:assert/strict";

let teardownState = 1;

test("teardown registers and runs after test", (t) => {
  t.after(() => { teardownState = 0; });
  assert.equal(teardownState, 1, "state before teardown");
});

test("teardown verification in subsequent test", () => {
  assert.equal(teardownState, 0, "teardown ran and updated state");
});

Case 10: Timeouts at test level

Before:

import test from "tape";

test("timeout success (no trigger)", (t) => {
  t.plan(1);
  t.timeoutAfter(200);
  setTimeout(() => {
    t.ok(true, "completed before timeout");
  }, 100);
});

After:

import { test } from "node:test";
import assert from "node:assert/strict";

test("timeout success (no trigger)", { timeout: 200 }, () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      assert.ok(true, "completed before timeout");
      resolve();
    }, 100);
  });
});

Case 11: Arrays and object structural comparisons

Before:

import test from "tape";

test("array assertions", (t) => {
  t.plan(2);
  t.same([1, 2, 3], [1, 2, 3], "arrays are same");
  t.notSame([1, 2, 3], [1, 2, 4], "arrays are not same");
});

After:

import { test } from "node:test";
import assert from "node:assert/strict";

test("array assertions", () => {
  assert.deepEqual([1, 2, 3], [1, 2, 3], "arrays are same");
  assert.notDeepEqual([1, 2, 3], [1, 2, 4], "arrays are not same");
});

Case 12: Mixed sync and async assertions in one test

Before:

import test from "tape";

function someAsyncThing() {
  return new Promise((resolve) => setTimeout(() => resolve(true), 50));
}

test("mixed sync and async", async (t) => {
  t.ok(true, "sync assertion 1");
  await someAsyncThing();
  t.ok(true, "async assertion");
  t.equal(1 + 1, 2, "sync assertion 2");
});

After:

import { test } from "node:test";
import assert from "node:assert/strict";

function someAsyncThing() {
  return new Promise((resolve) => setTimeout(() => resolve(true), 50));
}

test("mixed sync and async", async () => {
  assert.ok(true, "sync assertion 1");
  await someAsyncThing();
  assert.ok(true, "async assertion");
  assert.equal(1 + 1, 2, "sync assertion 2");
});

Caveats

  • Plan counts: Tape’s explicit t.plan(n) has no direct equivalent in node:test; the codemod should annotate such occurrences for manual review or add a clear comment indicating plan semantics were removed.
  • Callback-style endings: Tests that rely on t.end or callback conclusion patterns require careful handling; the codemod should annotate these for manual follow-up where automatic conversion to async/await or promises isn’t obvious.
  • Matcher nuances: Message/regex matching in throws/doesNotThrow is broadly compatible but may have subtle differences; contributors should verify during parity checks.
  • Timeout semantics: Converting a runtime-set timeout call to a static test option changes where timeout is expressed; verify that behavior remains equivalent in the suite.
  • Teardown scope: Tape teardown is per-test; node:test offers t.after and t.afterEach. The codemod should maintain intended scope and annotate ambiguous cases.
  • Output/reporting differences: The comparison harness validates behavior via exit codes and outcomes; textual output formatting (reporters) is not guaranteed to be identical.

Refs

Metadata

Metadata

Assignees

No one assigned

    Fields

    No fields configured for Feature.

    Projects

    Status
    🏗 In progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions