Jest + TypeScript でテストを書くことが増えてきたので、この辺でTipsを記載しておこうと思います。今後も何かあれば追記します。

インストール

こちら を参考にしました。

まず、必要なライブラリを追加します。

yarn init
yarn add jest @types/jest ts-jest --dev

jest.config.jsをプロジェクト直下に作成します。

module.exports = {
  "testMatch": [
    "**/__tests__/**/*.+(ts|tsx|js)",
    "**/?(*.)+(spec|test).+(ts|tsx|js)"
  ],
  "transform": {
    "^.+\\.(ts|tsx)$": "ts-jest"
  },
}

package.jsonにスクリプトを追加します。

  "scripts": {
    "test": "jest"
  },

あとは、以下で実行できます。

yarn test

フォルダ構成は以下のような感じです。

.
├── __tests__
├── dist
├── src
├── jest.config.js
├── package.json
├── tsconfig.json
└── yarn.lock

モジュール直下の関数のテスト

テスト対象の関数が、外部からインポートしたモジュール直下の関数を利用しているケースのテストの書き方を紹介します。

テスト対象のコード

今回、テスト対象のコードとして以下のような二つのコードを用意しました。

  • テスト対象のコード(useModuleMethod.ts
    与えられた文字列を2回繰り返すtwice関数と、外部のモジュール関数を呼び出して計5回繰り返すtwiceAndThrice関数があるだけです(特に処理内容には意味はありません)
    インポートして利用してるthrice関数をモックしてテストする想定です。

    import { thrice } from './otherModule';
    
    /** 文字列を2回繰り返す */
    export const twice = (str: string): string => {
      return str + str;
    };
    
    /** 文字列を2回+3回で合計5回繰り返す */
    export const twiceAndThrice = (str: string): string => {
      return twice(str) + thrice(str); // thriceがモジュール関数
    };
  • 外部モジュール(otherModule.ts
    /** 文字列を3回繰り返す */
    export const thrice = (str: string): string => {
      return str + str + str;
    };

このコードを使って spyOnmock を利用する基本的なテストコードを実装してみます。

spyOnを使ってテスト

spyOnを使う場合のポイントは以下の通りです。

  • モジュール関数をspyOnする場合は、モジュール全体を*でimportで別名をつける
  • jest.spyOn(otherModule, 'thrice')で、otherModule直下のthrice関数をスパイする
    • この時点では、thrice関数を監視下に置いただけで、振る舞いは実関数のまま
  • spy.mockReturnValueOnce('mock')で、関数の戻り値をモック

実際のソースコード(targetSpy.test.ts)は以下の通りです。

import { twice, twiceAndThrice } from '../../src/jest/useModuleMethod';
import * as otherModule from '../../src/jest/otherModule'; // `*` importして別名をつける

describe('with spy', () => {
  test('twiceAndThrice', () => {
    const spy = jest.spyOn(otherModule, 'thrice'); // 内部で呼び出しているmodule関数をspy
    spy.mockReturnValueOnce('mock'); // thrice関数の戻り値をモック
    const actual = twiceAndThrice('input');
    expect(actual).toBe('inputinputmock'); // inputが2回とmock値が連結された結果が返っている
  });
});

describe('real', () => {
  test('twice', () => {
    const actual = twice('input');
    expect(actual).toBe('inputinput'); // inputが2回繰り返された結果が返っている
  });

  test('twiceAndThrice', () => {
    const actual = twiceAndThrice('input');
    expect(actual).toBe('inputinputinputinputinput'); // inputが5回繰り返された結果が返っている
  });
});

自分の所感ですが、後述のmockを使うよりも、spyOnを使う方が以下の点で優れているので、基本はspyOnを使う方がオススメです。

  • spyOnは関数スコープに閉じてるので、モッククリアなどをやらなくても他のテストには影響を与えない
    • mockを使ったケースで、モッククリアを忘れて他のテストがエラーになってしまうケースは調査が大変
  • クラスやモジュールの一部の関数だけモックすることができる
  • 同じ関数をモックしたりしなかったり、テスト毎に切り替えられる

mockを使ってテスト

mockを使う場合のポイントは以下の通りです。

  • jest.mock()でモジュール、クラス、関数をモックする
    • この時点で、全ての関数は何もせずundefinedを返すようになる
  • 記述する場所は、関数の中ではなくトップレベルにする必要がある
  • TypeScript の場合は、jest.Mock型にキャストすることで、モックオブジェクトにアクセスできる
  • 他の関数に影響を与えるので、jest.clearAllMocks()等でモック定義を一旦クリアする必要がある

実際のソースコード(useModuleMethodMock.test.ts)は以下の通りです。

import { twice, twiceAndThrice } from '../../src/jest/useModuleMethod';
import { thrice } from '../../src/jest/otherModule';

jest.mock('../../src/jest/otherModule'); // 対象のモジュールをモックする。関数の中ではなくトップレベルに記載必要

/** 各テスト実行前に行われる共通処理 */
beforeEach(() => {
  jest.clearAllMocks(); // モックをクリアする
});

describe('with mock', () => {
  test('twiceAndThrice', () => {
    const mockThrice = thrice as jest.Mock; // typescriptの場合はキャストが必要
    mockThrice.mockReturnValueOnce('mock'); // モック関数の戻り値を指定
    const actual = twiceAndThrice('input');
    expect(actual).toBe('inputinputmock'); // 本来はinputが5回繰り返されるが、内部で呼び出しているthrice関数の戻り値部分がmockになっている
    expect(mockThrice.mock.calls.length).toBe(1); // mock関数の呼び出し回数チェック
    expect(mockThrice.mock.calls[0][0]).toBe('input'); // mock関数の1回目の呼び出しの第1引数チェック
  });
});

describe('real', () => {
  test('twice', () => {
    const actual = twice('input');
    expect(actual).toBe('inputinput'); // inputが2回繰り返された結果が返っている
  });
});

記述方法に差異はありますが、基本的にはspyOnと同等のことができます。
とはいえ、mockの方が色々と気をつけるべきことが多いので、特に理由がなければ何も考えずにspyOnを利用する方がいいと思います。

クラス直下の関数のテスト

テスト対象の関数が、外部からインポートしたクラス直下の関数を利用しているケースのテストの書き方を紹介します。

テスト対象のコード

今回、テスト対象のコードとして以下のような二つのコードを用意しました。

  • テスト対象のコード(useClassMethod.ts
    与えられた文字列に対して、static関数を呼び出して固定文字列を追加する関数と、instance関数を呼び出して固定文字列を追加する関数を提供しています。(特に処理内容には意味はありません)
    インポートして利用しているstatic関数とinstance関数をモックしててテストする想定です。

    import OtherClass from './otherClass';
    
    /** 固定文字列を追加する(classのstatic関数を利用) */
    export const plusFixedStrWithStaticMethod = (str: string): string => {
      return OtherClass.plusFixedStrByStaticMethod(str);
    };
    
    /** 固定文字列を追加する(classのinstance関数を利用) */
    export const plusFixedStrWithInstanceMethod = (str: string): string => {
      return new OtherClass().plusFixedStrByInstanceMethod(str);
    };
  • 外部モジュール(otherClass.ts

    export default class OtherClass {
      static fixedStr = 'fixed';
    
      // static関数
      static plusFixedStrByStaticMethod(str: string): string {
        return str + this.fixedStr;
      }
    
      // instance関数
      plusFixedStrByInstanceMethod(str: string): string {
        return str + OtherClass.fixedStr;
      }
    }

このコードを使って spyOnmock を利用する基本的なテストコードを実装してみます。

spyOnを使ってテスト

クラス直下の関数をspyOnす流場合は、以下の点だけ気を付けておく必要があります。

  • static関数の場合
    • モックしたい関数が所属するクラスをインポートしてjest.spyOn(OtherClass, 'plusFixedStrByStaticMethod')のように、第一引数にクラスを、第二引数に関数名を指定する
  • instance関数の場合
    • モックしたい関数が所属するクラスをインポートしてjest.spyOn(OtherClass.prototype, 'plusFixedStrByInstanceMethod')のように、第一引数にクラスのprototypeを、第二引数に関数名を指定する

後は、モジュール関数のケースと同じです。
実際のテストコード(useClassMethodSpy.test.ts)は以下の通りです。

import { plusFixedStrWithStaticMethod, plusFixedStrWithInstanceMethod } from '../../src/jest/useClassMethod';
import OtherClass from '../../src/jest/otherClass';

describe('static method', () => {
  test('real', () => {
    const actual = plusFixedStrWithStaticMethod('input');
    expect(actual).toBe('inputfixed'); // input+固定値が返っている
  });

  test('with spy', () => {
    const spy = jest.spyOn(OtherClass, 'plusFixedStrByStaticMethod'); // 内部で呼び出してるstatic関数をspy
    spy.mockReturnValueOnce('mock');
    const actual = plusFixedStrWithStaticMethod('input');
    expect(actual).toBe('mock'); // モック値が返っている
    expect(spy.mock.calls.length).toBe(1); // mock関数の呼び出し回数チェック
    expect(spy.mock.calls[0][0]).toBe('input'); // mock関数の1回目の呼び出しの第一引数チェック
  });
});

describe('instance method', () => {
  test('real', () => {
    const actual = plusFixedStrWithInstanceMethod('input');
    expect(actual).toBe('inputfixed'); // input+固定値が返っている
  });

  test('with spy', () => {
    const spy = jest.spyOn(OtherClass.prototype, 'plusFixedStrByInstanceMethod'); // 内部で呼び出してるinstance関数をspy
    spy.mockReturnValueOnce('mock');
    const actual = plusFixedStrWithInstanceMethod('input');
    expect(actual).toBe('mock'); // モック値が返っている
    expect(spy.mock.calls.length).toBe(1); // mock関数の呼び出し回数チェック
    expect(spy.mock.calls[0][0]).toBe('input'); // mock関数の1回目の呼び出しの第1引数チェック
  });
});

mockを使ってテスト

クラス直下の関数をmockする方法は、モジュール関数のケースとほぼ同じです。
一点だけ、クラス直下のinstance関数をモックしたい場合だけ、OtherClass.prototype.plusFixedStrByInstanceMethod as jest.Mockのように、prototypeを利用する必要があります。

実際のテストコード(useClassMethodMock.test.ts)は以下の通りです。

import { plusFixedStrWithStaticMethod, plusFixedStrWithInstanceMethod } from '../../src/jest/useClassMethod';
import OtherClass from '../../src/jest/otherClass';

// 対象のモジュールをモックする。関数の中ではなくトップレベルに記載必要
jest.mock('../../src/jest/otherClass');

beforeEach(() => {
  jest.clearAllMocks(); // モックをクリアする
});

test('mock static method', () => {
  const mockPlusFixedStrByStaticMethod = OtherClass.plusFixedStrByStaticMethod as jest.Mock;
  mockPlusFixedStrByStaticMethod.mockReturnValueOnce('mock'); // 内部で呼び出しているstatic関数をmock
  const actual = plusFixedStrWithStaticMethod('input');
  expect(actual).toBe('mock'); // モック値が返っている
  expect(mockPlusFixedStrByStaticMethod.mock.calls.length).toBe(1); // mock関数の呼び出し回数チェック
  expect(mockPlusFixedStrByStaticMethod.mock.calls[0][0]).toBe('input'); // mock関数の1回目の呼び出しの第一引数チェック
});

test('mock instance method', () => {
  const mockPlusFixedStrByInstanceMethod = OtherClass.prototype.plusFixedStrByInstanceMethod as jest.Mock;
  mockPlusFixedStrByInstanceMethod.mockReturnValueOnce('mock'); // 内部で呼び出しているinstance関数をmock
  const actual = plusFixedStrWithInstanceMethod('input');
  expect(actual).toBe('mock'); // モック値が返っている
  expect(mockPlusFixedStrByInstanceMethod.mock.calls.length).toBe(1); // mock関数の呼び出し回数チェック
  expect(mockPlusFixedStrByInstanceMethod.mock.calls[0][0]).toBe('input'); // mock関数の1回目の呼び出しの第一引数チェック
});

こちらも、モジュール関数の時と同じように気にすべき点がいくつかあるので、こちらも利用せず、spyOnを利用した方が良いと思います。

Tips

async 関数のモックの戻り値指定

Promise.resolve()する。

jest.spyOn(other, 'plusStatic').mockReturnValue(Promise.resolve('hoge'));

クラス直下のinstance関数のspyOnが上手くいかない

自分が経験したケースとして、クラス直下のinstance関数fugaをspyOnしようとして、

jest.spyOn(Hoge, 'fuga')

のように実装したところ、以下のようなエラーに直面したことがありました。

Cannot spy the fuga property because it is not a function; undefined given instead

直接の原因は、prototype下に指定した名前(fuga)の関数が存在しないためでした。
で、prototype下に対象関数が存在しなかった原因ですが、これは以下のようにプロパティとして関数を定義していたためでした。

class Hoge {
  fuga = () => {
    // do something
  }
}

この実装では、prototype下にfugaが含まれません。

対応方法ですが、Hogeクラスを自由に変更できるのであれば、プロパティとして関数定義するのではなく、普通に関数定義すればOKです。

class Hoge {
  fuga() {
    // do something
  };
}

Hogeクラスを変更できない事情がある場合は、spyOnを諦めて、mockを使ってHogeクラス全体をモックするしかない気がします。

デバッグ

VS Code の Jest プラグイン を使っています。
インストールするだけで、特に何かを設定する必要もなく利用できます。
ブレークポイントを貼って、debugボタンをクリックするだけで普通にデバッグできます。

CI on Linux だけエラー

LinuxのDockerイメージを使って、CIでテストを実行した時だけエラーになるケースがあります。
エラーの内容は、例えば、以下のような感じです。

FAIL __tests__/hoge/fuga.spec.ts
  ● Test suite failed to run
    Cannot find module '../../../src/hoge/fuga/Piyo' from 'fuga.spec.ts'
    > 10 | jest.mock("../../../src/hoge/fuga/Piyo");

このケースは、 OS X では、パスの大文字小文字を区別しないけどLinuxでは区別することに起因する、ファイル名の大文字小文字問題 なことが多いです。
例は、src/hoge/fuga/piyo.tsモジュールをsrc/hoge/fuga/Piyoというパスで探して見つからない状況です。もちろん、OS X 上で実行する分には問題なく動きます。

いつも jest の設定やバージョンを調べて何かミスってないかを確認して時間を潰してしまうので、自戒を込めてメモっておきます。

最後に

最初の頃は、Typescript + Jest でテストを書く際、mockを使うことが多かったですが、後にspyOnの方が多くのケースに対応できるし、他のテストに影響を与えないことを知って、最近はspyOnばかり使うようになりました。

今のところ、ここで記載している範囲の知識でも、Jest で何かをテストしようとして実現できなかったことは無いので、Jest は結構使いやすい気がします。