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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions