DEV Community

Cover image for Spies and mocking with Node.js test runner (node:test)
Željko Šević
Željko Šević

Posted on • Originally published at sevic.dev on

Spies and mocking with Node.js test runner (node:test)

Node.js version 20 brings a stable test runner so you can run tests inside *.test.js files with node --test command. This post covers the primary usage of it regarding spies and mocking for the unit tests.

Spies are functions that let you spy on the behavior of functions called indirectly by some other code while mocking injects test values into the code during the tests.

mock.method can create spies and mock async, rejected async, sync, chained methods, and external and built-in modules.

  • Async function
import assert from 'node:assert/strict';
import { describe, it, mock } from 'node:test';

const calculationService = {
  calculate: () => // implementation
};

describe('mocking resolved value', () => {
  it('should resolve mocked value', async () => {
    const value = 2;
    mock.method(calculationService, 'calculate', async () => value);

    const result = await calculationService.calculate();

    assert.equal(result, value);
  });
});
Enter fullscreen mode Exit fullscreen mode
  • Rejected async function
const error = new Error('some error message');
mock.method(calculationService, 'calculate', async () => Promise.reject(error));

await assert.rejects(async () => calculateSomething(calculationService), error);
Enter fullscreen mode Exit fullscreen mode
  • Sync function
mock.method(calculationService, 'calculate', () => value);
Enter fullscreen mode Exit fullscreen mode
  • Chained methods
mock.method(calculationService, 'get', () => calculationService);
mock.method(calculationService, 'calculate', async () => value);

const result = await calculationService.get().calculate();
Enter fullscreen mode Exit fullscreen mode
  • External modules
import axios from 'axios';

mock.method(axios, 'get', async () => ({ data: value }));
Enter fullscreen mode Exit fullscreen mode
  • Built-in modules
import fs from 'fs/promises';

mock.method(fs, 'readFile', async () => fileContent);
Enter fullscreen mode Exit fullscreen mode
  • Async and sync functions called multiple times can be mocked with different values using context.mock.fn and mockedFunction.mock.mockImplementationOnce.
describe('mocking same method multiple times with different values', () => {
  it('should resolve mocked values', async (context) => {
    const firstValue = 2;
    const secondValue = 3;
    const calculateMock = context.mock.fn(calculationService.calculate);
    calculateMock.mock.mockImplementationOnce(async () => firstValue, 0);
    calculateMock.mock.mockImplementationOnce(async () => secondValue, 1);

    const firstResult = await calculateMock();
    const secondResult = await calculateMock();

    assert.equal(firstResult, firstValue);
    assert.equal(secondResult, secondValue);
  });
});
Enter fullscreen mode Exit fullscreen mode
  • To assert called arguments for a spy, use mockedFunction.mock.calls[0] value.
mock.method(calculationService, 'calculate');

await calculateSomething(calculationService, firstValue, secondValue);

const call = calculationService.calculate.mock.calls[0];
assert.deepEqual(call.arguments, [firstValue, secondValue]);
Enter fullscreen mode Exit fullscreen mode
  • To assert skipped call for a spy, use mockedFunction.mock.calls.length value.
mock.method(calculationService, 'calculate');

assert.equal(calculationService.calculate.mock.calls.length, 0);
Enter fullscreen mode Exit fullscreen mode
  • To assert how many times mocked function is called, use mockedFunction.mock.calls.length value.
mock.method(calculationService, 'calculate');

calculationService.calculate(3);
calculationService.calculate(2);

assert.equal(calculationService.calculate.mock.calls.length, 2);
Enter fullscreen mode Exit fullscreen mode
  • To assert called arguments for the exact call when a mocked function is called multiple times, an assertion can be done using mockedFunction.mock.calls[index] and call.arguments values.
const calculateMock = context.mock.fn(calculationService.calculate);
calculateMock.mock.mockImplementationOnce((a) => a + 2, 0);
calculateMock.mock.mockImplementationOnce((a) => a + 3, 1);

calculateMock(firstValue);
calculateMock(secondValue);

[firstValue, secondValue].forEach((argument, index) => {
  const call = calculateMock.mock.calls[index];

  assert.deepEqual(call.arguments, [argument]);
});
Enter fullscreen mode Exit fullscreen mode

Running TypeScript tests

Node.js can run .ts test files natively with node --test. Since Node 22.18 / 23.6, type stripping is enabled by default for erasable TypeScript syntax (type annotations, interfaces, import type, etc.).

{
  "type": "module",
  "scripts": {
    "test": "node --test",
    "test:ts": "node --test ./src/**/*.{spec,test}.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

Requires Node >= 22.18 (or >= 23.6). Node strips types but does not type-check - run tsc --noEmit separately if you want type checking.

For non-erasable features (enums, namespaces with runtime code, parameter properties), use a loader like tsx (node --import=tsx --test ...) or compile with tsc first.

Demo

Runnable tests for each section live in the node-test-spies-mocking-demo folder. Get access via code demos.

Top comments (0)