Help us understand the problem. What is going on with this article?

Reactコンポーネントのテスト設計と実装(後編)

はじめに

この記事ではReactコンポーネントのテストユーティリティであるReact Testing Libraryについて説明します。

この記事で説明すること

  • 前編
    • Testing Trophyの概要
    • React Testing Libraryのコンセプト
  • 後編(この記事)
    • Reactコンポーネントのテストケースの設計
    • React Testing Libraryを使用したテストコードの実装

開発環境

  • React: 16.12.0
  • Jest: 24.9.0
  • React Testing Library: 9.3.2

React Testing Libraryを使ってみようと思ったきっかけ

だいぶ前のことになるのですが、ReactコンポーネントのテストユーティリティとしてEnzymeに代えてReact Testing Libraryを使ってみました。

Enzymeを使っていたときは、find(#id)find(displayName)といったコードをよく書いていました。ただこのようなコードを書くと、id名やdisplayNameを変更するというリファクタリングをしただけでもテストコードを書き直す必要が出てきてしまいます。

これではリファクタリングのコストが大きすぎるということで、Enzymeで良い方法が無いかを探していたところ、むしろEnzymeに代わるものとしてReact Testing Libraryというライブラリがあることを知りましたのでこのライブラリを使用してみました。合わせて、React Testing Libraryの作者によりTesting Trophyという考え方が提唱されているということも知りました。

そこで、この記事では私がReact Testing Libraryを学習する中で知ったTesting TrophyとReact Testing Libraryの考え方と、この考え方を実際のテストコードに適用した例を説明します。

コンポーネント単体のテスト

まず最初にコンポーネント単体のテストについて考えます。

テストの方針

テストの方針としては、React Testing Libraryの開発者であるKent C. Dodds氏の言葉にあるように、propsと描画される結果に着目します。

so what parts of our code do each of these users use, see, and know about? The end user will see/interact with what we render in the render method. The developer will see/interact with the props they pass to the component. So our test should typically only see/interact with the props that are passed, and the rendered output.

また、ロジックがない部分については、単純な実装でありテストをする必要性が低いものであったり、型チェックなどができるため、コンポーネント単体のテストの対象からは除外します。

Things that really have no logic in them at all (so any bugs could be caught by ESLint and Flow). Maintaining tests like this actually really slow you and your team down.

つまり、コンポーネント単体のテストとしては、以下のようなコンポーネントをテストする方針とします。

  • テスト対象のコンポーネント
    • コンポーネント内で何らかのDOM要素を描画する際にロジックを含むもの
  • テストの内容
    • コンポーネントのpropsの値に応じてDOM要素が正しい内容で描画されるかどうか

テスト設計の手順

前述したテストの方針を踏まえて、以下の手順でテストケースの設計を行います。

  1. コンポーネントのpropsをリストアップする

  2. 各propsが取り得る値をリストアップする

    1. propsの初期状態がどのようになっているか
    2. 正常時にはpropsがどのような値を取り得るか
    3. エラー時にはpropsがどのような値を取り得るか
  3. 各propsの取り得る値ごとに期待値(=コンポーネントの描画結果)を定義する

  4. それぞれの状態をテストケースとする

テスト設計とテストコードの実例

では、実際にテストケースの設計とテストコードの実装を行ってみます。

テスト対象のコードとして、Reduxのチュートリアルで使用されているTodoリストを使用します。

(本記事では、Todoリストアプリのコードについては説明はしませんので、コードの内容はReduxのチュートリアルページを見てください。また、Todoリストアプリの完成イメージはこちらで確認できます)

1つ目の例として、Todoコンポーネントのテスト設計をします。

1.コンポーネントのpropsをリストアップする
Todoコンポーネントが受け取るpropsはcompleted: booleantext: stringになります。

2.各propsが取り得る値をリストアップする

  • 初期状態: なし
  • 正常値:
    • completed = true, text = 'todo item'
    • completed=false, text='todo item'
  • エラー値: なし

3.各propsの取り得る値ごとに期待値(=コンポーネントの描画結果)を定義する

  • 正常値
    • completed = true, text = 'todo item' → Todoコンポーネント内にlist要素があり、text-decoration: line-throughのstyleが適用されている
    • completed=false, text='todo item' → Todoコンポーネント内にlist要素があり、text-decoration: line-throughのstyleが適用されていない

4.それぞれの状態をテストケースとする
1〜3をまとめると以下のようになります。

(2)正常値のケース
(2-1)completed=true

(2-1)completed=false
props This This
 completed true false
 text 'todo item' 'todo item'
期待値
 list要素 あり あり
 style text-decoration: line-through text-decoration: none

実際のコードは以下のようになります。

import React from 'react';
import { render } from '@testing-library/react';

import Todo from './Todo';

describe('Todo component', () => {
  describe('(2-1)completed=true', () => {
    it('has list and line-through style', () => {
      const todo = 'todo item'
      const completed = true
      const onClickMock = jest.fn();

      const { getByText } = render(<Todo text={todo} completed={completed} onClick={onClickMock}/>);

      expect(getByText(todo)).toBeInTheDocument()
      expect(getByText(todo)).toHaveStyle('text-decoration: line-through;')
    })
  });

  describe('(2-1)completed=false', () => {
    it('has list and no line-through style', () => {
      const todo = 'todo item'
      const completed = false
      const onClickMock = jest.fn();

      const { getByText } = render(<Todo text={todo} completed={completed} onClick={onClickMock}/>);

      expect(getByText(todo)).toBeInTheDocument()
      expect(getByText(todo)).toHaveStyle('text-decoration: none;')
    })
  });
})

APIリファレンス

サンプルコード内で使用されているReact Testing Libraryの各APIについては、公式のAPIリファレンスをご参照ください。

2つ目の例として、TodoListコンポーネントのテスト設計をします。

1.コンポーネントのpropsをリストアップする
TodoListコンポーネントが受け取るpropsはtodosになります

todos = [{
  id: 1,
   completed: true,
   text: 'Todo Item 1',
    }, {
      id: 2,
   completed: false,
   text: 'Todo Item 2',
  }
]

2.各propsが取り得る値をリストアップする

  • 初期状態: todos=[ ]
  • 正常値: todos = [{id:1,...}, {id:2,...}, {id:3,...}]
  • エラー値: なし(配列以外の値がpropsで渡される可能性はありますが、型チェックによってテストできているものとして、今回のテストからは除外します)

3.各propsの取り得る値ごとに期待値(=コンポーネントの描画結果)を定義する

  • 初期状態: Todoリスト(list要素)が描画されない
  • 正常値: todosの配列の要素数分だけ、Todoリスト(list要素)が描画される

4.それぞれの状態をテストケースとする
1〜3をまとめると以下のようになります。

Left align Right align Center align
This This This
column column column
will will will
be be be
left right center
aligned aligned aligned
(a)初期状態のケース (b)正常値のケース
props
 todos [ ] todos=[{id:1,...}, {id:2,...}, {id:3,...}]
期待値
 Todoリストの数 なし 3つ

実際のコードは以下のようになります。

import React from 'react';
import { render } from '@testing-library/react';

import TodoList from './TodoList';

describe('TodoList component', () => {
  describe('(a)初期状態のケース', () => {
    it('has no todo item', () => {
      const todos =[];
      const toggleTodoMock = jest.fn();
      const { queryByTestId } = render(<TodoList todos={todos} toggleTodo={toggleTodoMock}/>);

      // Todoリストの数が0個であることを確認する
      expect(queryByTestId('todo')).toBeNull();
    })
  });

  describe('(b)正常値のケース', () => {
    it('has 3 todo item', () => {
      const todos =[{
        id: 0,
        completed: false,
        text: 'パンを買う',
      }, {
        id: 1,
        completed: false,
        text: '牛乳を買う',
      }, {
        id: 2,
        completed: true,
        text: '掃除する'
      }];
      const toggleTodoMock = jest.fn();

      const { queryAllByTestId } = render(<TodoList todos={todos} toggleTodo={toggleTodoMock}/>);
      // Todoリストの数が3個であることを確認する
      expect(queryAllByTestId('todo').length).toBe(3);
    })
  });
})

APIリファレンス

サンプルコード内で使用されているReact Testing Libraryの各APIについては、公式のAPIリファレンスをご参照ください。

ここで、queryByTestIdというものが出てきましたので少し補足します。

ByTestIdというのは、APIリファレンスによると以下のようなものになります。

The ...ByTestId functions in DOM Testing Library use the attribute data-testid

In the spirit of the guiding principles, it is recommended to use this only after the other queries don't work for your use case. Using data-testid attributes do not resemble how your software is used and should be avoided if possible.

つまり、画面に表示されるテキスト(ByText)やラベル(ByLabelText)のようにユーザーから見えるDOM要素を取得するべきなのですが、今回の様なテストケースではそういったものがないため、ByTestIdで代用します。

また、ByTestIdで要素を取得できるようにTodo.jsコンポーネントも以下のように修正します。

import React from 'react'
import PropTypes from 'prop-types'

const Todo = ({ onClick, completed, text }) => (
  <li
    onClick={onClick}
    style={{
      textDecoration: completed ? 'line-through' : 'none'
    }}
    data-testid="todo" // ← この行を追加
  >
    {text}
  </li>
)
...
export default Todo

シナリオテスト

次にシナリオテストについて考えてみます。

テストの方針

シナリオテストとしては、ユーザーが実際に操作する方法でテストを行えばよいかと思います。

Write down a list of instructions for that user to manually test that code to make sure it's not broken. (render the form with some fake data in the cart, click the checkout button, ensure the mocked /checkout API was called with the right data, respond with a fake successful response, make sure the success message is displayed).

Turn that list of instructions into an automated test.

テスト設計の手順

具体的なテストケースとしては以下のような手順で設計します。

  1. ユーザーがアプリケーション使用する際のシナリオを書き出す
    要件定義書や設計書があるならば、ユースケース一覧やユースケース記述からシナリオを書き出せばよいかと思います。

  2. 各ユースケースの初期状態を決める

  3. 各ユースケースのイベントで発生する内容を洗い出す

  4. 2と3の期待値を定義する

  5. テスト内容としてまとめる

テスト設計とテストコードの実例

引き続き、Reduxのチュートリアルで使用されているTodoリストアプリを使用して実際のテストケースの設計を行ってみます。

1.ユーザーがアプリケーション使用する際のシナリオを書き出す
シナリオとしては以下の3つになります。

  • (a) Todoリストを追加する
  • (b) フィルターを切り替える
  • (c) Todoリストを完了済みにする

2.各ユースケースの初期状態を決める

  • (a) Todoリストを追加する
    • フィルターは'All'、Todoリストは空の状態
  • (b) フィルターを切り替える
    • フィルターは'All'、Todoリストは3つ登録された状態
  • (c) Todoリストを完了済みにする
    • フィルターは'All'、Todoリストは3つ登録された状態

3.各ユースケースのイベントで発生する内容を洗い出す

  • (a) Todoリストを追加する
    • 空の文字列のTodoリストを追加する
    • Todoリストを追加する
  • (b) フィルターを切り替える
    • フィルターを'Active'に切り替える
    • フィルターを'Completed'に切り替える
  • (c) Todoリストを完了済みにする
    • Todoリストを未完了から完了済みにする
    • Todoリストを完了済みから未完了にする

4.2と3の期待値を定義する

(a) Todoリストを追加する (b) フィルターを切り替える (c) Todoリストを完了済みにする
期待値
 初期状態 (a-1)
・Todoリストなし
・Allのフィルターが選択された状態
(b-1)
・すべてのTodoコンポーネントが表示された状態
・Allのフィルターが選択された状態
(c-1)
・すべてのTodoコンポーネントが表示された状態
・Allのフィルターが選択された状態
 イベント発生後 (a-2)
[空の文字列のTodoリストを追加する]
・Todoリストなし
(b-2)
[フィルターを'Active'に切り替える]
・未完了のTodoリストのみが表示された状態
・Activeのフィルターが選択された状態
(c-2)
[Todoリストを未完了から完了済みにする]
・完了済みにしたTodoリストには取り消し線が引かれる
(a-3)
[Todoリストを追加する]
・Todoリストが1つ
(b-3)
[フィルターを'Completed'に切り替える]
・完了済みのTodoリストのみが表示された状態
・Completedのフィルターが選択された状態
(c-3)
[Todoリストを完了済みから未完了にする]
・未完了にしたTodoリストには取り消し線が削除される

実際のテストコードとしては以下のようになります。

import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { createStore } from 'redux'
import { Provider } from 'react-redux'

import reducer from '../reducers'
import App from './App';

function renderWithRedux(
  ui,
  { initialState, store = createStore(reducer, initialState) } = {}
) {
  return {
    ...render(<Provider store={store}>{ui}</Provider>),
    store,
  }
}

describe('App component', () => {
  describe('(a) Todoリストを追加する', () => {
    it('add todo', () => {
      const { getByText, queryByTestId, getByTestId } = renderWithRedux(<App />);

      // (a-1)初期状態の確認
      // Todoリストなし
      expect(queryByTestId('todo')).toBeNull();
      // Allのフィルターが選択された状態
      expect(getByText('All')).toBeDisabled();
      expect(getByText('Active')).toBeEnabled();
      expect(getByText('Completed')).toBeEnabled();

      // (a-2)空の文字列のTodoリストを追加する
      fireEvent.change(getByTestId('input'), { target: { value: '' } })
      // Todoリストなし
      expect(queryByTestId('todo')).toBeNull();

      // (a-3)Todoリストを追加する
      fireEvent.change(getByTestId('input'), { target: { value: '123' } })
      fireEvent.click(getByText('Add Todo'))
      // Todoリストが1つ
      expect(getByText('123')).toBeInTheDocument();

    });
  });


  describe('(b) フィルターを切り替える', () => {
    it('switch filter', () => {
      const todos =[{
        id: 0,
        completed: false,
        text: 'パンを買う',
      }, {
        id: 1,
        completed: false,
        text: '牛乳を買う',
      }, {
        id: 2,
        completed: true,
        text: '掃除する'
      }];
      const { getByText, queryByText } = renderWithRedux(<App />, {
        initialState: {todos: todos},
      });

      // (b-1)初期状態の確認
      // すべてのTodoコンポーネントが表示された状態
      expect(getByText(todos[0].text)).toBeInTheDocument()
      expect(getByText(todos[0].text)).toHaveStyle('text-decoration: none;')

      expect(getByText(todos[1].text)).toBeInTheDocument()
      expect(getByText(todos[1].text)).toHaveStyle('text-decoration: none;')

      expect(getByText(todos[2].text)).toBeInTheDocument()
      expect(getByText(todos[2].text)).toHaveStyle('text-decoration: line-through;')

      // Allのフィルターが選択された状態
      expect(getByText('All')).toBeEnabled();
      expect(getByText('Active')).toBeDisabled();
      expect(getByText('Completed')).toBeEnabled();


      // (b-2)フィルターを'Active'に切り替える
      fireEvent.click(getByText('Active'))

      // 未完了のTodoリストのみが表示された状態
      expect(getByText(todos[0].text)).toBeInTheDocument()
      expect(getByText(todos[0].text)).toHaveStyle('text-decoration: none;')

      expect(getByText(todos[1].text)).toBeInTheDocument()
      expect(getByText(todos[1].text)).toHaveStyle('text-decoration: none;')

      expect(queryByText(todos[2].text)).toBeNull()

      // Activeのフィルターが選択された状態
      expect(getByText('All')).toBeEnabled();
      expect(getByText('Active')).toBeDisabled();
      expect(getByText('Completed')).toBeEnabled();


      // (b-3)フィルターを'Completed'に切り替える
      fireEvent.click(getByText('Completed'))

      // 完了済みのTodoリストのみが表示された状態
      expect(queryByText(todos[0].text)).toBeNull()

      expect(queryByText(todos[1].text)).toBeNull()

      expect(getByText(todos[2].text)).toBeInTheDocument()
      expect(getByText(todos[2].text)).toHaveStyle('text-decoration: line-through;')

      // Completedのフィルターが選択された状態
      expect(getByText('All')).toBeEnabled();
      expect(getByText('Active')).toBeEnabled();
      expect(getByText('Completed')).toBeDisabled();

    })
  });

  describe('Toggle todo scenario', () => {
    it('toggle todo', () => {
      const todos =[{
        id: 0,
        completed: false,
        text: 'パンを買う',
      }, {
        id: 1,
        completed: false,
        text: '牛乳を買う',
      }, {
        id: 2,
        completed: true,
        text: '掃除する'
      }];

      const { getByText } = renderWithRedux(<App />, {
        initialState: {todos: todos},
      });

      // (c-1)初期状態の確認

      // すべてのTodoコンポーネントが表示された状態
      expect(getByText(todos[0].text)).toHaveStyle('text-decoration: none;')
      expect(getByText(todos[1].text)).toHaveStyle('text-decoration: none;')
      expect(getByText(todos[2].text)).toHaveStyle('text-decoration: line-through;')

      // Allのフィルターが選択された状態
      expect(getByText('All')).toBeEnabled();
      expect(getByText('Active')).toBeDisabled();
      expect(getByText('Completed')).toBeEnabled();


      // (c-2)Todoリストを未完了から完了済みにする
      fireEvent.click(getByText(todos[0].text));

      // 完了済みにしたTodoリストには取り消し線が引かれる
      expect(getByText(todos[0].text)).toHaveStyle('text-decoration: line-through;')
      expect(getByText(todos[1].text)).toHaveStyle('text-decoration: none;')
      expect(getByText(todos[2].text)).toHaveStyle('text-decoration: line-through;')


      // (c-3)Todoリストを完了済みから未完了にする
      fireEvent.click(getByText(todos[2].text));

      // 未完了にしたTodoリストには取り消し線が削除される
      expect(getByText(todos[0].text)).toHaveStyle('text-decoration: line-through;')
      expect(getByText(todos[1].text)).toHaveStyle('text-decoration: none;')
      expect(getByText(todos[2].text)).toHaveStyle('text-decoration: none;')

    });
  });
})

APIリファレンス

サンプルコード内で使用されているReact Testing Libraryの各APIについては、公式のAPIリファレンスをご参照ください。

またReduxコンポーネントをテストをするときにRedux storeの値をコンポーネントを渡す方法は、公式サイトに記述されている方法を参考にしています。

参考にしたサイト

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした