React + TypeScript + Jest + react-testing-libraryのテスト その2: Redux前編

前回からの続きです。
今回の参考用ファイルはこちら

つくるもの

カラーピッカーで指定した色をカラーパネルとして追加するだけのものです。
カラーパネルのデータにはID、省略可能なラベル、カラーコード(カラーピッカーで取得)が入ります。 ColorPanelのstateはこのようになります。

src/modules/ColorPanelList/types.ts
type ColorPanelType = {
  id: number; // ID
  label?: string; // ラベル(任意で入力)
  colorCode: string; // カラーコード(カラーピッカーから取得)
}

type StateType = {
  items: ColorPanelType[];
}

jest modelx2

Redux

今回はテストを書くのが目的なので、先にReduxを書いてみます。
Reducer部分に副作用が及ばず、戻り値が期待通りになり、テストが書きやすいためです。
副作用が入る場合は、Middlewareを用いますが、そちらでのテストは後に書いていきます。

addColorPanel、removeColorPanelのActionを追加しました。

src/modules/ColorPanelList/actions.ts
import { ColorPanelType } from '@/types/colorPallet';

export const addColorPanel = (payload: ColorPanelType) =>
  ({
    type: 'addColorPanel',
    payload,
  } as const);

export const removeColorPanel = (payload: ColorPanelType['id']) =>
  ({
    type: 'removeColorPanel',
    payload,
  } as const);

型です。

src/modules/ColorPanelList/types.ts
import * as Actions from './actions';
import { ActionType } from 'redux-actions-type';

/**
 * ColorPanel
 */
export type ColorPanelType = {
  id: number;
  label?: string;
  colorCode: string;
};

/**
 * State
 */
export type StateType = {
  items: ColorPanelType[];
};

/**
 * ActionTypes
 */
export type ActionTypes = ActionType<typeof Actions>;

Action Creatorのテスト

型が保証されていれば、テストはそのままActionの出力をチェックするだけで良いと思います。
ここに副作用が入った時のテストは今回やりません。

Reducerのテスト

Reducerはこんな感じです。

src/modules/ColorPanelList/reducer.ts
import { StateType, ActionTypes } from '@/modules/ColorPanelList/types';

/**
 * State
 */
export const initialState: StateType = {
  items: [],
};

/**
 * Reducer
 */
export default function reducer(
  state: StateType = initialState,
  action: ActionTypes
): StateType {
  switch (action.type) {
    case 'addColorPanel':
      return {
        ...state,
        items: [...state.items, action.payload],
      };
    case 'removeColorPanel':
      return {
        ...state,
        items: state.items.filter(item => {
          return item.id !== action.payload;
        }),
      };
    default:
      return state;
  }
}

Reducer自体はプレーンなActionを受け取る純粋な関数なので、期待通りの結果を得やすいです。
入力を受けてdispatch…の流れは、今テストすることではないので一旦忘れます。

add

"addColorPanel"が正しく動作するかをテストしてみます。

reducerで返ってきたstateのitems配列には、payloadに渡したオブジェクトが追加されているはずです。
その値が期待されるものかどうかを、toEqualtoMatchObjectなどで比較しています。

specs/ColorPanelList/reducer.spec.ts
import reducer, { initialState } from '@/modules/ColorPanelList/reducer';
import { addColorPanel } from '@/modules/ColorPanelList/actions';
import { StateType } from '@/modules/ColorPanelList/types';

describe('ColorPanelのreducer', () => {
  let state: StateType;
  it('add', () => {
    state = reducer(
      initialState,
      addColorPanel({ id: 10, label: '黒', colorCode: '#000' })
    );
    expect(state.items).toEqual([{
      id: 10,
      label: '黒',
      colorCode: '#000',
    }]);
  });
});

toEqualは、厳密な等価性比較を行いますが、Stateの変更に合わせて値も変更する必要があります。
toMatchObjectは、対象となるプロパティがあれば通りますが、プロパティの追加があっても、テストが通ってしまいます。

それぞれのプロパティにアクセスして、toBeを使用する方法もあります。
この方法ではTypeScriptの補完が効き、存在しないプロパティをチェックすることもありません。
ただしこちらもtoMatchObjectと同じ問題を抱えます。

specs/ColorPanelList/reducer.spec.ts
import reducer, { initialState } from '@/modules/ColorPanelList/reducer';
import { addColorPanel } from '@/modules/ColorPanelList/actions';
import { StateType } from '@/modules/ColorPanelList/types';

describe('ColorPanelListのReducer', () => {
  let state: StateType;
  describe('add', () => {
    state = reducer(initialState, addColorPanel({ id: 0, colorCode: '#000' }));
    it('id', () => {
      expect(state.items[0].id).toBe(0);
    });
    it('colorCode', () => {
      expect(state.items[0].colorCode).toBe('#000');
    });
  });
});

今回の例では、payloadのlabelは省略可能になっています。
そのため、「labelがない時」のテストをするなら、toBeUndefined()でも通りますが…。

src/spec/reducer/ColorPanelList.spec.ts
let state = reducer(initialState, addColorPanel({ id: 0, colorCode: '#000' }));
it('labelの値がない', () => {
  expect(state.items[0].label).toBeUndefined();
});

Reducerではpayloadを返しているので、実際にはlabelを持たない状態になっています。

src/modules/ColorPanelList/reducer.ts
case 'addColorPanel':
  return {
    ...state,
    items: [...state.items, action.payload],
  };

toHavePropertymatcherを使用することで、正しいテストを書くことができます。

src/spec/reducer/ColorPanelList.spec.ts
state = reducer(initialState, addColorPanel({ id: 0, colorCode: '#000' }));
it('ラベル省略した時はlabelプロパティがない', () => {
  expect(state.items[0]).not.toHaveProperty('label');
});

正しいのですが、(記事投稿時点では)toHavePropertyに入る型がstringなので、'label'というプロパティ名に変更があってもTypeScript側で検知できなくなります。
(型定義を更新することもできそうですが、deep equalityの文字列とインデックスが鬼門です)

TypeScriptではstrictNullChecksがあるので、labelの有無はコーディング中に気が付ける問題です。
それと、使用するmatcherが特殊な上にnotを挟んでいるので、パッと見て混乱しやすいテストになります。
今回は、「labelプロパティに値が入っているか」を確認するテストだけを書くことにします。

remove、defaultのデータも書いていきます。

specs/ColorPanelList/reducer.spec.ts
import reducer, { initialState } from '@/modules/ColorPanelList/reducer';
import { addColorPanel, removeColorPanel } from '@/modules/ColorPanelList/actions';
import { StateType } from '@/modules/ColorPanelList/types';

describe('ColorPanelListのReducer', () => {
  // add
  describe('add', () => {
    let state: StateType;
    state = reducer(initialState, addColorPanel({ id: 0, label: '漆黒の黒', colorCode: '#000' }));
    it('id', () => {
      expect(state.items[0].id).toBe(0);
    });
    it('label', () => {
      expect(state.items[0].label).toBe('漆黒の黒');
    });
    it('colorCode', () => {
      expect(state.items[0].colorCode).toBe('#000');
    });
  });

  // remove
  describe('remove', () => {
    let state: StateType;
    state = reducer(initialState, addColorPanel({ id: 10, colorCode: '#000' }));
    it('何も残らない', () => {
      let newState = reducer(state, removeColorPanel(10));
      expect(newState.items).toHaveLength(0);
    });
  });

  // default
  describe('対象外のAction', () => {
    let state: StateType;
    // @ts-ignore (Actionの型チェックを回避するため)
    state = reducer(initialState, {});
    it('initialStateが返る', () => {
      expect(state).toEqual(initialState);
    });
  });
});

一通りのテストを作成したので、一旦カバレッジをとってみます。
長くなったので次回に続きます。

参考、使用ライブラリ

Writing Tests · Redux
redux-actions-type (Actionの型付けで使用させていただいてます)