🫣

jest.mockがどう動いてるのか実行されたコードを覗く

2023/08/24に公開

株式会社IVRy (アイブリー)のエンジニアのkinashiです。

IVRyではテストツールとして Jest を使っています。

普段なにげなく使っているモックですが、 import したモジュールをどうやって上書きしてるのか気になったことはありませんか?
呼び出す前に上書きしてるのかなという想像は付きますが、実際どんなコードが動いているのか見てみようと思います。

モックと言っても様々あり、下記の記事で紹介されているようにテストダブルの考え方では Dummy Object や Test Spy など5つのカテゴリに分類されます。
https://zenn.dev/chida/articles/cec625e3b6aa7b

Jestにもこれらのモックを使用するための機能がありますが、この記事ではテスト対象のファイルで import しているモジュールを jest.mock を使用してモックした際の挙動について見ていきます。
https://jestjs.io/ja/docs/jest-object#jestmockmodulename-factory-options

実行環境

下記の package 構成で確認しました。

package.json
{
  "devDependencies": {
    "@types/jest": "^29.5.3",
    "jest": "^29.6.2",
    "ts-jest": "^29.1.1",
    "typescript": "^5.1.6"
  }
}
tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "noEmit": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "types": ["jest"],
  }
}

実行されているコードを覗く

テスト対象コード

引数で渡された text に !!! を付けるだけの関数です。
(この処理のテストならモックする意味もないのですが、実行されているコードを見るのが目的なのでご容赦くださいmm)

import { say, IOptions } from 'cowsay'

export const surpriseCowsay = (options: Omit<IOptions, 'f'>) =>
  say({ ...options, text: `${options.text}!!!` })

準備

Jestのコードをイチから追うのは大変なので、こちらを参考にブラウザでstep実行できるようにします。

package.json
{
  "scripts": {
    "test": "jest",
    "test:debug": "node --inspect-brk ./node_modules/.bin/jest --runInBand"
  }
}

test:debug を 実行し、

$ yarn test:debug

Chrome でインスペクタを開くと Node のアイコンがあるのでクリックすると

ステップ実行できるようになります。

テストコード

テストコードの適当な箇所に debugger を仕込みます。

import * as cowsay from 'cowsay'
import { surpriseCowsay } from '../index'

jest.mock('cowsay')
const cowsayMock = cowsay as jest.Mocked<typeof cowsay>

describe('surpriseCowsay', () => {
  it('sayが期待する引数で呼ばれること', () => {
    debugger
    surpriseCowsay({ text: 'hello', p: true })
    expect(cowsayMock.say).toBeCalled()
    expect(cowsay.say).toBeCalledWith({ text: 'hello', p: true })
  })
})

Jestで実行されているコード

デバッガーが立ち上がったら F8(次のブレイクポイントまで進む) を1度押すと、 debugger を仕込んだところでコードが止まります。

({
    "Object.<anonymous>": function(module, exports, require, __dirname, __filename, jest) {
        "use strict";
        // ~~ 省略 ~~

        jest.mock('cowsay');
        const cowsay = __importStar(require("cowsay"));
        const index_1 = require("../index");
        const cowsayMock = cowsay;
        describe('surpriseCowsay', ()=>{
            it('sayが期待する引数で呼ばれること', ()=>{
                debugger ;(0, // ここで step 実行が止まる
                index_1.surpriseCowsay)({
                    text: 'hello',
                    p: true
                });
                expect(cowsayMock.say).toBeCalled();
                expect(cowsay.say).toBeCalledWith({
                    text: 'hello',
                    p: true
                });
            }
            );
        }
        );
    }
});

jest.mock が最初に実行されていますね。

jest.mock('cowsay');
const cowsay = __importStar(require("cowsay"));

jest.mock

jest.mock の中で何が行われているのか見てみましょう。
jest.mock の箇所にブレイクポイントを設定し、もう一度実行し、ステップインで処理へ飛ぶと

jest-runtime のこの部分の処理が実行されていることが分かります。
https://github.com/jestjs/jest/blob/main/packages/jest-runtime/src/index.ts#L2130-L2143

_explicitShouldMock は Map になっていて
https://github.com/jestjs/jest/blob/main/packages/jest-runtime/src/index.ts#L170

呼び出しているファイルパスやモジュール名をキーにしたものを格納していることが分かります。
https://github.com/jestjs/jest/blob/main/packages/jest-resolve/src/resolver.ts#L544

require

あとは _explicitShouldMock に格納されたものが呼び出されたときにモックを返却している箇所が分かればいいですね!
コードを見る限り require の部分でモックが返却されていそうなので、同じように処理を見てみましょう。

require の実体はこの処理で、モックが登録されている場合はモックを返却していました!
https://github.com/jestjs/jest/blob/main/packages/jest-runtime/src/index.ts#L1123-L1157

番外編 ~ 間違った書き方 ~

jest.mock は describe などの外で呼び出すもので、 describe の中に書いた場合うまく動きません。
このコードがどうなるのか見てみましょう。

import * as cowsay from 'cowsay'
import { surpriseCowsay } from '../index'

describe('surpriseCowsay', () => {
  jest.mock('cowsay') // 間違ったモックの指定
  const cowsayMock = cowsay as jest.Mocked<typeof cowsay>

  it('sayが期待する引数で呼ばれること', () => {
    debugger
    surpriseCowsay({ text: 'hello', p: true })
    expect(cowsayMock.say).toBeCalled()
    expect(cowsay.say).toBeCalledWith({ text: 'hello', p: true })
  })
})

実行されるコード

jest.mock より先に require が呼び出されているので、これだとうまく動かないですね。
仕組みを理解して見てみると納得です。

最後に

IVRyでは一緒に働いてくれるエンジニアを募集中です!
https://ivry-jp.notion.site/IVRy-e1d47e4a79ba4f9d8a891fc938e02271

 FAIL  __tests__/blog.test.ts
  surpriseCowsay
    ✕ should return Thank you for reading (3 ms)

  ● surpriseCowsay › should return Thank you for reading

    expect(received).toBe(expected) // Object.is equality

    - Expected  - 1
    + Received  + 8

    - Thank you for reading
    +  __________________________
    + < Thank you for reading!!! >
    +  --------------------------
    +         \   ^__^
    +          \  (oo)\_______
    +             (__)\       )\/\
    +                 ||----w |
    +                 ||     ||
IVRyテックブログ

Discussion