React

テストコードの基本(Jest)

React

今回はJestのテストコードについて、基本をまとめていきます。

業務で一応テストを書いているのですが、毎回見様見真似だったので、一通りインプットした内容を忘れないうちに記事にしていきます。

テストの必要性(簡単に)

そもそも私は、テストって何?という次元からで、テストを書く必要性がわかりませんでした。

フロントエンドの開発は、画面で確認できるじゃんと思っていたからです。

しかし、開発が進むと複雑化していき、人間がマニュアルでチェックするのは容易でなくなります。

「スタイリングが効いているか」などは目視で確認できたとしても、関数やコンポーネントのエラー特定はテストを書いた方が確実ということです。

なのでフロントエンドエンジニアもテストを書いていきましょう、ということですね!

Jestをインストール

まずは開発環境にテストライブラリをインストールします。今回はJestを使います。

他にも色々ツールはあるようですが、依存関係があったり複雑なようで、Jestはかなりシンプルで手軽とのことです。

yarn add jest

テスト用のコマンドを追加します。

{
  "name": "test",
  //省略
  "scripts": {
    "serve": "live-server public/",
    "build": "webpack",
    "dev-server": "webpack-dev-server",
    "test": "jest" //ここ
  },
  "dependencies": {
    //省略
  }
}

※jsonにコメントアウト機能はないですが、この記事用に書いています

テストコード(基本)

それでは早速テストを書いていきます。

簡単なテストから↓(3つ)

const add = (a,b) => a + b;
const greeting = (name = 'Anonymous') => `Hello ${name}!`;

test('should add two numbers', () => {
  const result = add(1, 2);
  expect(result).toBe(3);
});

test('should greet with name', () => {
  const result = greeting('Mike');
  expect(result).toBe(`Hello Mike!`)
});

test('should greet for no name', () => {
  const result = greeting();
  expect(result).toBe('Hello Anonymous!');
})

testメソッドの第一引数がテスト名第二引数はアロー関数を書きます。

コマンド設定した、yarn test を実行。

テストがクリアすると上記のような画面がターミナルに表示されます。

(テスト通ると毎回嬉しいですね)

逆にテストが通らないコードを書いてみます。

const add = (a,b) => a + b;

test('should add two numbers', () => {
  const result = add(1, 2);
  expect(result).toBe(4); //誤った数字
});

FAILしました。4を想定していたが、3を受け取ったよ、ときちんと表示されています。

このようにして、テストを進めていきます

action関数のテスト

それでは、reducerを使ったExpenseリストを例にして、色々なユニッドテストのパターンを見ていきます。

(Expenseリストは、家計簿リストのようなもので、アイテムを追加したり削除できたりできる)

まずはaction関数のテストです。

こちらのreducerのコードをテストしていきます。

import uuid from 'uuid';

// ADD_EXPENSE
export const addExpense = (
  { 
    description = '',
    note = '',
    amount = 0,
    createdAt = 0 
  } = {}
) => ({
  type: 'ADD_EXPENSE',
  expense: {
    id: uuid(),
    description,
    note,
    amount,
    createdAt
  }
});

// REMOVE_EXPENSE
export const removeExpense = ({ id } = {}) => ({
  type: 'REMOVE_EXPENSE',
  id
});

// EDIT_EXPENSE
export const editExpense = (id, updates) => ({
  type: 'EDIT_EXPENSE',
  id,
  updates
});
//Expenses Reducer
const expensesReducerDefaultState = [];

export default (state = expensesReducerDefaultState, action) => {
  switch (action.type) {
    case 'ADD_EXPENSE':
      return [
        ...state,
        action.expense
      ];
    case 'REMOVE_EXPENSE': 
      return state.filter(({ id }) => id !== action.id);
    case 'EDIT_EXPENSE':
      return state.map((expense) => {
        if (expense.id === action.id) {
          return {
            ...expense,
            ...action.updates
          };
        } else {
          return expense;
        };
      });
    default:
      return state;
  }
};

テストコード

テスト内容:該当のアイテムを削除するremoveExpense関数の、ationオブジェクトがきちんと入っているか

test('should setup remove expense action object', () => {
  const action = removeExpense({ id: '123abc'});
  expect(action).toEqual({
    type: 'REMOVE_EXPENSE',
    id: '123abc'
  });
});

先程の基本テストでは、toBe()を使いましたが、arrayやobjectでは使えません。

代わりに、toEqual()を使います。

上記の例では、手動でid: '123abc'を引数に指定していますが、idが動的に変わる場合は、expect.any()を使います。↓

(今回はuuidライブラリで、毎回アイテムが追加される度にランダムなidが設定されます)

テストコード

テスト内容:addExpense関数のactionオブジェクトの中身が、指定された中身であるか

test('should setup add expense action object with provided values', () => {
  const expenseData = {
    description: 'Rent',
    amount: 109500,
    createdAt: 1000,
    note: 'This was last months rent'
  };
  const action = addExpense(expenseData);
  expect(action).toEqual({
    type: 'ADD_EXPENSE',
    expense: {
      ...expenseData,
      id: expect.any(String) //中身がStringの型であるかだけ判定
    }
  });
});

state更新のテスト

続いて、reducerによってstateの値が変わったかをテストしていきます。

テストコード

テスト内容:Action(タイプ:ADD_EXPENSE)がdispatchされ、アイテムが追加されているか

※元のダミーデータ expensesに、新しいexpenseデータが追加される。

import expensesReducer from 'reducers';

// ダミーのデータを作る
const expenses = [{
  id: '1',
  description: 'Gum',
  note: '',
  amount: 195,
  createdAt: 0
}, {
  id: '2',
  description: 'Rent',
  note: '',
  amount: 1095000,
  createdAt: moment(0).subtract(4, 'days').valueOf()
}, {
  id: '3',
  description: 'Credit Card',
  note: '',
  amount: 45000,
  createdAt: moment(0).add(4, 'days').valueOf()
}];

test('should add an expense', () => {
  const expense = { //追加するデータ
    id: '109',
    description: 'Laptop',
    note: '',
    amount: 29500,
    createdAt: 20000
  };
  const action = {
    type: 'ADD_EXPENSE',
    expense
  };
  const state = expensesReducer(expenses, action);
  expect(state).toEqual([...expenses, expense]) //元のデータはスプレッド構文を使用して簡単に
});

テストコード

テスト内容:Action(タイプ:REMOVE_EXPENSE)がdispatchされ、該当するidのアイテムが削除されているか


test('should remove expense by id', () => {
  const action = {
    type: 'REMOVE_EXPENSE',
    id: expenses[1].id
  }
  const state = expensesReducer(expenses, action);
  expect(state).toEqual([expenses[0], expenses[2]]); //[expenses[1]以外のデータ
});

テストコード

テスト内容:Action(タイプ:REMOVE_EXPENSE)がdispatchされるが、idが存在しないのでstateは変わらない。(どのアイテムも削除されない)

test('shuold not remove expense if id not found', () => {
  const action = {
    type: 'REMOVE_EXPENSE',
    id: '-1'
  }
  const state = expensesReducer(expenses, action);
  expect(state).toEqual(expenses); //元のデータのまま
});

componentのテスト

続いてcomponentのテストをしてきます。

snapshotを取る

snapshotを取ることのメリットは、前回の出力内容との差分を確認できます。

(誤ってコードを修正してしまっていたり、予期せぬ変更などに気付けたりと、色々メリットがある)

まずは、react-test-rendererを追加します。

yarn add react-test-renderer

例として、こちらのHedaderコンポーネントを対象にします。

import React from 'react';
import { NavLink } from 'react-router-dom';

const Header = () => (
  <header>
    <h1>Expensify</h1>
    <NavLink to="/" activeClassName="is-active" exact={true}>Dashboard</NavLink>
    <NavLink to="/create" activeClassName="is-active">Create Expense</NavLink>
    <NavLink to="/help" activeClassName="is-active">Help</NavLink>
  </header>
);

export default Header;

テストコード

テスト内容:snapshotを取る

import React from 'react';
import ReactShallowRenderer from 'react-test-renderer/shallow';
import Header from '../components/Header';

test('should render Header correctly', () => {
  const renderer = new ReactShallowRenderer();
  renderer.render(<Header />);
  expect(renderer.getRenderOutput()).toMatchSnapshot();
});

shallowは、該当のcomponentのみを対象とします。

初めてsnapshotを取る時は、比較する差分がないので必ずテストをpassします。

中身が変わると前回の内容と比べて変化があればエラーとして表示されます。

例えば、<h1>の隣にabsという余計な文字を入れた場合

しかし、エラーではなく単純に修正してupdateしたい場合があります。

そういう場合は、 uキーを押せばsnapshotがアップデートされます。

Enzymeを導入する

上記のreact-test-renderer単体では書き方が大変なので、Airbnbが提供しているライブラリEnzymeを使います。

yarn add enzyme enzyme-adapter-react-16 raf@3.3.2

enzymeと、追加パッケージenzyme-adapterもreactのバージョンを指定してインストールします。

※現在、reactバージョン17で、enzyme-adapter-react-16を使用するとエラーになる部分があるようです。なので、reactのバージョンを下げるか、非公式のenzyme-adapter-reactを使う必要があります。

バージョンを下げる場合は、こちらのコマンドで。とりあえず、v16の一番最新にバージョン下げます。

yarn upgrade react@16.14.0

また、バージョンによっては、reactAnimationFrame/polyfillでエラーが出るようです↓

React depends on requestAnimationFrame. Make sure that you load a polyfill in older browsers.

その場合は、rafライブラリ(古いブラウザ=IEなどにreactAnimationFrameを機能させる)を追加することで解決されます。

yarn add raf

では、enzymeのセットアップをしていきます。

import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({
  adapter: new Adapter()
});

続いて、jestのセットアップをします。setupFiles [array]を使用

{
  "setupFiles": [
    "raf/polyfill", //rafを追加した場合
    "<rootDir>src/tests/setupTests.js" //ディレクトリの場所を指定
  ]
}

最後に、package.jsonで上記で設定したconfigファイルを見るように修正。

"test": "jest --config=jest.config.json"

では改めて、先ほどと同じsnapshotテストをします。

テストコード

テスト内容:snapshotを取る

(先程のコードと比較するとenzymeのおかげで、非常にシンプルに書けているのがわかります)

import React from 'react';
import { shallow } from 'enzyme';
import Header from '../../components/Header';

test('should render Header correctly', () => {
  const wrapper = shallow(<Header />);
  expect(wrapper).toMatchSnapshot();
});

snapshotの結果

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render Header correctly 1`] = `
ShallowWrapper {
  Symbol(enzyme.__root__): [Circular],
  Symbol(enzyme.__unrendered__): <Header />,
  Symbol(enzyme.__renderer__): Object {
    "batchedUpdates": [Function],
    "checkPropTypes": [Function],
    "getNode": [Function],
    "render": [Function],
    "simulateError": [Function],
    "simulateEvent": [Function],
    "unmount": [Function],
  },
  Symbol(enzyme.__node__): Object {
    "instance": null,
    "key": undefined,
    "nodeType": "host",
    "props": Object {
      "children": Array [
        <h1>
          Expensify
</h1>,
        <Unknown
          activeClassName="is-active"
          exact={true}
          to="/"
>
          Dashboard
//長すぎるので省略

結果ですが、enzymeライブラリの余計なobjectなどが入ってしまっています。

こちらを直すために、enzyme-to-jsonを入れる必要があります。

yarn add enzyme-to-json

wrapperをtoJsonで囲みます

テストコード

import React from 'react';
import { shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import Header from '../../components/Header';

test('should render Header correctly', () => {
  const wrapper = shallow(<Header />);
  expect(toJson(wrapper)).toMatchSnapshot();
});

結果↓

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render Header correctly 1`] = `
<header>
  <h1>
    Expensify
  </h1>
  <NavLink
    activeClassName="is-active"
    exact={true}
    to="/"
  >
    Dashboard
  </NavLink>
  <NavLink
    activeClassName="is-active"
    to="/create"
  >
    Create Expense
  </NavLink>
  <NavLink
    activeClassName="is-active"
    to="/help"
  >
    Help
  </NavLink>
</header>
`;

見やすく良い感じになりました!

toJsonで毎回囲みたくない時は、jest.confit.jsonファイルに下記を追加すれば、自動的に見やすいフォーマットに変換してスナップショットを取ってくれます。

  "snapshotSerializers": [
    "enzyme-to-json/serializer"
  ]

UIをテストする

ユーザーの操作によるテストをしていきます。

例えば下記のような、「ユーザーが入力→onChangeイベントが発火される→state更新」のよくあるパターンの場合。

import React from 'react';
import moment from 'moment';
import { SingleDatePicker } from 'react-dates';

export default class ExpenseForm extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      description: props.expense ? props.expense.description : '',
      note: props.expense ? props.expense.note : '',
      amount: props.expense ? (props.expense.amount / 100).toString() : '',
      createdAt: props.expense ? moment(props.expense.createdAt) : moment(),
      calendarFocused: false,
      error: ''
    };
  }
  onDescriptionChange = (e) => {
    const description = e.target.value;
    this.setState(() => ({ description }));
  };
  onNoteChange = (e) => {
    const note = e.target.value;
    this.setState(() => ({ note }));
  };
  onAmountChange = (e) => {
    const amount = e.target.value;

    if (!amount || amount.match(/^\d{1,}(\.\d{0,2})?$/)) {
      this.setState(() => ({ amount }));
    }
  };
  onDateChange = (createdAt) => {
    if (createdAt) {
      this.setState(() => ({ createdAt }));
    }
  };
  onFocusChange = ({ focused }) => {
    this.setState(() => ({ calendarFocused: focused }));
  };
  onSubmit = (e) => {
    e.preventDefault();

    if (!this.state.description || !this.state.amount) {
      this.setState(() => ({ error: 'Please provide description and amount.' }));
    } else {
      this.setState(() => ({ error: '' }));
      this.props.onSubmit({
        description: this.state.description,
        amount: parseFloat(this.state.amount, 10) * 100,
        createdAt: this.state.createdAt.valueOf(),
        note: this.state.note
      });
    }
  };
  render() {
    return (
      <div>
        {this.state.error && <p>{this.state.error}</p>}
        <form onSubmit={this.onSubmit}>
          <input
            type="text"
            placeholder="Description"
            autoFocus
            value={this.state.description}
            onChange={this.onDescriptionChange}
          />
          <input
            type="text"
            placeholder="Amount"
            value={this.state.amount}
            onChange={this.onAmountChange}
          />
          <SingleDatePicker
            date={this.state.createdAt}
            onDateChange={this.onDateChange}
            focused={this.state.calendarFocused}
            onFocusChange={this.onFocusChange}
            numberOfMonths={1}
            isOutsideRange={() => false}
          />
          <textarea
            placeholder="Add a note for your expense (optional)"
            value={this.state.note}
            onChange={this.onNoteChange}
          >
          </textarea>
          <button>Add Expense</button>
        </form>
      </div>
    )
  }
}

テストコード

テスト内容:最初のinput要素に入力された内容が、descriptionステートに追加されたか

import React from 'react';
import { shallow } from 'enzyme';
import ExpenseForm from '../../components/ExpenseForm';

test('should set description on input change', () => {
  const value = 'New description';
  const wrapper = shallow(<ExpenseForm />);
  wrapper.find('input').at(0).simulate('change', {
    target: { value }
  });
  expect(wrapper.state('description')).toBe(value);
});

simulate eventsを使ってinput操作をシミュレートできます。

また、findでタグを探せますが、inputが複数ある場合は、at()を使うことでどのinputが対象かを指定できます。

便利なjestグローバルAPI

jestのAPIは色々用意されていますが、例えばbeforeEach(fn)は、毎回同じコードを書いている場合に使えます。

二つのテストで、同じコードを3行書いている例

テストコード

テスト内容:①snapshotを取る ②onSubmit関数が正しく機能しているか

import React from 'react';
import { shallow } from 'enzyme';
import { AddExpensePage } from '../../components/AddExpensePage';
import expenses from '../fixtures/expenses';

test('should render AddExpensePage correctly', () => {
  const addExpense = jest.fn(); //①
  const history = { push: jest.fn() }; //②
  const wrapper = shallow(<AddExpensePage addExpense={addExpense} history={history}/>); //③
  expect(wrapper).toMatchSnapshot();
});

test('should handle onSubmit', () => {
  const addExpense = jest.fn(); //①
  const history = { push: jest.fn() }; //②
  const wrapper = shallow(<AddExpensePage addExpense={addExpense} history={history}/>); //③
  wrapper.find('ExpenseForm').prop('onSubmit')(expenses[1]);
  expect(history.push).toHaveBeenLastCalledWith('/');
  expect(addExpense).toHaveBeenLastCalledWith(expenses[1]);
});

使われているメソッドの補足

beforeEach(fn)を使ってみる

import React from 'react';
import { shallow } from 'enzyme';
import { AddExpensePage } from '../../components/AddExpensePage';
import expenses from '../fixtures/expenses';

let addExpense, history, wrapper;

beforeEach(() => {
  addExpense = jest.fn();
  history = { push: jest.fn() };
  wrapper = shallow(<AddExpensePage addExpense={addExpense} history={history}/>);
});

test('should render AddExpensePage correctly', () => {
  expect(wrapper).toMatchSnapshot();
});

test('should handle onSubmit', () => {
  wrapper.find('ExpenseForm').prop('onSubmit')(expenses[1]);
  expect(history.push).toHaveBeenLastCalledWith('/');
  expect(addExpense).toHaveBeenLastCalledWith(expenses[1]);
});

テストの前に毎回、beforeEach関数が実行されて、重複したコードを避けられます。

まとめ

以上、jestコードの色々なパターンをまとめてみました。

ドキュメントを見るとかなり多くのAPIやメソッドが用意されているので非常に便利ですが、逆にどれを使ったら良いか迷うことが多かったです、、

今回一通り勉強して、便利なメソッドやパターンを学習できたので、参考にして応用していこうと思います。

参考教材:The Complete React Developer Course (w/ Hooks and Redux)