豆腐とコンソメ

豆腐とコンソメ

もろもろのプログラム勉強記録

jestのmockを学ぶ

モックを学ぶ

jestを使っていてモックで毎回あれ?ってなるので備忘録

関連 www.tohuandkonsome.site

前提

テスト対象のtarget.js

import getName, { getAge } from "./service";

// 関数の場合
export default (id: string) => {
  const { name } = getName(id);
  const { age } = getAge(id);
  return { name, age };
};

// クラスの場合
export const verClass = (id: string) => {
  const member = new Member(id);
  const { name } = member.getName();
  const { age } = member.getAge();
  return { name, age };
};

モック対象のservice.js

const getName = (id: string) => {
  return { name: `${id}_tarou` };
};

export default getName;

export const getAge = (id: string) => {
  return { age: `${id}_21` };
};

export class Member {
  id: string;
  constructor(id: string) {
    this.id = id;
  }
  getName() {
    return { name: `${this.id}_tarou` };
  }
  getAge() {
    return { age: `${this.id}_21` };
  }
}

基本のテスト

import target from "../../target";

it("targetにtarouを設定して実行すると{result: 'tarou'}}が返却されること", () => {
  // 関数ver
  expect(target("01")).toEqual({ name: "01_tarou", age: "01_21" });
  // クラスver
  expect(verClass("01")).toEqual({ name: "01_tarou", age: "01_21" });
});

最初のモック

import target from "../../target";

// serviceをモックする
jest.mock("../../service");

it("省略");

jest.mockでモックしたいモジュールを指定する。ここでは、パスは、sample.test.tsからの相対パスを指定している。

jest.mockの第 2 引数にモックしたモジュールの実装を書くことができる。 何も書かないと、undefinedを返す関数が設定される。

イメージとしてはモックされたservice.tsはこんな感じのコードに置き換えられる。

const getName = () => {};
export const getAge = () => {};
export const Member = () => {};
export default getName;

この状態でテストを実行すると、テストが失敗することが確認できる。

 モックの実装を書く

default exportだけのモックを書くだけであれば、以下のようにシンプルに書ける。

jest.mock('../../service', () => {
  return (id:string) => {
    return { name: `${id}_tarou` };
  }
}

アロー関数なのでreturnすればよりシンプルになるけど、ひとまずそのまま。

上記の状態だと、モックされたservice.tsは以下のように置き換えられる。

// default export だけ置き換わる
const getName = (id: string) => {
  return { name: `${id}_tarou` };
};
export const getAge = () => {};
export const Member = () => {};
export default getName;

あんまり使う局面はないかもしれないけど、default export だけじゃなくってnamed exportもモックする場合は、__esModuleオプションの設定すれば対応できる。

jest.mock('../../service', () => {
  return {
    __esModule:true,
    // defualt export
    default: (id:string) => { return { name:`${id}_tarou` }},
    // named export
    getAge: (id:string) => { return { age: `${id}_21` }},
  }
}

上記に加え、named export されているMemberクラスのモックも追加する。

jest.mock('../../service', () => {
  // ES2015より前のクラス構文をちょうど学んだので、こっちで書いてみる
  // 特に意味はない
  const Member = function(this: any, id:string) {
    this.id = id
  }
  Member.prototype = {
    getName: function() {
      return { name: `${this.id}_tarou` }
    },
    getAge: function() {
      return { age: `${this.id}_21` }
    }
  }

  // 通常は、実際の実装と同じようににクラス構文を使えばいい
  //class Member {
  //  id:string
  //  constructor(id:string) {
  //    this.id = id
  //  }
  //  getName() {
  //    return { name: `${this.id}_tarou` }
  //  }
  //  getAge() {
  //    return { age: `${this.id}_21` }
  //  }
  //}

  return {
    __esModule:true,
    default: (id:string) => { return { name:`${id}_tarou` }},
    getAge: (id:string) => { return { age: `${id}_21` }},
    Member
  }

jest.mock のハマりどころ

さきほどまでは、jest.mock配下にMemberクラスの実装を書いていた。

jest.mock("../../service", () => {
  // 内容は省略
  class Member {}

  return {
    __esModule: true,
    default: (id: string) => {
      return { name: `${id}_tarou` };
    },
    getAge: (id: string) => {
      return { age: `${id}_21` };
    },
    Member
  };
});

jest.mockに設定する関数が長くなるので、見通しがわるくるなるので、以下のようにMemberjest.mockの外に出してみる。

// 外に出してみた
// 内容は省略
class Member {}

jest.mock("../../service", () => {
  return {
    __esModule: true,
    default: (id: string) => {
      return { name: `${id}_tarou` };
    },
    getAge: (id: string) => {
      return { age: `${id}_21` };
    },
    Member
  };
});

この場合エラーになる。 なぜならjest.mockで設定した内容は、jest 実行時にコードの先頭箇所へと hoisting される。 具体的には以下のような実行順序になる。

jest.mock("../../service", () => {
  return {
    __esModule: true,
    default: (id: string) => {
      return { name: `${id}_tarou` };
    },
    getAge: (id: string) => {
      return { age: `${id}_21` };
    },
    Member // undefined
  };
});

class Member {}

※ function から始まる関数宣言であれば、気にしなくてもよさそうだけど、あんまり使わない方がよいかな。 https://qiita.com/jkr_2255/items/9f9a25987dfaa81472fa

mockfn.mockImplementation で回避する

ここまでは、jest.mockにモックするモジュールの実装を直接書いてきたが、モック関数を使うとより便利になる。
モック関数はjest.fnで作成できて、そのモックがどんな引数で呼ばれたのか?何回呼ばれたの?とかをアサートすることができる。

ここでは、モック関数の実装を書くことができるmockImplementationを使うことで、jest.mockでは hosting されることでできなかったことを対応してみる。

// 2:ここで読み込むserviceモジュールは、既にモックが適用されている状態になる
// つまり、MockedMember = jest.fn()
import { Member as MockedMember}  from '../../service'

// モックするMemberの実装
class Member {}

// 1:serviceのモックの設定
jest.mock("../../service", () => {
  return {
    __esModule: true,
    default: (id: string) => {
      return { name: `${id}_tarou` };
    },
    getAge: (id: string) => {
      return { age: `${id}_21` };
    },
    // Memberにはクラスではなくモック関数をセットしておく。
    Member: jest.fn()
  };
});

// 3: Typescriptをつかっている場合、モック状態のクラスにモック関数のメソッドがあることを教えてあげるのでキャストする必要がある
// jest.Mocked, jest.MockedClass とかあるけど違いがよくわかってない
const mockedMember = MockedMember as jest.Mock
// mockImplementationに実装を書く
mockedMember.mockImplementation((id:string)=>{
  return new Member(id)
})

項番 1 のモックの設定は hoisting されるので、最初に設定される。 なので項番 2 の状態ではモックされているモジュールになっている。 モックされているモジュールに対して、さらにモックの変更を加えることで、以降 別の箇所でserviceモジュールを使用している箇所でその変更が反映される。

補足

あんまりわかってないのが、jest.mockのときはクラスを渡していて、mockImplementationのときはインスタンスを返さないとうまくいかない点。

class Member {}

// jest.mockで設定するとき
jest.mock("../../service", () => {
  return {
    Member: Member
  };
});

// mockImplementationで設定するとき
mockedMember.mockImplementation((id: string) => {
  // インスタンスを返却する
  return new Member(id);
});

公式を見る限り、jest.mockのモジュールファクトリは、コンストラクタ関数を返せっていってるので、クラス定義(ただん関数)でよくって、mockImplementationのときは、オブジェクトを返すっぽいのかな。

// jest.mockでモックしたMemberクラスをnewしたときの挙動を想定
// 以下の記事が大変わかりやすかった
// https://qiita.com/takeharu/items/010752b1427773558f7c
function Member() {
  this = {}
  // プロトタイプの設定とか
  return this
  }

// mockImplementionでモックしたMemberクラスをnewしたときの挙動
function() {
  //Objectは new Memberした結果のインスタンス
  return Object
}

jest.fn

mockImplementationででてきたjest.fnについて。

以下のようにjest.fnでラップすると、モックされた関数は、jest.fnの機能をもつ関数として使うことができる。

jest.mock("../../service", () => {
  return {
    __esModule: true,
    //jest.fnでラップ
    default: jest.fn((id: string) => {
      return { name: `${id}_tarou` };
    }),
    //jest.fnでラップ
    getAge: jest.fn((id: string) => {
      return { age: `${id}_21` };
    }),
    // mockImplementationパターン
    Member: jest.fn()
  };
});

上記モックをした状態で、モックされた関数を import してテスト内でアサーションすることができる。 できることはいっぱいあるので、公式を確認する。

API 呼び出しとかは、API に渡すリクエストの値をテストしたり、とある条件のときは API を呼び出さないとかあるので、そういった場合にjest.fnを使うと便利。 その場合、beforeEach とかで、モック関数の設定を毎回リセットすることを忘れないようにする。

import getName, { getAge, Member as MockedMember } from "../../service";

it("targetにtarouを設定して実行すると{result: 'tarou'}}が返却されること", () => {
  expect(target("01")).toEqual({ name: "01_tarou", age: "01_21" });

  // モックされた関数が何回よばれたかをアサート
  expect(getName).toHaveBeenCalledTimes(1);
  expect(getAge).toHaveBeenCalledTimes(1);

  expect(verClass("01")).toEqual({ name: "01_tarou", age: "01_21" });
});

jest.spyonについてはまた別途書くかも。