Help us understand the problem. What is going on with this article?

テスト初心者が初めに覚えた依存モジュールのモック化5パターン(Jest + TypeScript)

More than 1 year has passed since last update.

付録Cでテストに対する考え方が変わりました!

社会人になって会社で学んだ単体テストは、Excelで全条件分岐の組み合わせ表(マトリクス)を作成し、デバッガ(gcc)のステップ実行で目視確認、マトリクスのセルに○印をつけていくというスタイルです。コード中のあり得ない分岐もデバッガで値を変えて無理やりカバレッジ100%を達成する必要があり、嫌でもデバッガと仲良くなります。もちろんコードに修正がはいれば全てやり直しという過酷なものでした。
その苦い思い出以来、なにかと言い訳して極力テストを書かずに逃げてきました。。しかし、最近いろいろなところから「テスト駆動開発の付録Cが素晴らしい!」という評判を聞いて、遅ればせながら読んでみたところ、テストに対する考え方が180度変わりました。
色々な方が書評を書いておりますので詳細は省略しますが、私は付録Cを読んだ翌日からおもわず開発にTDDを取り入れ、すっかりテストが好きになってしまいました。

テスト対象が依存するモジュールのモック化

テストを書くモチベーションは十分に高まったのですが、いままでテストケースを書いたことがないし、先輩方も似たような状況なので、いろいろとつまづきました。
特にテスト対象が依存するモジュールのモック化で手間取ることが多かったので、モック化のパターンをまとめてみました。私のようなテスト初心者の助けになれば幸いです。
誤りやもっとよい方法があれば編集リクを頂けると嬉しいです :bow:
サンプルはNode.jsのアプリで、TypeScript (v2.6), Jest (v21.2.1)で書きました(Jestの概要は「ついに jest の軍門に降った」や公式サイト(日本語)でどうぞ)。

もっともシンプルなパターン

サンプルのテスト対象は、引数で指定されたファイルを読み込んで行数を返す関数LineCount()です。LineCount()はモジュールfsに依存しており、fs.readFileSync()をモック化したいとします。

app.ts(テスト対象)
import * as fs from 'fs'; // <-- テスト対象が依存するモジュール

export function LineCount (path: string): number {
    const data = fs.readFileSync(path, 'utf8'); // <-- readFileSync()をモック化したい
    return data.split('\n').length;
}

export default LineCount;  

この場合、テストコードは下記のようになります。

app.spec.ts
// (1): テスト対象が依存するモジュールのモックを定義
jest.mock('fs', () => ({
        readFileSync: jest.fn(() => `first¥n second¥n third`),
}));
// (2): 検証用((4),(5))にモック読み込む
import * as fs from 'fs';          

// (3): (1)を使うテスト対象を読み込む
import {LineCount} from '../app';

describe('サンプル', () => {
    const path = 'dummy';
    it('LineCount()', () => {
        const result = LineCount(path);

        expect(result).toBe(3);
        expect(fs.readFileSync.mock.calls.length).toBe(1); // (4): モックが呼ばれたか検証
        expect(fs.readFileSync.mock.calls[0][0]).toBe(path); // (5): モックが呼ばれた際の引数を検証
    });
});

ポイントは以下のとおり。

  • テスト対象を読み込む前にjest.mock()でテスト対象が依存するモジュールをモック化し、振る舞いを定義します(①は③より前)。
  • モック化したモジュールを検証用に読み込む場合はモック化の後に読み込みます(①は②より前)。

デフォルトエクスポートのパターン

モジュールによっては、export default XXXという形でモジュールが公開されている場合があります。その場合はモックする際にdefaultプロパティをつくって、その中にモックを作成する必要があります。
デフォルトエクスポートかどうかわからない場合は、下記のように両方に対応させておくと良いかと思います。

app.spec.ts
// (1): テスト対象が依存するモジュールのモックを定義
jest.mock('fs', () => {
    const mockFn = jest.fn(() => `first¥n second¥n third`);
    return {
        default: { // デフォルトエクスポート
            readFileSync: mockFn,
        },
        readFileSync: mockFn, // デフォルトエクスポートじゃない
    };
});
// (2)-(5)は変更なし

非同期関数(callback系)のパターン

次のテスト対象のサンプルLineCountAsync()は、先のLineCount()とは異なり、内部で非同期型のfs.readFile()を呼び出しています。このfs.readFile()をモック化したいとします。

app.ts
import {promisify} from 'util';
import * as fs from 'fs';
const readFilePromise = promisify(fs.readFile); // 非同期関数をプロミス化

export async function LineCountAsync (path: string): Promise<number> {
    const data = await readFilePromise(path, 'utf8'); // <-- モック化したい
    return data.split('\n').length;
}

うえの例では今時っぽくpromisify()でプロミス化してawaitやasyncを利用していますが、プロミス化しない場合でもモックの書き方は同じです。LineCountAsync()の呼び出しをreturnで返すようにしないと、非同期処理が走るまえにテストが終了してしまうので要注意です。

app.spec.ts
// (1): テスト対象が依存するモジュールのモックを定義
jest.mock('fs', () => ({
        readFile: jest.fn((path, opt, cb) => { // 非同期関数のモック
            cb(null, `first¥n second¥n third`);  // コールバック関数の引数にの戻り値を渡す
        }),
}));

// 省略

describe('test', () => {
    it('LineCountAsync', () => {
        return LineCountAsync(path).then(result => {
            expect(result).toBe(3);
        });
    });
});

各テストケースでモックの振る舞いを変えるパターン

いままでの例だと、一番初めにモックの振る舞いを定義するので、すべてのテストケースでモックの動作が同じになります。モックの振る舞いを変えるには、テストケース内でjest.fn().mockImpelementation()で定義するのが良いと思います。また、beforeEachでテストケース毎にモックを初期化(mockClear())しましょう。

app.spec.test
// (1),(2),(3)はそのまま
describe('test', () => {           
    beforeEach(() => {             
        fs.readFileSync.mockClear();                                   
    });    
    it('ファイルが空', () => {
        fs.readFileSync.mockImplementation(() => ''); // 空文字を返す振る舞いを定義
        const result = LineCount(path);
        expect(result).toBe(1); 
    });
    it('ファイルが空でない', () => {
        fs.readFileSync.mockImplementation(() => 'hello¥n world'); // 文字列を返す振る舞いを定義
        const result = LineCount(path);
        expect(result).toBe(1);
    });
});

1つのテストケース内でモックの振る舞いを変えるパターン

次のテスト対象のサンプルLineCountDiff()は、2つのファイルの行数の差を返す関数で、内部ではreadFileSyncを2回読んでいます。readFileSync()をモック化して1回目と2回目で異なる値を返したいとします。

app.ts
export function LineCountDiff (path1: string, path2: string): number {
    const data1 = fs.readFileSync(path1, 'utf8'); // 1回目
    const data2 = fs.readFileSync(path2, 'utf8'); // 2回目
    return data1.split('\n').length - data2.split('\n').length;
}

モックが呼ばれるたびに異なる値を返すようにするには、ジェネレータが便利です。

app.spec.ts
    it.only('LineCountDiff', () => {
        const path2 = 'dummy2';
        const generator = function* (e) { yield *e; };
        const mockFn = generator(['1¥n2¥n3', '1']); //1回目は'1¥n2¥n3¥n'、2回目は'1'を返す

        fs.readFileSync.mockImplementation(() =>  mockFn.next().value); // ジェネレータでモック化

        const result = LineCountDiff(path, path2);
        expect(result).toBe(2);
    });

--

以上のソースコードはGistにものせております。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away