Jestのモック機能を活用する

テスト環境をJasmineからJestに入れ替えましたので、カバレッジを上げるためおよびTDD的にもこちらの活用を行っていきたいと思います。一年ぐらい、いや二年?、目を離した間にモック機能がかなり成熟していました。

そのまえにeslint react/jsx-boolean-value

細かい話として、ずっとJSXでBoolean値のプロパティに対してリテラルでtrue / falseを書くとEslintに怒られることに理不尽を感じてましたが、理由と対応を目にして納得しました。

理由:https://facebook.github.io/react/docs/jsx-in-depth.html#booleans-null-and-undefined-are-ignored

JSXでBooleanリテラルは出力されないから。そのため思惑違いのコードを書かせないためにBooleanリテラルをJSX中で禁止するルールを入れている。

対策:https://github.com/airbnb/javascript/tree/master/react#props

明確にtrueとしたいプロパティは、プロパティ名だけを書く。falseの場合はプロパティを書かない。知らなかった。なるほど。

Jestのモック機能

Jestに組み込まれているモックは多機能で多くのAPIを持ちますが、煎じ詰めればほぼ2つに絞れます。mockReturnValueOnceメソッドとmock.callsプロパティ。

    test('jest.fn() sample', () => {
        const fn = jest.fn().mockReturnValueOnce(6).mockReturnValueOnce(3);
        expect(fn(5)).toBe(6); // 1度目に戻り値が6をテスト
        expect(fn(2)).toBe(3); // 2度目に戻り値が3をテスト
        expect(fn.mock.calls).toEqual([[5], [2]]); // 1度目に引数5、2度目に引数2をテスト
    });

mockReturnValueOnceで戻り値シナリオを定義することができます。例では関数fnを1回目に呼ばれたら6を、2回目に呼ばれたら3を、返すように連続して定義しました。一方mock.callsプロパティの中には呼び出し記録が入ってるので、実行された際の引数を確かめることができます。他にもたくさん機能ありますが、ほぼこれだけでほとんどのケースに対応できるんじゃないですかね。

外部ライブラリのモック化

ここまででも十分に便利ですが、Jestにはさらに脅威的な使い方があります。まずテスト対象とする実際のコードの紹介。

// CookiesMiddleware.js
import { Map } from 'immutable';
import Cookies from 'browser-cookies';

const getStateFromCookies = (name) => {
    const value = Cookies.get(name);
    if (value) {
        try {
            return Map(JSON.parse(value));
        } catch (e) {
            // no-op
        }
    }
    return Map({});
};

const cookiesMiddleware = (reducer, name, options) => () => (next) => {
    let state = null;
    return (action) => {
        // action.meta.cookiesにtrueが入っていて、errorじゃなかったpayloadを保存する。
        if (action.meta && action.meta.cookies && !action.error) {
            if (!state) {
                state = getStateFromCookies(Cookies, name);
            }
            state = reducer(state, action);
            Cookies.set(name, JSON.stringify(state.toJS()), options);
        }
        next(action);
    };
};

export { getStateFromCookies, cookiesMiddleware };

これは、ReduxのMiddlewareとして、Actionのmetaを見て判断し、ブラウザのクッキーに値を保存するものです。これはブラウザ環境と結びついているCookiesから値を取って来るし、値を設定しても処理を喰っちゃうのでテストが難しい。従来ならばヘッドレスなブラウザ環境を用意するなど面倒なことを必要としましたが、JestではこのMiddlewareを手軽に非破壊検査できます。

// __tests__/CookiesMiddlewareTest.js
import actions from '../AppActions';
import app from '../AppReducer';

describe('Cookies', () => {
    test('getStateFromCookies', () => {
        jest.mock('browser-cookies');
        /* eslint-disable global-require */
        const mockCookies = require('browser-cookies');
        const getStateFromCookies = require('../CookiesMiddleware').getStateFromCookies;
        /* eslint-enable */
        mockCookies.get.mockReturnValueOnce('{"title":"aaa","vol":"bbb","page":0}');
        const state = getStateFromCookies('app1');
        expect(state.toJS()).toEqual({ title: 'aaa', vol: 'bbb', page: 0 });
    });

    test('cookiesMiddleware', () => {
        jest.mock('browser-cookies');
        /* eslint-disable global-require */
        const mockCookies = require('browser-cookies');
        const cookiesMiddleware = require('../CookiesMiddleware').cookiesMiddleware;
        /* eslint-enable */
        mockCookies.get.mockReturnValueOnce('{"adjustPage":false}');
        const mockNext = jest.fn();
        const middleware = cookiesMiddleware(app, 'app', { expires: 1 })()(mockNext);
        const action = actions.toggleAdjustPage();
        action.meta = { cookies: true };
        middleware(action);
        expect(mockCookies.set.mock.calls[0])
            .toEqual(['app', '{"adjustPage":true}', { expires: 1 }]);
        expect(mockNext.mock.calls[0][0].type).toBe('TOGGLE_ADJUST_PAGE');
    });
});

以下、要点をステップバイステップで。

jest.mock('browser-cookies');

jest.mockでライブラリを丸ごとモックにします。\‘browser-cookies\'は{ get, set, erase, all } と4つの関数をexportしていますが、これらすべてがjest.fn()に置き換えられている状態になります。

const mockCookies = require('browser-cookies');
const cookiesMiddleware = require('../CookiesMiddleware').cookiesMiddleware;

jest.mockを呼び出した後でJestでモック化したい外部ライブラリである\‘browser-cookies\'とテスト対象の\’../CookiesMiddleware\‘を読み込みます。この読み込みはモック実装とテストロジックとを書くために参照が必要だから。

mockCookies.get.mockReturnValueOnce('{"adjustPage":false}');
const mockNext = jest.fn();

上記のgetはmockReturnValueOnceを用いてシナリオの返り値を定義しています。Middlewareは内部でnext(action)を呼び出して次に処理を回し、最後にReducerに流し込まれますが、そのnextもモック化しておきます。そのほかに使われるsetはすでにjest.mock実行時でjest.fn()に置き換えられているのでここでは特になにもしません。

const middleware = cookiesMiddleware(app, 'app', { expires: 1 })()(mockNext);
const action = actions.toggleAdjustPage();
action.meta = { cookies: true };
middleware(action);

モックの準備ができたのでテスト対象Middlewareを組み立てて手動で実行します。ActionのmetaをいじってるのはMiddlewareをそういう仕様で実装したから。

expect(mockCookies.set.mock.calls[0])
    .toEqual(['app', '{"adjustPage":true}', { expires: 1 }]);
expect(mockNext.mock.calls[0][0].type).toBe('TOGGLE_ADJUST_PAGE');

最後にreceived-expectedマッチを行ってテスト完了。

後始末は不要

ドキュメントを読むと、jest.fn()やjest.mock()によるモック化はスコープを外れると自動的に戻るとのことなので後始末は不要、他のテストに副作用をもたらさないようになるべく狭いスコープで実行したほうがよいでしょう。外部ライブラリも非破壊にモック化できるなんて、すごいパワフル!