Zebra Codes

How to Mock a Library with NodeJS, TypeScript, and Jest

18th of September, 2023

The ability to provide a mock implementation of functionality is essential to unit testing, however the method for doing so with Jest and TypeScript is not well documented. This tutorial aims to elucidate the method for mocking a JavaScript library that is loaded into your TypeScript project. This is demonstrated with the ws WebSocket library but applies equally to any library, including built-in NodeJS modules.

Applicability

Please note that the information here only applies to JavaScript modules that you wish to mock. If you wish to mock a TypeScript class (one defined in a *.ts file as export default class MyClass { ... } then the procedure is different.

Prerequisites

  • NodeJS v18+
  • TS Node: A TypeScript execution environment for NodeJS.
  • Jest v29+: The unit testing framework.
  • TS Jest: Allow Jest to test projects written in TypeScript.
  • Jest types: TypeScript type definitions for Jest functions.

Note that I will be using ts-jest instead of installing Babel manually. The requirements can be installed with:

npm install --save-dev @jest/globals @types/jest jest ts-jest ts-node

The library I will be mocking is the WebSocket library ws. Modify the examples to suit whichever library you are using.

# Install the WebSocket library.
npm install ws

# ws is JavaScript so type annotations for TypeScript must be installed separately.
npm install --save-dev @types/ws

You will need to configure jest by using the creating the file jest.config.js as described in the ts-jest documentation:

npx ts-jest config:init

You will also need a TypeScript configuration file tsconfig.json. Here is mine for reference:

{
    "compilerOptions": {
        "lib": [
            "es2023"
        ],
        "module": "Node16",
        "target": "ES2022",
        "strict": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "moduleResolution": "Node16",
        "esModuleInterop": true,
        "noImplicitAny": false
    }
}

Finally: create your test file as tests/my.test.ts. You can then run the test using npx jest.

TL;DR: A Full Example

// Import your library using "import * as ...".
import * as ws from 'ws';

// Tell Jest to mock all the functions and classes in the library.
jest.mock('ws');

// This is how to access a mocked class.
// This lets you do things like count the number of calls to the constructor.
const MockWebSocket = ws.WebSocket as jest.MockedClass<typeof ws.WebSocket>;
MockWebSocket.mockClear();

// This is how to create a mock object.
const socket = new ws.WebSocket('') as jest.MockedObjectDeep<ws.WebSocket>;

// Set the implementation of a mocked function.
socket.close.mockImplementation(() => console.log('Closed the socket'));

Detailed Explanation

Import

The first step is to import your library. In classic NodeJS you would have written const ws = require('ws'). In TypeScript you must write import * as ws from 'ws'. Do not forget the * as part. This tells it to import everything from the module and make it accessible as properties of the object ws.

Replace the Module with a Mock

Next, tell Jest to replace everything in the library with mock implementations using jest.mock('ws'). Note that this function call gets hoisted to the top of the file, so it actually runs before the import statement. It hooks into the import call and returns mock items instead of real items.

Access the Mock Constructor

const MockWebSocket = ws.WebSocket as jest.MockedClass<typeof ws.WebSocket>;
MockWebSocket.mockClear();

Here, ws.WebSocket is the constructor function for the WebSocket class. TypeScript does not know that its implementation was replaced when jest.mock('ws') hooked the import statement, so we must tell it by casting it to jest.MockedClass<typeof ws.WebSocket>. This cast maintains the constructor function’s original signature and augments it with Jest’s mocking functions such as mockClear() and mockImplementation(). The result is saved into MockWebSocket.

It should be noted that although MockWebSocket is itself a constructor function, its return type is WebSocket, not MockWebSocket. This means that you cannot instantiate a MockWebSocket by using new MockWebSocket.

Instantiate a Mock Object

const socket = new ws.WebSocket('') as jest.MockedObjectDeep<ws.WebSocket>;

To create a mock object use new to create the object as normal, and then cast it to a MockedObjectDeep. As with accessing the constructor, new ws.WebSocket actually constructs a mocked WebSocket due to jest.mock() having swapped out the implementation of the constructor during the import, but TypeScript doesn’t know this and so the cast is required to tell it.

Implement a Mock Function

To add functionality to your mock object you can use Jest’s mockImplementation() function. For example, we may wish to ensure that send() is not called after close().

To accomplish this we will add a new property to the mock called isClosed, and provide implementations for send() and close().

// Extend the mocked WebSocket to give it an 'isClosed' property.
type MockedSocket = jest.MockedObjectDeep<ws.WebSocket> & { isClosed: boolean }

// Create the mock socket.
const socket = new ws.WebSocket('') as MockedSocket;
socket.isClosed = false;

// Implement the close() and send() functions.
socket.close.mockImplementation(() => socket.isClosed = true);
socket.send.mockImplementation(() => { if (socket.isClosed) throw new Error('Sending after closing'); });

// Run the code under test, using the mock socket.
// testMyCode(socket);

Constructor Parameters

It is slightly unfortunate that the mock class constructors take the same parameters as the real class constructors. This means that you must pass valid and correctly typed arguments to your mock object constructors, even though they will not be used. This can result in having to create a lot of unnecessary mocks as you need to mock the class’s dependencies, the dependencies’ dependencies, and so on.