| name | node-native-tests |
| description | Write and maintain TypeScript tests for Node.js-only modules using only the native node:test runner and node:assert/strict, with no browser, DOM, Jest, Vitest, or transpiler-based test runner. |
Node Native Tests
Use this skill when writing tests for Node.js modules where the test stack must be:
- TypeScript source files executed directly by Node.js
node:test for test definitions, lifecycle hooks, subtests, mocking, and timers
node:assert/strict for assertions
- no browser APIs, DOM APIs, Jest, Vitest, Playwright, jsdom, tsx, ts-node, or Babel
Workflow
- Confirm the target is a Node.js module, not browser or DOM code.
- Read the module API and existing package scripts before choosing file names or commands.
- Add colocated
*.test.ts files unless the package already has a dedicated Node test directory.
- Write the failing test first, using only public exports.
- Run the smallest native command that covers the new test.
- Run type checking separately; Node executes TypeScript but does not type-check it.
Native TypeScript Constraints
Node.js runs TypeScript through type stripping. Keep tests and imported test-only helpers inside erasable TypeScript syntax:
- Use
.ts, do not use .mts, .cts or .tsx.
- Include real file extensions in relative imports:
./parser.ts, not ./parser.
- Use
import type for type-only imports.
- Avoid syntax that requires transformation:
enum, parameter properties, runtime namespaces, decorators, and TypeScript path aliases.
- Do not assume
tsconfig.json changes Node runtime behavior. tsconfig.json is for editors and tsc.
Recommended tsconfig.json options for native Node TypeScript projects:
{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true
}
}
If the project imports .ts extensions and type-checks without emitting, also ensure the compiler accepts those imports, for example with allowImportingTsExtensions where appropriate.
Running Tests
Prefer explicit globs for TypeScript tests:
node --test "**/*.test.ts"
For Node versions earlier than v22.18.0, TypeScript execution may require:
node --experimental-strip-types --test "**/*.test.ts"
Type-check separately:
tsc --noEmit
Useful native runner flags:
--test-name-pattern "pattern" to run matching tests.
--test-only with test.only() for a temporary focused run; remove before finishing.
--watch for local iteration.
--test-concurrency <n> when file-level concurrency must be constrained.
--test-reporter spec for readable local output.
--experimental-test-coverage when native coverage is available in the target Node version.
Test Structure
Use ESM-style imports:
import assert from "node:assert/strict";
import { describe, test } from "node:test";
import { parsePort } from "./parse-port.ts";
describe("parsePort", (): void => {
test("accepts a valid TCP port", (): void => {
assert.equal(parsePort("8080"), 8080);
});
test("rejects a non-numeric port", (): void => {
assert.deepEqual(parsePort("abc"), {
type: "error",
error: "not-a-number",
});
});
});
Keep tests black-box:
- Assert observable return values, emitted values, written files, or explicit
Result/Option errors.
- Do not assert private helper calls or implementation details.
- Prefer one structural
assert.deepEqual(actual, expected) over many weak partial assertions.
- Use
assert.equal() for primitives, assert.deepEqual() for records and arrays, and assert.match() for intentional text patterns.
- Import from
node:assert/strict; do not import legacy node:assert.
Async Tests
Return or await every async operation. Native tests fail on rejected promises when they are awaited by the test body.
import assert from "node:assert/strict";
import { test } from "node:test";
import { readConfig } from "./read-config.ts";
test("reads a config file", async (): Promise<void> => {
const config = await readConfig(
new URL("./fixtures/app.json", import.meta.url),
);
assert.deepEqual(config, {
mode: "test",
});
});
When testing promise rejection, use assert.rejects() and await it:
await assert.rejects(
async (): Promise<void> => {
await readConfig(new URL("./fixtures/missing.json", import.meta.url));
},
{
name: "Error",
},
);
Fixtures And Node APIs
Use Node standard modules for Node tests:
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, test } from "node:test";
Keep filesystem tests isolated:
- Create per-test temporary directories with
mkdtemp(join(tmpdir(), "package-name-")).
- Clean up with
afterEach() or try/finally.
- Avoid shared fixture mutation. Read immutable fixtures from a
fixtures/ directory.
- Do not depend on test execution order.
Mocking
Prefer dependency injection over mocks. When a mock is the simplest expression of behavior, use the native test context so cleanup is automatic:
import assert from "node:assert/strict";
import { test, type TestContext } from "node:test";
test("calls the injected logger once", (context: TestContext): void => {
const loggedMessages: Array<string> = [];
const log = context.mock.fn((message: string): void => {
loggedMessages.push(message);
});
log("ready");
assert.equal(log.mock.callCount(), 1);
assert.deepEqual(log.mock.calls[0]?.arguments, ["ready"]);
assert.deepEqual(loggedMessages, ["ready"]);
});
For timers, use context.mock.timers and advance time deterministically. Avoid real sleeps.
Do Not Use
expect, fake DOMs, or browser globals such as window and document.
- Transpiler-only TypeScript syntax
assert.ok(actual) when a more precise assertion is available.
assert.doesNotThrow() or assert.doesNotReject() as a substitute for asserting the real outcome.
- Focused tests, skipped tests, or random temporary files left behind after the run.
Source Docs