Node.js includes a built-in test runner for writing tests without external dependencies. Here's how to use it.
Basic Usage#
1import { test } from 'node:test';
2import assert from 'node:assert';
3
4// Basic test
5test('addition works', () => {
6 assert.strictEqual(1 + 1, 2);
7});
8
9// Async test
10test('async operation', async () => {
11 const result = await Promise.resolve(42);
12 assert.strictEqual(result, 42);
13});
14
15// Test with callback
16test('callback style', (t, done) => {
17 setTimeout(() => {
18 assert.ok(true);
19 done();
20 }, 100);
21});Subtests#
1import { test } from 'node:test';
2import assert from 'node:assert';
3
4test('Math operations', async (t) => {
5 await t.test('addition', () => {
6 assert.strictEqual(2 + 2, 4);
7 });
8
9 await t.test('subtraction', () => {
10 assert.strictEqual(5 - 3, 2);
11 });
12
13 await t.test('multiplication', () => {
14 assert.strictEqual(3 * 4, 12);
15 });
16
17 await t.test('division', () => {
18 assert.strictEqual(10 / 2, 5);
19 });
20});
21
22// Nested subtests
23test('User operations', async (t) => {
24 await t.test('create user', async (t) => {
25 await t.test('with valid data', () => {
26 // Test creating user with valid data
27 });
28
29 await t.test('with invalid email', () => {
30 // Test validation error
31 });
32 });
33
34 await t.test('delete user', async (t) => {
35 await t.test('existing user', () => {
36 // Test deleting existing user
37 });
38 });
39});describe and it#
1import { describe, it } from 'node:test';
2import assert from 'node:assert';
3
4describe('Calculator', () => {
5 describe('add', () => {
6 it('should add two positive numbers', () => {
7 assert.strictEqual(add(2, 3), 5);
8 });
9
10 it('should handle negative numbers', () => {
11 assert.strictEqual(add(-1, 1), 0);
12 });
13
14 it('should handle decimals', () => {
15 assert.strictEqual(add(0.1, 0.2), 0.3);
16 });
17 });
18
19 describe('divide', () => {
20 it('should divide two numbers', () => {
21 assert.strictEqual(divide(10, 2), 5);
22 });
23
24 it('should throw on division by zero', () => {
25 assert.throws(() => divide(10, 0), Error);
26 });
27 });
28});Hooks#
1import { test, before, after, beforeEach, afterEach } from 'node:test';
2import assert from 'node:assert';
3
4let db;
5
6before(async () => {
7 // Setup before all tests
8 db = await connectToDatabase();
9});
10
11after(async () => {
12 // Cleanup after all tests
13 await db.close();
14});
15
16beforeEach(async () => {
17 // Run before each test
18 await db.clear();
19});
20
21afterEach(async () => {
22 // Run after each test
23 await db.rollback();
24});
25
26test('database operations', async (t) => {
27 await t.test('insert', async () => {
28 await db.insert({ name: 'Test' });
29 const result = await db.find({ name: 'Test' });
30 assert.strictEqual(result.length, 1);
31 });
32
33 await t.test('update', async () => {
34 await db.insert({ name: 'Original' });
35 await db.update({ name: 'Original' }, { name: 'Updated' });
36 const result = await db.find({ name: 'Updated' });
37 assert.strictEqual(result.length, 1);
38 });
39});Skipping and Focusing#
1import { test, describe, it } from 'node:test';
2
3// Skip a test
4test('skipped test', { skip: true }, () => {
5 // This won't run
6});
7
8// Skip with reason
9test('feature not implemented', { skip: 'TODO: implement later' }, () => {
10 // Won't run
11});
12
13// Only run this test
14test('focused test', { only: true }, () => {
15 // Only this runs when using --test-only flag
16});
17
18// Conditional skip
19test('Windows only', { skip: process.platform !== 'win32' }, () => {
20 // Skipped on non-Windows
21});
22
23// Skip in describe block
24describe('Feature', () => {
25 it.skip('not ready', () => {});
26
27 it.todo('implement this');
28
29 it('works', () => {
30 // This runs
31 });
32});Mocking#
1import { test, mock } from 'node:test';
2import assert from 'node:assert';
3
4test('mock function', () => {
5 const fn = mock.fn((a, b) => a + b);
6
7 const result = fn(1, 2);
8
9 assert.strictEqual(result, 3);
10 assert.strictEqual(fn.mock.calls.length, 1);
11 assert.deepStrictEqual(fn.mock.calls[0].arguments, [1, 2]);
12});
13
14test('mock implementation', () => {
15 const fn = mock.fn();
16
17 fn.mock.mockImplementation(() => 'mocked');
18
19 assert.strictEqual(fn(), 'mocked');
20});
21
22test('mock module', async (t) => {
23 // Mock a module
24 const mockFs = {
25 readFile: mock.fn(() => Promise.resolve('content')),
26 };
27
28 // Use mocked module
29 const content = await mockFs.readFile('/path/to/file');
30 assert.strictEqual(content, 'content');
31 assert.strictEqual(mockFs.readFile.mock.calls.length, 1);
32});
33
34test('spy on method', () => {
35 const obj = {
36 method: () => 'original',
37 };
38
39 const spy = mock.method(obj, 'method', () => 'mocked');
40
41 assert.strictEqual(obj.method(), 'mocked');
42 assert.strictEqual(spy.mock.calls.length, 1);
43
44 // Restore original
45 spy.mock.restore();
46 assert.strictEqual(obj.method(), 'original');
47});Timers#
1import { test, mock } from 'node:test';
2import assert from 'node:assert';
3
4test('mock timers', (t) => {
5 t.mock.timers.enable();
6
7 let called = false;
8 setTimeout(() => {
9 called = true;
10 }, 1000);
11
12 // Advance time
13 t.mock.timers.tick(1000);
14
15 assert.strictEqual(called, true);
16});
17
18test('mock Date', (t) => {
19 t.mock.timers.enable({ apis: ['Date'] });
20
21 const fixedDate = new Date('2024-01-01');
22 t.mock.timers.setTime(fixedDate.getTime());
23
24 assert.strictEqual(
25 new Date().toISOString(),
26 '2024-01-01T00:00:00.000Z'
27 );
28});
29
30test('interval', (t) => {
31 t.mock.timers.enable();
32
33 let count = 0;
34 setInterval(() => {
35 count++;
36 }, 100);
37
38 t.mock.timers.tick(350);
39
40 assert.strictEqual(count, 3);
41});Assertions#
1import { test } from 'node:test';
2import assert from 'node:assert';
3
4test('assertion examples', () => {
5 // Equality
6 assert.strictEqual(1, 1);
7 assert.notStrictEqual(1, '1');
8 assert.deepStrictEqual({ a: 1 }, { a: 1 });
9
10 // Truthiness
11 assert.ok(true);
12 assert.ok(1);
13 assert.ok('string');
14
15 // Throws
16 assert.throws(() => {
17 throw new Error('test');
18 }, Error);
19
20 assert.throws(
21 () => {
22 throw new Error('specific message');
23 },
24 { message: 'specific message' }
25 );
26
27 // Rejects
28 assert.rejects(
29 async () => {
30 throw new Error('async error');
31 },
32 Error
33 );
34
35 // Does not throw
36 assert.doesNotThrow(() => {
37 return 'safe';
38 });
39
40 // Match
41 assert.match('hello world', /world/);
42 assert.doesNotMatch('hello', /world/);
43});Snapshot Testing#
1import { test } from 'node:test';
2import assert from 'node:assert';
3
4test('snapshot test', (t) => {
5 const result = {
6 name: 'Test',
7 items: [1, 2, 3],
8 nested: { value: 42 },
9 };
10
11 // Assert matches snapshot
12 t.assert.snapshot(result);
13});
14
15// Run with --test-update-snapshots to update
16// node --test --test-update-snapshotsCoverage#
1// Run with coverage
2// node --test --experimental-test-coverage
3
4// Coverage report options
5// node --test --experimental-test-coverage --test-reporter=lcov
6
7// Check coverage thresholds in test
8import { test } from 'node:test';
9
10test('coverage check', async (t) => {
11 // Your tests...
12
13 // Coverage data available via test context
14 const coverage = t.diagnostic();
15});Test Configuration#
1import { test } from 'node:test';
2
3// Timeout
4test('with timeout', { timeout: 5000 }, async () => {
5 // Must complete within 5 seconds
6});
7
8// Concurrency
9test('concurrent tests', { concurrency: true }, async (t) => {
10 // Subtests run concurrently
11 await t.test('fast', async () => {});
12 await t.test('slow', async () => {});
13});
14
15// Retry on failure
16test('flaky test', { retry: 3 }, async () => {
17 // Retries up to 3 times on failure
18});
19
20// Run in isolation
21test('isolated', { only: true }, () => {
22 // Use with --test-only flag
23});Running Tests#
1# Run all tests
2node --test
3
4# Run specific file
5node --test tests/math.test.js
6
7# Run with pattern
8node --test 'tests/**/*.test.js'
9
10# Watch mode
11node --test --watch
12
13# With coverage
14node --test --experimental-test-coverage
15
16# Specific reporter
17node --test --test-reporter=spec
18
19# Only run focused tests
20node --test --test-only
21
22# Parallel execution
23node --test --test-concurrency=4Best Practices#
Structure:
✓ Use describe/it for organization
✓ Keep tests focused
✓ Use hooks for setup/cleanup
✓ Group related tests
Mocking:
✓ Mock external dependencies
✓ Restore mocks after tests
✓ Use timers mock for time
✓ Spy on methods
Assertions:
✓ Use strictEqual for primitives
✓ Use deepStrictEqual for objects
✓ Test error conditions
✓ Be specific in assertions
Avoid:
✗ Testing implementation details
✗ Shared mutable state
✗ Flaky tests
✗ Too many assertions per test
Conclusion#
The Node.js test runner provides a complete testing solution without external dependencies. Use test or describe/it for organization, hooks for setup/cleanup, and the mock API for test doubles. Run with --test flag and add coverage with --experimental-test-coverage.