How to Mock with jest

How to Mock with jest

node.js + jest + typescript

Recently I have started Test Driven Development (TDD) during my work. However, I often feel frustrated of how to mock functions, and I want to share what I have learnt with all of you.

You need mock because you may need to access database or utility functions from third party, like axios. Without an actual database or a working API endpoint, you cannot test them without mocking.

Jest.mock

Let's start by mocking mongoose which is a

Node.js-based Object Data Modeling (ODM) library for MongoDB

Let's say I have a User model and I need to retrieve users from database

import { UserModel } from '../somewhere/user'

export const findUserByUsername = async (username: string) => {
    const user = await UserModel.findOne({ username })
    if (!user) {
        throw Error(`User not found with username: ${username}`)
    }
    return user
}

Then you should mock the findOne function in Mongoose

// your-test.test.ts
import { UserModel } from '../somewhere/user'
import { findUserByUsername } from './somewhere/find-user'

jest.mock('../somewhere/user')

describe('test findUserByUsername', () => {
    it('should throw error if user is not found', async () => {
        const mockedFindOne = UserModel.findOne as jest.Mock
        mockedFindOne.mockResolvedValue(null)
        await expect(findUserByUsername('abc')).rejects.toThrow('User not found with username: abc')
    })
    it('should return user', () => { ... })
})
  1. Import the function you'd like to mock, i.e. UserModel.findOne in this example
  2. Mock the module that the function belongs to, i.e. jest.mock('../somewhere/user')
  3. Declare a mocked function and the value you want it to be. As the function is asynchronous which returns a Promise, mockResolvedValue is used. However, there are other ways to mock a value depends on your use case, like mockReturnValue, mockReturnValueOnce e.t.c.

Important Note:

Using jest.mock('...') will cause all the functions to return undefined by default. Therefore, the above test will also work even I have not mock the return value of UserModel.findOne

it('should throw error if user is not found', async () => {
    // UserModel.findOne will return undefined with jest.mock
    await expect(findUserByUsername('abc')).rejects.toThrow('User not found with username: abc')
})

Jest.spyOn

Sometimes you may have troubles with jest.mock because you may not want to mock the whole module that you really need the actual implementation of some functions. Don't worry! Here is another way:

import { UserModel } from '../somewhere/user'

describe('findUserByUsername', () => {
    it('should throw error if user is not found', async () => {
        jest.spyOn(UserModel, 'findOne').mockResolvedValue(null)
        await expect(findUserByUsername('abc')).rejects.toThrow('User not found with username: abc')
    })
    it.todo('should return user', () => { ... })
})
  1. Import the function you'd like to mock
  2. This time you don't need jest.mock and instead write jest.spyOn([object], [methodName])

It is simple, right? And you can do more than mocking a function, for example you know how many times it is called or what parameter is called with. You can do something like this:

it('should throw error if user is not found', async () => {
    jest.spyOn(UserModel, 'findOne').mockResolvedValue(null)
    await expect(findUserByUsername('abc')).rejects.toThrow('User not found with username: abc')
    expect(UserModel.findOne).toBeCalledWith({ username: 'abc' })
    expect(UserModel.findOne).toBeCalledTimes(1)
})

What if I want to spy on module functions?

Answer: import * as moduleName from './module-to-test'

Let's say we have a function getRandomInt which generates random integer within a range and rollDice which returns an array of random numbers.

// random.ts
export const getRandomInt = (min: number, max: number) => {
    const lowerLimit = Math.ceil(min)
    const upperLimit = Math.floor(max)
    return Math.floor(Math.random() * (upperLimit - lowerLimit + 1)) + lowerLimit
}

export const rollDice = (numOfTimes: number) => [...Array<number>(numOfTimes)].map(() => getRandomInt(1, 6))

If you want to test rollDice, you have two ways to mock a random value, either mock the Math object or mock the getRandomInt function, like this:

// random.test.ts
import * as randomModule from './random'

describe('test rollDice', () => {
    it('should return an array of numbers (mock Math)', () => {
        jest.spyOn(Math, 'random').mockReturnValue(1)
        expect(rollDice(3)).toEqual([1, 1, 1])
    })

    it('should return an array of numbers (mock getRandomInt)', () => {
        jest.spyOn(randomModule, 'getRandomInt').mockReturnValue(1)
        expect(rollDice(3)).toEqual([1, 1, 1])
    })
})

Mock Object Partially

Sometimes you might need to mock the implementation of a method of an object. And you may find jest.spyOn not working because the object is not within the scope of the function you want to test. Let's look at a real world example which tests upload feature to AWS S3 bucket.

import { getSafeEnvConfig } from '../somewhere/config'
import { CaptchaImageServiceConfig } from '../types/config'
import { S3 } from 'aws-sdk'

export type ImageType = 'image/jpeg' | 'image/png'

let defaultS3: S3

const getDefaultS3 = () => {
    if (!defaultS3) {
        const config = getSafeEnvConfig<CaptchaImageServiceConfig>()
        defaultS3 = new S3({
            accessKeyId: config.AWS_ACCESS_KEY_ID,
            secretAccessKey: config.AWS_SECRET_ACCESS_KEY,
            region: config.AWS_REGION,
        })
    }
    return defaultS3
}

export const uploadToS3 = ({
    file,
    fileType,
    fileName,
    s3 = getDefaultS3(),
}: {
    file: S3.Body
    fileType: ImageType
    fileName: string
    s3?: S3
}): Promise<string> => {
    const config = getSafeEnvConfig<CaptchaImageServiceConfig>()
    const params = {
        Bucket: config.AWS_S3_BUCKET,
        Key: fileName,
        Body: file,
        ContentType: fileType,
    }

    return new Promise((resolve, reject) => {
        s3.upload(params, (err: Error, data: S3.ManagedUpload.SendData) => {
            if (err) {
                reject(err)
            } else {
                resolve(data.Location)
            }
        })
    })
}

Explanation: uploadToS3 receive s3 as an optional parameter and it comes from getDefaultS3 function. It reads parameters from getSafeEnvConfig and call the s3.upload method. By the way this is the Singleton design pattern in software engineering.

Why need partial mock? jest.spyOn cannot be used because the s3 object is declared outside of the uploadToS3 function and we need to mock the upload method of the s3 object with the following way:

const mockedUpload = jest.fn()

jest.mock('aws-sdk', () => ({
    ...jest.requireActual('aws-sdk'),
    S3: jest.fn(() => ({
        upload: mockedUpload,
    })),
}))

Explanation: A mock function mockedUpload is declared and we partially mock the upload method of S3 class to be the mock function.

Here is how to test it:

describe('test uploadToS3', () => {
     it('should return image url if upload success', async () => {
        mockedGetSafeEnvConfig.mockReturnValue({
            AWS_ACCESS_KEY_ID: 'id',
            AWS_S3_BUCKET: 'bucket-name',
            AWS_REGION: 'ap-northeast-1',
            AWS_SECRET_ACCESS_KEY: 'key',
        })
        mockedUpload.mockImplementation(
            (params: { Key: string }, cb: (err: unknown, data: { Location: string }) => void) => {
                cb(null, {
                    // mock value of Location to be the file name
                    Location: params.Key,
                })
            },
        )

        await expect(
            uploadToS3({
                file: Buffer.from('test'),
                fileName: 'test-image.jpeg',
                fileType: 'image/jpeg',
            }),
        ).resolves.toBe('test-image.jpeg')
    })
})

That's pretty much it. You have now learnt how to mock functions with jest.mock or jest.spyOn, and methods of an object. I think these can help you mock most of the thing you need during testing. Hope you found this article useful! Happy coding :)