1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Reduxのmiddlewareのテストを書く

Posted at

Reactでアプリケーションを開発する際には専ら、Reduxを使うことが多いと思います。
React/Reduxのアプリケーションのユニットテストを書くとき、action,reducerのテストは比較的簡単ですが、middlewareが結構面倒くさいなと感じました。

middlewareはロジックが集約する場所であり、storeの更新や更なるactionのdispatchなどアプリケーションのコアとなる部分です。
そのため、テストを行う重要度もそれに比例して高いと思われます。
その割りには、middlewareのテストの書き方を紹介してる記事が少なかったので、ここに書き記しておきます。

カウンター

「+ボタン」が押されたらカウント値が +1 「-ボタン」が押されたらカウント値が -1 されるような簡単なカウンターを例にして説明します。
また、カウンターには以下の条件があります。

  • カウント値が0になったら「-ボタン」にdisabledをかける
  • カウント値が1以上かつ「-ボタン」にdisabledがかかっている場合、解除する
  • 「-ボタン」の使用可否を制御するactionは上記2条件のタイミングにのみ発火する

この3つの条件をmiddlewareに記述します。

actionとreducer

「+ボタン」が押されたらカウント値が +1 「-ボタン」が押されたらカウント値が -1 されるような簡単なカウンターを想定してactionとreducerを作成します。

// src/actions/counter.js

export const incriment = () => ({
    type: 'INCRIMENT',
});

export const decriment = () => ({
    type: 'DECRIMENT',
});

export const disabled = payload => ({
    type: 'DISABLED',
    payload,
});
// src/reducers/counter.js

const INITIAL_STATE = {
    count: 0,
    disabled: true,
};

export const counter = (state = INITIAL_STATE, action) => {
    const { type, payload } = action;

    switch (type) {
        case 'INCRIMENT': {
            return {
                ...state,
                count: state.count + 1,
            };
        }

        case 'DECRIMENT': {
            return {
                ...state,
                count: state.count - 1,
            };
        }

       case 'DISABLED': {
            return {
               ...state,
               disabled: payload,
            };
        }

        default:
            return state;
    }
};

middleware

// src/middlewares/counter.js
import { disabled } from '../actions/counter';

export const countInspector = store => next => action => {
    const { type } = action;

    switch (type) {
        case 'INCRIMENT': {
            const { disabled } = store.getState().counter;
            if (disabled) store.dispatch(disabled(false));
            break;
        }

        case 'DECRIMENT': {
            const { count, disabled } = store.getState().counter;
            if (count === 1) store.dispatch(disabled(true));
            else if (disabled) store.dispatch(disabled(false));
            break;
        }

        default:
    }

    next(action);
};

mockの作成

middlewareのテストを行うためには、middlewareを実行しなければいけません。

最初にテストを書こうとすると、先ずこの実行させるところで躓きます。
更に、検証するものをどうやって返り値(actual)として取得するのかを考えないといけません。

middlewareで確認すべき項目は、以下の4項目です。

  • dispatchされたactionの数
  • nextされたactionの数
  • actionの引数(payload)
  • dispatch, nextされた順番

middlewareの実行のさせ方ですが
countInspector = store => next => action =>
このあまり見慣れない関数で戸惑うと思います。
関数のカリー化や部分適用をやろうとするとする時にこの書き方がでてきます。
ただ単純に実行するだけならばcountInspector()()();で実行できますが、これでは動かないです。

なので、middlewareを実行できるように、なおかつ上記の4項目を確認できるようにするためのmockを作成します。

const storeHelpers = (fakeStore= {}, action, middleware) => {
    const dispatched = [];
    const nexted = [];
    const fakeAction = typeof action === 'function' ? action() : action;

    const store = {
        getState() { return fakeStore; },
        dispatch(dispatchAction) { dispatched.push(dispatchAction); },
    };
    const next = nextAction => nexted.push(nextAction);

    middleware(store)(next)(fakeAction);

    return {
        dispatched,
        nexted,
    };
}

export default storeHelpers;

3つの引数はそれぞれ

  • fakeStore : storeのmock
  • action : middlewareがキャッチするaction
  • middleware : テスト対象のmiddleware

を意味しております。
dispatch, nextされたactionをそれぞれdispatched, nextedという配列にpushして溜め、それをリターンするようにしています。

テストの作成

middlewareのテストのみここでは作成します。
テスト環境はmochaを使い、アサーションライブラリにpower-assertを使います。

// test/middlewares/counter.spec.js

import assert from 'power-assert';
import * as actions from '../../src/actions/counter';
import { countInspector } from '../../src/middlewares/counter';
import storeHelpers from './sotre-helpers';

describe('middlewares/counter', () => {
    describe('countInspector', () => {
        describe('無関係のactionを受け取った時', () => {
            const fakeAction = { type: 'fakeAction' };

            it('nextされるのみか', () => {
                const {
                    dispatched,
                    nexted,
                } = storeHelpers(null, fakeAction, countInspector);

                assert.deepEqual(dispatched, []);
                assert.deepEqual(nexted, [fakeAction]);
            });
        });

        describe('action: INCRIMENT を受け取った時', () => {
            const createFakeStore = value => ({
                counter: { disabled: value },
            });

            describe('disabled : false である時', () => {
                it('nextされるのみか', () => {
                    const fakeStore = createFakeStore(false);
                    const {
                        dispatched,
                        nexted,
                    } = storeHelpers(fakeStore, actions.incriment, countInspector);
                    const expected = [{ type: 'INCRIMENT' }];

                    assert.deepEqual(dispatched, []);
                    assert.deepEqual(nexted, expected);
                });
            });

            describe('disabled : true である時', () => {
                it('disabled(false)がdispatchされるか', () => {
                    const fakeStore = createFakeStore(true);
                    const {
                        dispatched,
                    } = storeHelpers(fakeStore, actions.incriment, countInspector);
                    const expected = [{ type: 'DISABLED', payload: false }];

                    assert.deepEqual(dispatched, expected);
                });

                it('incrimentがnextされるか', () => {
                    const fakeStore = createFakeStore(true);
                    const {
                        nexted,
                    } = storeHelpers(fakeStore, actions.incriment, countInspector);
                    const expected = [{ type: 'INCRIMENT' }];

                    assert.deepEqual(nexted, expected);
                });
            });
        });

        describe('action: DECRIMENT を受け取った時', () => {
            const createFakeStore = (count, value = false) => ({
                counter: {
                    count,
                    disabled: value,
                },
            });

            describe('count値が2以上である場合', () => {
                it('nextされるのみか', () => {
                    const fakeStore = createFakeStore(2, false);
                    const {
                        dispatched,
                        nexted,
                    } = storeHelpers(fakeStore, actions.decriment, countInspector);
                    const expected = [{ type: 'DECRIMENT' }];

                    assert.deepEqual(dispatched, []);
                    assert.deepEqual(nexted, expected);
                });
            });

            describe('count値が2以上であり、disabled : true である場合', () => {
                const fakeStore = createFakeStore(2, true);

                it('disabled(false)がdisaptchされるか', () => {
                    const {
                        dispatched,
                    } = storeHelpers(fakeStore, actions.decriment, countInspector);
                    const expected = [{ type: 'DISABLED', payload: false }];

                    assert.deepEqual(dispatched, expected);
                });

                it('decrimentがnextされるか', () => {
                    const {
                        nexted,
                    } = storeHelpers(fakeStore, actions.decriment, countInspector);
                    const expected = [{ type: 'DECRIMENT' }];

                    assert.deepEqual(nexted, expected);
                });
            });

            describe('count値が1である場合', () => {
                const fakeStore = createFakeStore(1);

                it('disabled(true)がdisaptchされるか', () => {
                    const {
                        dispatched,
                        nexted,
                    } = storeHelpers(fakeStore, actions.decriment, countInspector);
                    const dispatchExpected = [{ type: 'DISABLED', payload: true }];
                    const nextExpected = [{ type: 'DECRIMENT' }];

                    assert.deepEqual(dispatched, dispatchExpected);
                    assert.deepEqual(nexted, nextExpected);
                });
            });
        });
    });
});

長いですが、countInspecterの条件分岐を完全網羅するテストを作成する場合、このようなコードになります。


const {
    dispatched,
    nexted,
} = storeHelpers(fakeStore, actions.incriment, countInspector);

storeHelpersを使う時は、このようにdispatchedとnextedを受け取るようにし、
元コードに記述してあるactionとmiddlewareを渡すようにします。

素のmiddlewareを使う場面はそんなに多くはないですが、middlewareのテストを行う時は参考にしてみてください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?