jest.mockがどう動いてるのか実行されたコードを覗く
株式会社IVRy (アイブリー)のエンジニアのkinashiです。
IVRyではテストツールとして Jest を使っています。
普段なにげなく使っているモックですが、 import したモジュールをどうやって上書きしてるのか気になったことはありませんか?
呼び出す前に上書きしてるのかなという想像は付きますが、実際どんなコードが動いているのか見てみようと思います。
モックと言っても様々あり、下記の記事で紹介されているようにテストダブルの考え方では Dummy Object や Test Spy など5つのカテゴリに分類されます。
Jestにもこれらのモックを使用するための機能がありますが、この記事ではテスト対象のファイルで import しているモジュールを jest.mock
を使用してモックした際の挙動について見ていきます。
実行環境
下記の package 構成で確認しました。
{
"devDependencies": {
"@types/jest": "^29.5.3",
"jest": "^29.6.2",
"ts-jest": "^29.1.1",
"typescript": "^5.1.6"
}
}
{
"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実行できるようにします。
{
"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
のこの部分の処理が実行されていることが分かります。
_explicitShouldMock
は Map になっていて
呼び出しているファイルパスやモジュール名をキーにしたものを格納していることが分かります。
require
あとは _explicitShouldMock
に格納されたものが呼び出されたときにモックを返却している箇所が分かればいいですね!
コードを見る限り require の部分でモックが返却されていそうなので、同じように処理を見てみましょう。
require
の実体はこの処理で、モックが登録されている場合はモックを返却していました!
番外編 ~ 間違った書き方 ~
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では一緒に働いてくれるエンジニアを募集中です!
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 |
+ || ||
Discussion