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
Description
We should provide a codemod to migrate projects from the Tape test framework to the native Node.js test runner ("node:test").
t.endsemantics).Important points
node:testsuite that matches behavior and intended outcomes.node:testandnode:assert/strict.Detection Strategy
The codemod should identify Tape usage and migration targets by analyzing import patterns and assertion/test invocations:
Examples
Case 1: Basic equality assertions
Before:
After:
Case 2: Deep equality and non-equality
Before:
After:
Case 3: Truthiness, falsiness, and explicit booleans
Before:
After:
Case 4: Throws and does-not-throw with message/regex
Before:
After:
Case 5: Error presence checks
Before:
After:
Case 6: Nested tests and subtests
Before:
After:
Case 7: Skip and todo metadata
Before:
After:
Case 8: Async test using promises or async/await
Before:
After:
Case 9: Lifecycle teardown
Before:
After:
Case 10: Timeouts at test level
Before:
After:
Case 11: Arrays and object structural comparisons
Before:
After:
Case 12: Mixed sync and async assertions in one test
Before:
After:
Caveats
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.t.endor 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.Refs
node:test): https://nodejs.org/api/test.htmlnode:assert/strict): https://nodejs.org/api/assert.html