見出し画像

Jestのmockを使いES6クラスのテストを実装してみた 様々なモックの作成方法

はじめに

こんにちは、SHIFT の開発部門に所属している Katayama です。

テストを書くとき、テスト対象のものの実装方法によっては異なるテクニックを使う必要が出てくるが、今回は ES6 クラスの実装パターンでよくやりそうなパターン 2 つにおいて、モックを使ったテストがそれぞれどのように実装できるか?についてみていきたいと思う。

① コンストラクターの引数で、他の ES6 クラスのインスタンスを受け取り、コンストラクタ内でそれを this に格納する

② コンストラクター内で、他の ES6 クラスのインスタンスを作成する

※以下のいずれの方法を取っても以下のようなテスト実行結果になる(モックの違いによるテスト結果の違いはない)。

テストを書く対象の ES6 クラス

まずは、テストを書きたいコードについて見ていく。

コンストラクタ以外のコード

①・② のパターンの違いはコンストラクタの部分だけなので、まずコンストラクタ以外のコードについて見ていく。

// user.js(jsonスキーマを使ったmodel)

// 省略
export default class User {
	tableName = 'user';

	// ここにコンストラクタが実装してあるイメージ

	async createOrUpdate(item, options = {}) {
		// ajv default options of 'allErrors' is false
		if (!this.validateSchema(item))
			throw new Error(this.validateSchema.errors.shift().message);

		const data = await this.customDynamodbClient.putItem(item, {
			tableName: this.tableName,
			...options
		});
		this.dataValues = data;

		return this;
	}

	toJson() {
		return cloneDeep(this.dataValues);
	}
	...
}

① のパターンのコンストラクタ

	constructor(customDynamodbClient, tableDefinition, otherAttributes) {
		this.customDynamodbClient = customDynamodbClient;

		const schema = mergeWith(
			// 省略
		);
		this.validateTableDefinition = ajv.compile(tableDefinition);
		this.validateSchema = ajv.compile(schema);

		this.dataValues = {};
	}

② のパターンのコンストラクタ

	constructor(tableDefinition, otherAttributes, config = {}) {
		this.customDynamodbClient = new CustomDynamodbClient(config);

		const schema = mergeWith(
			// 省略
		);
		this.validateTableDefinition = ajv.compile(tableDefinition);
		this.validateSchema = ajv.compile(schema);

		this.dataValues = {};
	}

実際にモックを使ったテストを書いてみる

以下では、実際に①・②のいずれのパターンでも利用できる方法と、①のパターンでのみ利用できる方法についてみていこうと思う。①・②の実装パターンで適用可能なモック化の方法は以下の通り。

・①・②共通で利用可能
  A:jest.mock(moduleName, factory, options)(mockImplementation() または mockImplementationOnce() を使用したモックを置き換える)を利用する
  B:マニュアルモックを利用する
  C:jest.mock() をモジュールファクトリ引数で呼ぶを利用する
・①のみで利用可能
  D:jest.createMockFromModule(moduleName)を利用する

①・② のパターン共通で利用できる方法

①・② で共通して利用できる方法になるので、記事の長さの都合上、①・② の両方の具体例を見ていく事はしない。以下では ① のパターンのコードだとどのようにテストが書けるか?を見ていく。

※② のパターンでも以下のようにモジュールを import して jest.mock()によりモックを作成する事で、① のパターンと全く同じ方法でモックを利用したテストを実装できる(② では User クラスのコンストラクタ内で CustomDynamodbClient を new しているので本来User クラスを new する際には CustomDynamodbClient を import する必要はないが、テストの方でそのモジュールを import する所がポイント)。

import User from '../../../src/models/user';
import CustomDynamodbClient from '../../../src/lib/custom-dynamoidb-client';

jest.mock('../../../src/lib/custom-dynamoidb-client');

describe('User Model Test : createOrUpdate', () => {
	// 省略

	beforeAll(() => {
		CustomDynamodbClient.mockClear();
		CustomDynamodbClient.mockImplementation(() => ({
			putItem: () => data
		}));

		models.user = new User(); // <- ①のパターンと違い、ここでモック化したCustomDynamodbClientを渡していない。Userクラスのコンストラクタ内でnewされる時にはモックになる。
	});
// 省略

Ajest.mock(moduleName, factory, options)mockImplementation() または mockImplementationOnce() を使用したモックを置き換えるを利用する

Jest でモックを利用する際には最も簡単な方法で、自動でモックを作成するやり方。ただし、今回は ES6 クラスで単なるモジュールではないので少し注意が必要で、ES6 クラスのモックについてはES6 クラスのモックに書かれているので、こちらを参考に実装していく。

実際のテストとしては以下のようになる。

import User from "../../../src/models/user";
import CustomDynamodbClient from "../../../src/lib/custom-dynamoidb-client";

jest.mock("../../../src/lib/custom-dynamoidb-client");

describe("User Model Test : createOrUpdate", () => {
  const models = {};
  const data = {
    id: "id",
    name: { fullName: "fullName" },
    ttl: 1600000000,
  };

  beforeAll(() => {
    CustomDynamodbClient.mockClear();
    CustomDynamodbClient.mockImplementation(() => ({
      putItem: () => data,
    }));

    const mockCustomDynamodbClient = new CustomDynamodbClient();
    models.user = new User(mockCustomDynamodbClient);
  });

  describe("Test Block", () => {
    test("createOrUpdate", async () => {
      const res = await models.user.createOrUpdate(data);
      expect(res.toJson()).toStrictEqual(data);
    });
  });
});
// CustomDynamodbClient.mockImplementation(...)の実装は以下と同じ(arrow-body-styleで書くと上記のようになる)
CustomDynamodbClient.mockImplementation(() => {
  return {
    putItem: () => {
      return data;
    },
  };
});

今回はモック化しただけではテストを書くための要件を満たしておらず、putItem で指定した値を返すようにしたいので自動モックに書かれている実装だけでは不十分であった(自動モックした ES6 クラスのメソッドは、全て undefined を返すモックになるので)。

というわけでは上記の実装では、mockImplementation() または mockImplementationOnce() を使用したモックを置き換えるという方法でモックを実装し、this.customDynamodbClient.putItem(...)の返り値を指定している。mockImplementation()の部分は、User クラス内で使われている this.customDynamodbClient インスタンス(オブジェクト)を追加で実装しているイメージで、オブジェクトの putItem キーに関数を定義しているので、this.customDynamodbClient.putItem(...)とすると、putItem キーに定義した関数が実行され、今回だと data として定義していたオブジェクトが返り値として返るという事になる。

※上記のように ES6 クラスのモックを作成する方法は、自動モックmockImplementation() または mockImplementationOnce() を使用したモックを置き換えるの 2 つ以外に、実は後 2 つもある。それぞれ以下で見ていく。

B:マニュアルモックを利用する

これは公式の通り、mocksというディレクトリに新しくモックを返すモジュールを実装するパターンで、実装としては以下のようになる(省略の部分は「jest.mock(moduleName, factory, options)」の章の実装と同じ)。

// 省略
describe("User Model Test : createOrUpdate", () => {
  // 省略
  beforeAll(() => {
    const mockCustomDynamodbClient = new CustomDynamodbClient();
    models.user = new User(mockCustomDynamodbClient);
  });

  describe("Test Block", () => {
    test("createOrUpdate", async () => {
      const res = await models.user.createOrUpdate(data);
      expect(res.toJson()).toStrictEqual(data);
    });
  });
});
// ../../../src/lib/__mocks__/custom-dynamoidb-client.js
const data = {
  id: "id",
  name: { fullName: "fullName" },
  ttl: 1600000000,
};

const mock = jest.fn().mockImplementation(() => ({ putItem: () => data }));

export default mock;

このモックの実装方法だと、テストを書く側で"jest.mock()"以外のモックの実装に関するコードが出てこず、モック化していないものを利用しているような感覚でテストが書ける。ただ、モックで ES6 クラスのメソッドの返り値を定義したい場合などは、テストコード側と同じものをモックの実装にも書かなければならず、その部分は予期せぬテストの失敗につながったり、テストの内容を理解するのに複数のファイルを見なければいけないのが不便に思うかもしれないと感じた。

C:jest.mock() をモジュールファクトリ引数で呼ぶを利用する

この方法はmockImplementation() または mockImplementationOnce() を使用したモックを置き換えるの実装と似ているが、以下のように、jest.mock の第 2 引数にモジュールのファクトリ(モックを返す関数)を指定して明示的にモックを作成する方法。実装としては以下のようになる(省略の部分は「jest.mock(moduleName, factory, options)」の章の実装と同じ)。

// 省略

const data = {
	id: 'id',
	name: { fullName: 'fullName' },
	ttl: 1600000000
};

jest.mock('../../../src/lib/custom-dynamoidb-client', () =>
	jest.fn().mockImplementation(() => ({ putItem: () => data }))
);

describe('User Model Test : createOrUpdate', () => {
	const models = {};

	beforeAll(() => {
		CustomDynamodbClient.mockClear();

		const mockCustomDynamodbClient = new CustomDynamodbClient();
		models.user = new User(mockCustomDynamodbClient);
	});
// 省略
// jest.mock(...)の実装は以下と同じ(arrow-body-styleで書くと上記のようになる)
jest.mock("../../../src/lib/custom-dynamoidb-client", () => {
  return jest.fn().mockImplementation(() => {
    return { putItem: () => data };
  });
});

「mockImplementation() または mockImplementationOnce() を使用したモックを置き換える」の方法と違い、jest.mock(...)時に全ての mock の動きが定義されるので、全てのテストで同じ値を返す動きで問題がない場合には、分かりやすいかもしれない。

※ちなみに、モジュールファクトリパラメータを使用してモックするに以下のように書かれている通り、function を返す高階関数で実装する事もできる。

jest.mock(path, moduleFactory) に渡されたモジュールファクトリ関数は、function* を返す高階関数にすることもできます

具体的には、以下のような実装でも期待通りになる。

// 省略

jest.mock(
	'../../../src/lib/custom-dynamoidb-client',
	() =>
		function () {
			return { putItem: () => data };
		}
);

describe('User Model Test : createOrUpdate', () => {
	const models = {};

	beforeAll(() => {
		// CustomDynamodbClient.mockClear(); // mock関数ではないのでエラーになるためコメントアウト

		const mockCustomDynamodbClient = new CustomDynamodbClient();
		models.user = new User(mockCustomDynamodbClient);
	});
// 省略
// jest.mock(...)の実装は以下と同じ(arrow-body-styleで書くと上記のようになる)
jest.mock("../../../src/lib/custom-dynamoidb-client", () => {
  return function () {
    return { putItem: () => data };
  };
});

以下の実装もまた、モック関数を返す高階関数になっているので期待通りになる。

// 省略

jest.mock('../../../src/lib/custom-dynamoidb-client', () =>
	jest.fn(() => ({ putItem: () => data }))
);

describe('User Model Test : createOrUpdate', () => {
	const models = {};

	beforeAll(() => {
		CustomDynamodbClient.mockClear();

		const mockCustomDynamodbClient = new CustomDynamodbClient();
		models.user = new User(mockCustomDynamodbClient);
	});
// 省略
// jest.mock(...)の実装は以下と同じ(arrow-body-styleで書くと上記のようになる)
jest.mock("../../../src/lib/custom-dynamoidb-client", () => {
  return jest.fn(() => {
    return { putItem: () => data };
  });
});

公式に以下のように書かれている通り、アロー関数で実装する事はできないので注意。

モックはアロー関数にすることはできません。 JavaScript では、new をアロー関数に対して使用できないからです。 そのため、以下のような関数は動作しません。

// 以下の実装はエラーになる
jest.mock("../../../src/lib/custom-dynamoidb-client", () => {
  return () => {
    return { putItem: () => data };
  };
});

// 上記をarrow-body-styleで実装すると以下のようになる(もちろんこの実装もエラーになる)
jest.mock("../../../src/lib/custom-dynamoidb-client", () => () => ({
  putItem: () => data,
}));

① のパターンのみで利用できる方法

D:jest.createMockFromModule(moduleName)を利用する

上記の jest.mock(...)による実装との違いとして、① のパターンの場合、テスト対象のクラスのコンストラクタで ES6 クラスのインスタンスを渡す、という実装になるが、jest.createMockFromModule(moduleName)でモックを作成するような実装にすると、モックをそのままコンストラクタに渡す実装になり、直観的で分かりやすいかもしれないという事が挙げられるだろう。

実際のテストとしては以下のようになる。

import User from "../../../src/models/user";

describe("User Model Test : createOrUpdate", () => {
  const models = {};
  const data = {
    id: "id",
    name: { fullName: "fullName" },
    ttl: 1600000000,
  };

  beforeAll(() => {
    const mockCustomDynamodbClient = jest.createMockFromModule(
      "../../../src/lib/custom-dynamoidb-client"
    );
    mockCustomDynamodbClient.putItem = jest.fn(() => data);
    models.user = new User(mockCustomDynamodbClient);
  });

  describe("Test Block", () => {
    test("createOrUpdate", async () => {
      const res = await models.user.createOrUpdate(data);
      expect(res.toJson()).toStrictEqual(data);
    });
  });
});

上記のテストでは、jest.createMockFromModule で CustomDynamodbClient クラスをモック化し、mockCustomDynamodbClient.putItem で this.customDynamodbClient.putItem(...)の返り値を指定している。

ちなみに、mockCustomDynamodbClient.putItem = jest.fn(() => data)の部分は、モック関数で実装するのではなく、mockCustomDynamodbClient.putItem = () => data のように単なる関数として実装しても同じ結果が得られる。また、呼び出し回数ごとに返り値を変えたい時には、mockFn.mockReturnValueOnce(value)を利用して実装する事もできる。

※両方実装するとどうなるか?だが、以下のような実装をすると、後で実装している mockReturnValueOnce の値(空のオブジェクト)が this.customDynamodbClient.putItem(...)の返り値になる。

beforeAll(() => {
  const mockCustomDynamodbClient = jest.createMockFromModule(
    "../../../src/lib/custom-dynamoidb-client"
  );
  mockCustomDynamodbClient.putItem = jest.fn(() => data);
  mockCustomDynamodbClient.putItem.mockReturnValueOnce({}); // 空のobject

  models.user = new User(mockCustomDynamodbClient);
});

・参考:jest.createMockFromModule(moduleName)

まとめとして

今回は ES6 クラスに関するテストについて、その実装が方法が異なる 2 パターンについてみてきた。いずれの方法でもモック化して実際にCRUD処理が行われないようにすることができるが、それぞれを実際に実装してみての感想をまとめてみると以下のようになった。

※モックの返り値をテスト毎に変えたいなどのニーズを満たす場合には、A~Dのパターンで必然的にこのパターンにしなければならないが決まる事もあるので、上記の所感はあくまでそういった部分を考慮しなかった場合の所感。

※ちなみに、今回 Jest でテスト書いた model では、筆者オリジナルのカスタムの DynamoDB クライアントを利用するようなコードだったので、そのクライアントをモックにしていたが、AWS SDK の DyanamoDB のクライアントをそのまま使っていた場合には、公式のDynamoDB を使用する場合に書かれている方法を取ることもできる。

《この公式ブロガーの記事一覧》


執筆者プロフィール:Katayama Yuta
SaaS ERPパッケージベンダーにて開発を2年経験。 SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。 昨年に開発部門へ異動し、再び開発エンジニアに。座学で読み物を読むより、色々手を動かして試したり学んだりするのが好きなタイプ。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/