こんにちは。iimonでエンジニアをしている林と申します。 本記事はiimonアドベントカレンダ11日目の記事の続きで前回は基本のjestテストの方法を記載しましたが今回はその続きでmockテストについて記載していこうと思います。
mockの概要
mockとはソフトウェア開発において、テスト対象のコードの外部にある部分を模倣するために使用される関数のことです。ユニットテストにおいてモックの主な目的は、テスト対象のコードが依存している外部システム(データベース、APIなどなど)の振る舞いを模倣し、テストの実行を容易にすることにあります。
例を挙げると
- 外部APIの戻り値がbase64化やサイトupload時のリンクの再生成などの場合、期待通りの戻り値を返す事が難しい。mockを使う事によって任意の値に制御することにより、テスト対象の振る舞いを正確に確認する事が可能。
- 自社の開発途中のAPIやDBに依存している場合は、それらの開発が終わるまでテストが実行不可。その上、それらに障害が起こった場合はこちらのテストまでコケてしまう。
- 外部APIを叩かないのでテストを高速化する事が可能。
などなどです。
スパイの概要
jestにはmockと少し似ているメソッドで設定した関数を監視する役割としてスパイという関数が用意されています。
似ているというのは一部mockとオーバラップしている部分があり、mock化する関数を使用できるのでspyでmock化するコードも度々見受けられます。
モックとスパイの違い
モック
- テストを行う前の段階で特定の振る舞いや戻り値を定義する
- モックを使用することでテスト実行時に特定の関数やメソッドの実際の振る舞いをモックで定義した振る舞いに置き換える事が出来る。
jest.fn()
スパイ
- 関数やメソッドの実際の振る舞いを監視する
- スパイを使用すると関数が「いつ、何回、どのような引数で呼び出されたか」を監視する事が出来る
- スパイは基本的にはテスト実行時の関数やメソッドの実際の振る舞いを置き換えない。
- ただ、上記でも記載している通りモックとスパイは一部オーバラップしているので監視するだけでなく、必要に応じてmockの様にその振る舞いも置き換える事が可能。
jest.spyOn()
- 関数やメソッドの実際の振る舞いを監視する
mockマッチャー紹介
それではmockのマッチャーを紹介していきます。まだまだあるので他にも気になった場合はjestの公式ドキュメントをご確認下さい。
ハンズオンで確認可能にしているので興味ある方はハンズオンで動きの確認をしてくれると嬉しいです。
前回記事での設定
前回の記事で環境構築している人は飛ばして下さい。。。
jestのインストールと設定
mkdir jest-tutorial && cd $_
node.jsを初期化(package.jsonが作成される。)
npm init -y
typescriptをinstall(-Dは --save-devの短縮系)
npm i -D typescript
tsconfig.jsonを作成
npx tsc --init
ts-jestをinstall
- ts-jestはjestでtypescriptのコードをテストことができる。
npm i -D jest @types/jest ts-jest
必要なライブラリは入ったけど、jestはそのままだとtsのテストは出来ないので追加の設定を行う。
まずはjestの設定ファイルを作成
npx ts-jest config:init
[package.json]
scriptのtestの項目をjestに変更する
- これでnpm testコマンドでテストが実行できる様になる。
{ "scripts": { "test": "jest" }, }
mkdir {test,src}
上記までが前回のブログで紹介したプロジェクトの初期設定。
以下でmock用のテストファイルを作成
touch test/mock.test.ts
mockの戻り値のテスト
以下でmockの戻り値を設定してそのテストをしていきます。
まずjest.fn()で新しいモック関数を生成できます。
- 以下はコールバック関数を引数に渡すことによってモック関数の動作を設定しています。
[test/mock.test.ts]
test('mockのテスト', () => { const mockFunc = jest.fn(() => 'return mock value') expect(mockFunc()).toBe('return mock value') })
- mockImplementation
- mockImplementationは上記と挙動は同じだが、空のモックを作った後にメソッドチェーンでモック関数を設定出来るという利点があります。
test('mockImplementationを使ってのテスト', () => { const mockFunc = jest.fn() mockFunc.mockImplementation(() => 'return mock value') expect(mockFunc()).toBe('return mock value') })
- mockReturnValue
- mockReturnValueはmockの戻り値を設定できます。
test('mock関数の戻り値テスト', () => { const mockFunc = jest.fn() mockFunc.mockReturnValue('return mock value') expect(mockFunc()).toBe('return mock value') expect(mockFunc()).toBe('return mock value') expect(mockFunc()).toBe('return mock value') })
- mockReturnValueOnce
- mockReturnValueOnceは一回だけmockの戻り値を設定できます。
test('mock関数の一回だけ返される戻り値テスト', () => { const mockFunc = jest.fn() mockFunc.mockReturnValueOnce('return mock value') expect(mockFunc()).toBe('return mock value') expect(mockFunc()).toBe(undefined) expect(mockFunc()).not.toBe('return mock value') })
- mockResolvedValue
- mockResolvedValueは非同期の戻り値を設定できます。
- mockテストにおいては、promise形式で返り値を返してくれるこのメソッドは重宝するのではないでしょうか。
it("mock関数の非同期な戻り値をテスト", async () => { const mockFunc = jest.fn() mockFunc.mockResolvedValue('resolve value') const result = await mockFunc() expect(result).toBe('resolve value') })
mock関数の監視テスト
以下はmock関数が実際に呼び出された検証したい時に使うメソッドを紹介していきます。
- toHaveBeenCalled
- toHaveBeenCalledはその関数が期待通りに呼び出されているかテストする事ができます。
- 呼び出されてさえいればテストは通ります。
test('mock関数が実際に呼び出されているかのテスト', () => { const mockFunc = jest.fn() mockFunc() expect(mockFunc).toHaveBeenCalled() })
- toHaveBeenCalledTimes
- toHaveBeenCalledTimesは何回呼び出されているかテストできます。
test('mock関数が何回呼び出されているかのテスト', () => { const mockFunc = jest.fn() mockFunc() mockFunc() mockFunc() expect(mockFunc).toHaveBeenCalledTimes(3) })
- toHaveBeenCalledWith
- toHaveBeenCalledWithはmock関数が特定の引数で実行されたかテスト出来ます。ただ、呼び出されているかのみ見ているので順番までは見ていません。
test('mock関数の渡された引数のテスト', () => { const mockFunc = jest.fn() mockFunc('test') mockFunc('test2') mockFunc() expect(mockFunc).toHaveBeenCalledWith('test') expect(mockFunc).toHaveBeenCalledWith('test2') expect(mockFunc).toHaveBeenCalledWith() })
- toHaveBeenNthCalledWith
- toHaveBeenNthCalledWithはmock関数がどの引数でどの順番で実行されたかをテスト出来ます。
test('mock関数の渡された引数と呼び出しの順番が正しいかのテスト', () => { const mockFunc = jest.fn() mockFunc('test') mockFunc('test2') mockFunc() expect(mockFunc).toHaveBeenNthCalledWith(1, 'test') expect(mockFunc).toHaveBeenNthCalledWith(2, 'test2') expect(mockFunc).toHaveBeenNthCalledWith(3) })
それでは実際にプロダクトを使ってテストの記載例を解説していきます。
ブラウザ上でテストするプログラムの動きも確認もしたいのでviteを使用していきます。
- Viteをローカルにインストール
npm install -D vite
package.json
ファイルのscripts
セクションに、Viteを使用して開発サーバーを起動するためのスクリプトを追加します:
"scripts": { "dev": "vite" }
- そしてtestEnvironmentをnodeからブラウザの動きを模倣してくれるjsdomに切り替えます。
[jest.config.js]
/** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest', testEnvironment: 'jsdom', };
そしてjest-environment-jsdomをinstallします
npm install -D jest-environment-jsdom
以下のコマンドでファイルを作成し、コードをコピペして下さい。
touch {index.html,src/dogApi.ts}
[index.html]
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Vite App</title> </head> <body> <div> <img src="https://images.dog.ceo/breeds/borzoi/n02090622_5956.jpg" id="dogImg" alt="dog"> </div> <br> <div> <button id="dogBtn">dog</button> </div> <script type="module" src="/src/dogApi.ts"></script> </body> </html>
[src/dogApi.ts]
export default class DogApi { setDogImageEvent(){ const btn = document.getElementById('dogBtn') if(!btn) return btn.addEventListener('click', async () => { const dogImg = document.getElementById('dogImg') const image = await this.fetchDogImage() if (!(dogImg instanceof HTMLImageElement)) return dogImg.src = image dogImg.style.width = '300px'; dogImg.style.height = '300px'; }) } async fetchDogImage() { try { const res = await fetch('https://dog.ceo/api/breed/akita/images/random') if (!res.ok) { throw new Error(`HTTP error! status: ${res.status}`); } const data = await res.json() const imageUrl = data.message return imageUrl } catch (error) { throw new Error(`${error}`) } } } new DogApi().setDogImageEvent()
viteを以下コマンドで立ち上げて下さい。
npm run dev
こちらはボタンを押す毎に犬の写真が切り替わるプログラムです。
こちらはfetchで無料APIであるdog Apiを叩き犬の画像を取得して表示させてます。
こちらのApiを叩く処理をモック化したテストを書いていきましょう
mkdir test/html touch test/dogApi.test.ts touch test/html/dogApiHtml.ts
[test/html/dogApiHtml.ts]
export const dogApiHtml = `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Vite App</title> </head> <body> <div> <img src="https://images.dog.ceo/breeds/borzoi/n02090622_5956.jpg" id="dogImg" alt="dog"> </div> <br> <div> <button id="dogBtn">dog</button> </div> <script type="module" src="/src/dogApi.ts"></script> </body> </html>` export default dogApiHtml
[dogApi.test.ts]
import DogApi from '../src/dogApi'; import dogApiHtml from './html/dogApiHtml' let instance = new DogApi() const imgTestUrl ='https://testdog/img/testUrl.jpg' document.body.innerHTML = dogApiHtml; const watchImgSrcObserver = (elem: HTMLImageElement) => { return new Promise<void>((resolve) => { const observer = new MutationObserver(() => { observer.disconnect(); resolve(); }); observer.observe(elem, { attributes: true, attributeFilter: ['src'] }); }); } describe('DogApiのテスト', () => { beforeEach(() => { jest.clearAllMocks(); instance = new DogApi(); instance.fetchDogImage = jest.fn().mockResolvedValue(imgTestUrl) }) test('fetchDogImageのmockテスト', async () => { const res = await instance.fetchDogImage() expect(res).toEqual(imgTestUrl) }) test('setDogImageEventのmockテスト', async () => { instance.setDogImageEvent() const dogBtn = document.getElementById('dogBtn') const dogImg = document.getElementById('dogImg') if (!(dogBtn instanceof HTMLButtonElement) || !(dogImg instanceof HTMLImageElement)) return expect(dogImg.src).toEqual('https://images.dog.ceo/breeds/borzoi/n02090622_5956.jpg') dogBtn.click() await watchImgSrcObserver(dogImg); expect(instance.fetchDogImage).toHaveBeenCalled() expect(instance.fetchDogImage).toHaveBeenCalledTimes(1) expect(instance.fetchDogImage).toHaveBeenCalledWith() expect(dogImg.src).toEqual(imgTestUrl) }) })
上画像がテスト結果になります。ちゃんと通ってますね。
- beforeEachでdescribeで纏められたテストの一つ一つを行う前にmockの値を初期化したり、instanceを初期化して再代入を行ったり、別のテストで行われた変更などに影響されないようにしています。これはテストを行う際によく行われる手法です。
- 「fetchDogImageのmockテスト」ではjest.fn().mockResolvedValue(imgTestUrl)によってmock化出来ているので、返り値がimgTestUrlの値になっておりmock化に成功しています。
- 「setDogImageEventのmockテスト」ではdogImg.srcがhttps://images.dog.ceo/breeds/borzoi/n02090622_5956.jpgだったものがdogBtn.click()によってurlが切り替わっているのがわかります。
- watchImgSrcObserverはclickイベントは同期処理ですが、渡されている関数が非同期処理なのでdogImg.srcを監視してsrcが切り替わったらexpectテストを実行しています。
jest.mockメソッド
jest.mockを使うと読み込まれたパスのmoduleが丸ごとmock化されます。第二引数にコールバック関数を渡して、mock化したものを返します。これは全部のメソッドを再度作成する必要があるので個人的にはこのままでは使いたくなく、マニュアルモックとjest.requireActualを併用する場合に使いたいなって思ってます。
jest.mock('../src/dogApi.ts', () => jest.fn(() => { return { fetchDogImage: jest.fn().mockResolvedValue('https://testdog/img/testUrl.jpg'), . . . mock化してないメソッドまで全部の処理を再定義する必要がある。。。。。 }; }) );
マニュアルモックとjest.requireActual
マニュアルモックはテストしたいファイル同階層に__mocks__ディレクトリを作り、その下にテストしたいファイルと同名のものを作るmock化の手法です。
そうすることによりjest.mockメソッドは__mocks__以下にある同名のファイルを読み込んでくれます。
以下を実行してください
mkdir src/__mocks__ touch src/__mocks__/dogApi.ts
[src/mocks/dogApi.ts]
// 指定したmoduleのdefault exportを取得している(DogApi class) const DogApi = jest.requireActual('../dogApi.ts').default const dogApi = new DogApi() // fetchDogImageメソッドのmock化 dogApi.fetchDogImage = jest.fn().mockResolvedValue('https://testdog/img/testUrl.jpg') export = { __esModule: true, default: jest.fn(() => dogApi) };
jest.requireActualは実際のmoduleのメソッドを使えるように出来ます。
.defaultでdefault exportを取得できます。今回はDogApiクラスがdefault exportなのでそちらを取得できます。
その上でdogApiのinstanceクラスを作成して、fetchDogImageをmock化してmockResolvedValueで非同期で値を返しています。
__esModule: trueはjestはnode.jsなのでCommonJSのモジュールシステムがデフォルトですが、ここをtrueにすることによってESモジュールの挙動にしてimportとexportなどのハンドリングが出来ます。
そしてdefault: jest.fn(() => dogApi)としてこのファイルのdefault exportをdogApiを返すmock関数としてます。
import DogApi from '../src/dogApi'; import dogApiHtml from './html/dogApiHtml' jest.mock('../src/dogApi.ts') let instance = new DogApi() const imgTestUrl ='https://testdog/img/testUrl.jpg' document.body.innerHTML = dogApiHtml; const watchImgSrcObserver = (elem: HTMLImageElement) => { return new Promise<void>((resolve) => { const observer = new MutationObserver(() => { observer.disconnect(); resolve(); }); observer.observe(elem, { attributes: true, attributeFilter: ['src'] }); }); } describe('DogApiのManual Mockのテスト', () => { beforeEach(() => { jest.clearAllMocks(); instance = new DogApi(); // instance.fetchDogImage = jest.fn().mockResolvedValue(imgTestUrl) }) test('fetchDogImageのmockテスト', async () => { const res = await instance.fetchDogImage() expect(res).toEqual(imgTestUrl) }) test('setDogImageEventのmockテスト', async () => { instance.setDogImageEvent() const dogBtn = document.getElementById('dogBtn') const dogImg = document.getElementById('dogImg') if (!(dogBtn instanceof HTMLButtonElement) || !(dogImg instanceof HTMLImageElement)) return expect(dogImg.src).toEqual('https://images.dog.ceo/breeds/borzoi/n02090622_5956.jpg') dogBtn.click() await watchImgSrcObserver(dogImg); expect(instance.fetchDogImage).toHaveBeenCalled() expect(instance.fetchDogImage).toHaveBeenCalledTimes(1) expect(instance.fetchDogImage).toHaveBeenCalledWith() expect(dogImg.src).toEqual(imgTestUrl) }) })
マニュアルモックにすることにより上記のように
instance.fetchDogImage = jest.fn().mockResolvedValue(imgTestUrl)のmockは必要なくなり
jest.mock('../src/dogApi.ts')と宣言することによって同じようにテストが通ります。
jest.mock()としたら本来渡されたmoduleの全てがmock化されてしまうのですが、jest.requireActualをマニュアルモック内で行なっていることにより、mock化されていない関数はそのまま使う事ができます。
以下のように問題なくテストが通ってます。 現在のシンプルなコードだけでは分かり辛いかもしれませんが、mock化しているメソッドが複数あったり、dogApiクラスをimportしているファイルが複数ある場合、毎回そのテストファイル内でmockしたい関数の数だけコードを書いていくのは冗長です。
マニュアルモックとjest.requireActualを使えばjest.mock(’path’)の一行でmock化したモジュールの関数と通常の関数を使用出来るのでとても簡潔にmock化が出来ます
spyでのmock化
import DogApi from '../src/dogApi'; import dogApiHtml from './html/dogApiHtml' // jest.mock('../src/dogApi.ts') let instance = new DogApi() const imgTestUrl ='https://testdog/img/testUrl.jpg' document.body.innerHTML = dogApiHtml; const watchImgSrcObserver = (elem: HTMLImageElement) => { return new Promise<void>((resolve) => { const observer = new MutationObserver(() => { observer.disconnect(); resolve(); }); observer.observe(elem, { attributes: true, attributeFilter: ['src'] }); }); } describe('DogApiのManual Mockのテスト', () => { beforeEach(() => { jest.clearAllMocks(); instance = new DogApi(); // instance.fetchDogImage = jest.fn().mockResolvedValue(imgTestUrl) jest.spyOn(instance, 'fetchDogImage').mockResolvedValue(imgTestUrl) }) test('fetchDogImageのmockテスト', async () => { const res = await instance.fetchDogImage() expect(res).toEqual(imgTestUrl) }) test('setDogImageEventのmockテスト', async () => { instance.setDogImageEvent() const dogBtn = document.getElementById('dogBtn') const dogImg = document.getElementById('dogImg') if (!(dogBtn instanceof HTMLButtonElement) || !(dogImg instanceof HTMLImageElement)) return expect(dogImg.src).toEqual('https://images.dog.ceo/breeds/borzoi/n02090622_5956.jpg') dogBtn.click() await watchImgSrcObserver(dogImg); expect(instance.fetchDogImage).toHaveBeenCalled() expect(instance.fetchDogImage).toHaveBeenCalledTimes(1) expect(instance.fetchDogImage).toHaveBeenCalledWith() expect(dogImg.src).toEqual(imgTestUrl) }) })
spyでもmock処理と機能が一部オーバラップしているのでmock化をしたい場合はjest.fn().mockResolvedValue(imgTestUrl)を以下のように書き換えれば良いだけです。
jest.spyOn(instance, 'fetchDogImage').mockResolvedValue(imgTestUrl)
ただ、spyに関しては上記でも記載した通り「いつ、何回、どのような引数で呼び出されたか」を監視する事が主な用途なので、jest.mock()やjest.fn()でmock化するのが基本的な用途に乗っ取った使い方だと思われます。。。
jest.fn()とjest.spyOn()の違い
やはり少し似ているのでハンズオン後に再度mock関数であるjest.fn()とspy関数であるjest.spyOn()の違いを解説します。
jest.fn()はモック関数を作成します。これは完全に新しい関数で、元の関数の動作は全く含まれません。一方、jest.spyOn()は元の関数をラップしてスパイを作成します。これは関数の呼び出しを監視しつつ、元の関数の動作を保持する場合に便利です。つまりスパイは元の関数がどのように動作するかを覚えています。
toHaveBeenCalledとかtoHaveBeenCalledWithなどのメソッドはmockかspyにしか使えないためspyを使う場合はmock化してない関数を監視する場合に使うのが一番用途に合っているのかなって個人的には思います。
まとめ
最後まで読んで下さりありがとうございます。
mockテストまでマスター出来れば外部システムに左右されず、純粋にこちらが作ったメソッドの振る舞いをテスト出来、より安全性が高いプロダクト開発が出来るのでは無いでしょうか。
この記事を読んで興味を持って下さった方がいらっしゃればカジュアルにお話させていただきたく、是非ご応募をお願いします! Wantedly / Green
参照
https://jestjs.io/ja/docs/mock-functions
https://jestjs.io/ja/docs/next/tutorial-jquery
https://jestjs.io/ja/docs/manual-mocks
https://qiita.com/craftect/items/28844664875b89b4e2fb
https://www.shookuro.com/entry/mockito-spy
https://jestjs.io/ja/docs/jest-object#jestrequireactualmodulename
https://jestjs.io/ja/docs/expect#tohavebeencalledtimesnumber