Back to Blog
Node.jsTestingTest RunnerBuilt-in

Node.js Test Runner Guide

Master the built-in Node.js test runner for writing and running tests without external frameworks.

B
Bootspring Team
Engineering
October 10, 2019
7 min read

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-snapshots

Coverage#

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=4

Best 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.

Share this article

Help spread the word about Bootspring