Node.js 25 Built-In Test Runner
Zero-dependency testing with node:test. No Jest, no Mocha, no Vitest — everything is built in.
When to Use
✅ USE this skill when:
- Writing unit tests or integration tests for Node.js projects
- Running test suites for CLI tools, APIs, libraries
- Checking code coverage on a project
- Mocking functions, methods, or timers in tests
- Testing TypeScript files directly (type stripping)
- User says: "run the tests", "write tests for X", "test coverage", "mock something"
❌ DON'T use this skill when:
- Browser/frontend testing (use Playwright, Vitest browser mode)
- React component testing (use Vitest + Testing Library)
- Projects locked to Jest/Vitest (respect existing config)
Quick Start
# Run all tests (auto-discovers *.test.js, *_test.js, etc.)
node --test
# Run specific file
node --test src/math.test.js
# Run with coverage
node --test --experimental-test-coverage
Core APIs
describe / it (BDD-style)
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
describe('Calculator', () => {
let calc;
beforeEach(() => {
calc = { add: (a, b) => a + b };
});
it('should add two numbers', () => {
assert.strictEqual(calc.add(2, 3), 5);
});
it('should handle negatives', () => {
assert.strictEqual(calc.add(-1, 1), 0);
});
describe('nested suite', () => {
it('works inside describe', () => {
assert.ok(true);
});
});
});
Aliases: describe = suite, it = test. Use whichever you prefer.
test() (functional style)
import test from 'node:test';
import assert from 'node:assert';
test('synchronous', () => {
assert.strictEqual(1, 1);
});
test('async', async () => {
const result = await fetch('https://example.com');
assert.ok(result.ok);
});
test('callback', (t, done) => {
setTimeout(done, 100);
});
Subtests
test('parent', async (t) => {
await t.test('child 1', () => { /* ... */ });
await t.test('child 2', () => { /* ... */ });
});
Built-In Mocking
No jest.fn() needed. Mocking is built into node:test.
Mock Functions (Spy)
import { mock, test } from 'node:test';
import assert from 'node:assert';
test('spy on a function', () => {
const fn = mock.fn((a, b) => a + b);
assert.strictEqual(fn(3, 4), 7);
assert.strictEqual(fn.mock.callCount(), 1);
const call = fn.mock.calls[0];
assert.deepStrictEqual(call.arguments, [3, 4]);
assert.strictEqual(call.result, 7);
mock.reset(); // Reset all globally tracked mocks
});
Mock Methods (auto-restore via test context)
test('mock object method', (t) => {
const obj = {
value: 5,
add(a) { return this.value + a; }
};
t.mock.method(obj, 'add');
obj.add(3);
assert.strictEqual(obj.add.mock.callCount(), 1);
assert.deepStrictEqual(obj.add.mock.calls[0].arguments, [3]);
// Auto-restored when test finishes — no manual cleanup needed
});
Mock Timers
import { mock, test } from 'node:test';
import assert from 'node:assert';
test('mock setTimeout', () => {
const fn = mock.fn();
mock.timers.enable({ apis: ['setTimeout'] });
setTimeout(fn, 9999);
assert.strictEqual(fn.mock.callCount(), 0);
mock.timers.tick(9999); // Fast-forward time
assert.strictEqual(fn.mock.callCount(), 1);
mock.timers.reset();
mock.reset();
});
Supported timer APIs: setTimeout, setInterval, Date.now(), Date constructor, setImmediate.
Mock Modules (getters/setters)
test('mock getter', (t) => {
const obj = { get val() { return 'real'; } };
t.mock.getter(obj, 'val', () => 'mocked');
assert.strictEqual(obj.val, 'mocked');
});
Test Options
Skipping Tests
// Skip via option
it('skipped', { skip: true }, () => { /* never runs */ });
it('skipped with reason', { skip: 'bug #123' }, () => {});
// Skip via method
it('also skipped', (t) => {
t.skip(); // no message
t.skip('reason'); // with message
});
// Skip entire suite
describe.skip('skipped suite', () => { /* ... */ });
TODO Tests (expected failures, v25.5+)
TODO tests execute but don't fail the suite. Use for pending work.
it('not yet implemented', { todo: true }, () => {
throw new Error('this does NOT fail the test');
});
it('with message', { todo: 'needs refactoring' }, () => {});
expectFailure — Mark Known Bugs (v25.5+)
🔄 Reversed logic: The test must throw to pass. If it doesn't throw (i.e., it runs clean), it fails.
Perfect for tracking known bugs: when the bug is fixed, the test starts failing — alerting you to remove the flag.
// Basic: any failure = pass
it.expectFailure('known bug: fails intermittently', () => {
assert.strictEqual(buggyFunction(), expected);
});
// With options object
it('known bug', { expectFailure: true }, () => {
assert.strictEqual(buggyFunction(), expected);
});
// With reason string
it('known bug', { expectFailure: 'feature not implemented' }, () => {
assert.strictEqual(buggyFunction(), expected);
});
// Match specific error (regex)
it('fails with specific error', { expectFailure: /timeout/i }, () => {
throw new Error('Connection timeout');
});
// Match specific error (object shape)
it('error code match', { expectFailure: { code: 'ERR_EXPECTED' } }, () => {
const err = new Error('boom');
err.code = 'ERR_EXPECTED';
throw err; // matches → test passes
});
// Both reason AND specific error
it('specific failure', {
expectFailure: { label: 'known bug', match: /error msg/ }
}, () => {
throw new Error('error msg here');
});
Priority: skip > todo > expectFailure. If a test has both skip and expectFailure, it's skipped.
Only Tests (focused runs)
# Must pass --test-only flag
node --test --test-only
it.only('only this runs', () => { /* ... */ });
describe.only('only this suite', () => { /* ... */ });
Filtering by Name Pattern
# Run tests matching regex
node --test --test-name-pattern="should add"
node --test --test-name-pattern="/should add/i"
# Skip tests matching regex
node --test --test-skip-pattern="slow"
Code Coverage
# Basic coverage (spec reporter shows summary)
node --test --experimental-test-coverage
# Generate lcov report
node --test --experimental-test-coverage \
--test-reporter=lcov \
--test-reporter-destination=coverage.info
# Include node_modules in coverage
node --test --experimental-test-coverage \
--test-coverage-include="**/*.js"
# Exclude specific paths
node --test --experimental-test-coverage \
--test-coverage-exclude="**/migrations/**"
Inline Coverage Control
/* node:coverage disable */
// Lines here are ignored for coverage
/* node:coverage enable */
/* node:coverage ignore next */
if (neverReached) { console.log('nope'); }
/* node:coverage ignore next 3 */
if (neverReached) {
console.log('line 1');
console.log('line 2');
}
--test-rerun-failures — Only Rerun Failed Tests
Instead of re-running the entire suite, persist state and only re-run what failed:
# First run: saves state to a file
node --test --test-rerun-failures ./test-state.json
# Second run: only re-runs tests that failed
node --test --test-rerun-failures ./test-state.json
# Third run: keeps narrowing down
node --test --test-rerun-failures ./test-state.json
The state file is JSON tracking which tests passed on which attempt. Each subsequent run skips passing tests and only re-executes failures. Great for CI flaky-test retry loops.
TypeScript — Type Stripping (Zero Config)
Node.js 25 has built-in type stripping. No ts-node, no tsx, no transpile step.
# Run .ts test files directly (type stripping is ON by default in Node 25)
node --test src/utils.test.ts
# Explicitly disable type stripping
node --test --no-strip-types src/utils.test.ts
Auto-discovery of .ts test files (when type stripping is enabled):
*.test.{cts,mts,ts}*-test.{cts,mts,ts}*_test.{cts,mts,ts}test-*.{cts,mts,ts}test.{cts,mts,ts}test/**/*.{cts,mts,ts}
Limitations: Type stripping removes types but does NOT check them. Run tsc --noEmit separately for type checking.
// math.test.ts — runs directly with node --test
import { describe, it } from 'node:test';
import assert from 'node:assert';
function add(a: number, b: number): number {
return a + b;
}
describe('add', () => {
it('sums correctly', () => {
assert.strictEqual(add(2, 3), 5);
});
});
Watch Mode
node --test --watch
Automatically re-runs tests when files change. Good for TDD workflow.
Global Setup / Teardown (v24+)
// setup.js
export async function globalSetup() {
console.log('Starting DB...');
// await startDatabase();
}
export async function globalTeardown() {
console.log('Stopping DB...');
// await stopDatabase();
}
node --test --test-global-setup=./setup.js
Reporters
# Spec (default, human-readable)
node --test --test-reporter=spec
# TAP (machine-readable)
node --test --test-reporter=tap
# LCOV (for coverage tools)
node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info
# Dot (minimal)
node --test --test-reporter=dot
Key Flags Reference
| Flag | Description |
|---|---|
--test |
Enable test runner, auto-discover test files |
--test-concurrency=N |
Max parallel child processes (default: CPU count) |
--test-name-pattern=REGEX |
Run only tests matching pattern |
--test-skip-pattern=REGEX |
Skip tests matching pattern |
--test-only |
Only run tests with { only: true } |
--test-rerun-failures=FILE |
Persist state, only rerun failed tests |
--test-reporter=TYPE |
Output format: spec, tap, dot, lcov |
--test-reporter-destination=FILE |
Reporter output destination |
--test-global-setup=FILE |
Global setup/teardown module |
--experimental-test-coverage |
Enable code coverage collection |
--test-coverage-include=GLOB |
Coverage include pattern |
--test-coverage-exclude=GLOB |
Coverage exclude pattern |
--watch |
Re-run tests on file changes |
--test-force-exit |
Force exit after tests complete |
--experimental-strip-types |
Enable TS type stripping (default in Node 25) |
--no-strip-types |
Disable type stripping |