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

前回からの続きです。
カバレッジ、スナップショットテストについてやっていきます。
参考用ファイルはこちら

あらすじ

カラーピッカーで指定した値を登録するやつを作っています。

jest modelx2

このような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;
  }
}

このようなテストコードを書きました。

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);
    });
  });
});

一通りのテストが作れたので、カバレッジを調べてみます。

カバレッジ

カバレッジ(Coverage)は、テストがどこまでカバーできているのかを示す割合です。
これを参考にして、テスト漏れがないかなどを確認することができます。
jestはIstanbulを内蔵していて、カバレッジを簡単に出力することができます。

ルートのjest.config.jsを次のようにします。

jest.config.js
module.exports = {
  roots: ['<rootDir>/'],
  testRegex: '((\\.|/)(test|spec))\\.(jsx?|tsx?)$',
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
  moduleNameMapper: { '^@/(.+)': '<rootDir>/src/$1' },
  setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'],
  transform: { '\\.(js|ts|tsx)?$': 'babel-jest' },
  coverageDirectory: './coverage/', // ここから追加分
  collectCoverageFrom: [
    '**/*.{ts,tsx}',
    '!**/node_modules/**',
    '!**/vendor/**',
  ],
};

yarn test --coverageを実行します。

shell
yarn test --coverage

すると、テスト後カバレッジが出力され、ルートフォルダに.coverageフォルダが生成されます。
中にあるlcov-report/reducer.ts.htmlを見ると、Reducer箇所のカバレッジが確認できます。

jest01

initialStateが黄色くハイライトされていますが、一旦置いときます。
画面上部ではStatement, Branches, Functions, Lines4つの項目と割合が掲載されています。
それぞれの項目は、以下の通りです。

  • Statement - テストの中で実行された命令文の割合(命令網羅)
  • Branches - テストできた分岐処理の割合(分岐網羅)
  • Functions - テストで呼び出された関数の割合
  • Lines - テストの中で実行されたコード単位(行)の割合

よくわからないので、一旦、removeColorPanelのテストを除外して、再度カバレッジを出力してみます。

jest02

すると、画面上部の項目の割合が一気に下がりました。
また、removeColorPanelのcaseあたりが、ピンクや黄色のハイライトで覆われています。

ピンクはStatementsにあたり、テストの対象になっていない箇所を示します。
また、黄色はBranchesにあたり、分岐箇所がテストできていないことを示します。
つまり、Reducerのcase 'removeColorPanel':の部分がテストから漏れていて、その先のreturnも実行されていない、ということがわかります。

(Functionsに不備がある場合、オレンジのハイライトで表示されます。)
(Linesですが、実質Statementsの後方互換のようなものなので割愛します。)

これを参考に、テスト漏れの部分を探してみます。
先ほど消してみたremoveColorPanelのテストを復活させば、Branches以外は100%になります。
残ったのは、先ほど一つだけ黄色くなっていたinitialStateの部分です。
これは、Reducerのデフォルト引数(initialState)を呼び出すテストを書いていないからでしょう。

jest03

そこで、defaultのテストで呼び出しているreducerの第一引数をundefinedにします。

specs/ColorPanelList/reducer
// default
  describe('デフォルトの動作', () => {
    let state: StateType;
    // @ts-ignore (型チェックを回避するため)
    state = reducer(undefined, {});    it('initialStateが返る', () => {
      expect(state).toEqual(initialState);
    });
  });

これで全てのテストが網羅できました。
ただ、カバーしたから品質が完璧だ、ということではありません。
規模が大きくなるとケースも複雑になっていくので、カバレッジ100%を目指すことはあまりありません。

スナップショットテスト

朝起きたら、なぜかColorPanelにpublishedというoptionalな項目が追加されていたとします。
しかも、Reducerを見ると謎のコメントと共に、publishedの値が追加されています…。

src/modules/ColorPanelList/reducer
case 'addColorPanel':
  return {
    ...state,
    // 型エラー出ましたがpublishedの後にハテナ?をつけたら直りました
    items: [...state.items, {...action.payload, published: '2019-07-22'} ],
  };

追加されたのにも関わらず、これは型もテストも通ってしまいますし、カバレッジも変わりません。
toEqualを使わなかったり、個々のプロパティに対してテストしていると起こります。
テストが追加されるとは限らず、このまま放置されてしまうかもしれません。

そこで、あらかじめスナップショットを作成することもできます。
toMatchSnapshot()を用いることで、同階層にスナップショット用のフォルダとファイルを生成し、次回テスト時にその差分をチェックしてくれます。

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', () => {
  let state: StateType;

  beforeEach(() => {
    state = reducer(
      initialState,
      addColorPanel({ id: 10, label: '漆黒の黒', colorCode: '#000' })
    );
  });

  // スナップショットを作成
  it('addAction', () => {
    expect(state).toMatchSnapshot();
  });
  it('removeAction', () => {
    let newState = reducer(state, removeColorPanel(10));
    expect(newState).toMatchSnapshot();
  });
});

例えば、publishedが追加されなかった時のスナップショットはこちらです。

specs/ColorPanelList/__snapshots__/reducer.spec.ts.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`ColorPanelListのReducer addAction 1`] = `
Object {
  "items": Array [
    Object {
      "colorCode": "#000",
      "id": 10,
      "label": "漆黒の黒",
    },
  ],
}
`;

exports[`ColorPanelListのReducer removeAction 1`] = `
Object {
  "items": Array [],
}
`;

その後、publishedが追加されるとaddActionの結果はこうなります。
差分が生じるのでテストが通りません。

specs/ColorPanelList/__snapshots__/reducer.spec.ts.snap
"items": Array [
Object {
    "colorCode": "#000",
    "id": 10,
    "label": "漆黒の黒",
  + "published": "20190724", // これが追加されるのでエラー},

もし、この差分に対して問題がなければ、jest -u(yarn test -u)を行うことでスナップショットをアップデートし、テストを通すこともできます。

VSCodeではjestを用いると、スナップショットの更新が起こりうる時に確認通知を出してくれます。
https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest

長くなるので後編に続きます。

参考

HTML Code Coverage Report (Documentation) · Issue #111 · gotwarlost/istanbul