NHN Cloud NHN Cloud Meetup!

実用的なフロントエンドのテスト戦略(3)

2部では、ストーリーブックを使ってビジュアル要素のテストを自動化する方法を紹介しました。私たちが作成したタスク管理アプリケーションの実行手順をもう一度整理してみましょう。

  1. アプリケーションが実行されたら、画面にメインUIを表示する
  2. APIサーバーから「ToDoリスト」のデータを受け取り、Reduxストアに保存する
  3. 保存されたストアの値に応じてToDoリストをUIで表示する
  4. ユーザーが入力ボックスをクリックし、「昼寝」と入力してEnterキーを入力する
  5. ReduxストアのToDoリストに「昼寝」を追加する
  6. 変更されたストアの値によってUIを更新する
  7. 変更されたストアの状態をサーバーに送信して同期する

この実行手順は、「アプリケーションの状態」を基準に2つに区分できます。まず、取り消し線がついた1,3,6は、現在のアプリケーションの状態を画面(UI)に表示する段階です。2部では、この段階でテストの自動化が難しい理由と、ストーリーブックを使ってこれらの問題の解決方法を紹介しました。

残りの2,4,5,7は、すべてのアプリケーションの状態を管理(操作)する段階です。この段階は、ユーザーの入力を受けて状態を変更する部分(4,5)と、クライアントの状態とサーバーの状態を同期する部分(2,7)に大きく分けることができます。3部では、この段階における従来のテスト方法と、Cypressを使ったテストと比較して、どのようなメリットがあるか紹介したいと思います。

モジュール別の単体テストを作成する

Reduxを使ったReactアプリケーションは、通常アクションコンストラクタ(action creator)、リデューサー(reducer)、コンテナ(container)コンポーネント、視覚的(presentational)コンポーネントで構成されます。また、ネットワークIO、ローカルストレージなどの付随効果が必要な場合は、ミドルウェアのコードを追加します。ここで使用するサンプルは、サーバーとの非同期通信を処理するredux-thunkを使用するため、付随効果を扱うコードは、アクションのコンストラクタ内で処理します。

それぞれのモジュールは独自の役割を持ち、関数やクラス単位でうまく分離されているので、各モジュールの単体テストは簡単に作成ができます。Reduxの公式チュートリアルでも、役割に応じたモジュール別の単体テストを作成する方法が説明されており、最も広く使用されているReactテストライブラリのEnzymeなどでもこの方法を推奨しています。

しかし、テストフレームワークでも小さな単位に分割するモックオブジェクトの使用が増えてきており、モジュール間の接続状態を確認するのが難しくなっています。例えば、「タスク追加」機能をテストするため、各モジュールの単体テストを作成しようとすると、以下のような4つの個別テストが必要になるでしょう。

(このサンプルでは、JestとEnzymeを使用しますが、各ツールの使用方法までは説明しません。JavaScriptでは大半のテストフレームワークが同じようなAPIを持っているため、Jestを使用したことがない方でもコードは楽に読み取れるでしょう。Enzymeの詳しい使い方は、公式APIドキュメントを参照してください。)

1.コンテナコンポーネント(Header)

Headerコンポーネントとストアを連結するコンテナコンポーネントが、アクションコンストラクタのaddTodo()関数をストアと連結して、子コンポーネントのPropsにaddTodoという名前で伝達されるか検証します。

import React from 'react';
import Header from '../components/Header';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {shallow} from 'enzyme';
import {configure} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import {addTodo} from '../actions';

configure({adapter: new Adapter()});
jest.mock('../actions', () => ({
  addTodo: jest.fn().mockReturnValue({type: 'ADD_TODO'})
}));
const mockStore = configureStore([thunk]);

it('should pass addTodo action to child component', () => {
  const store = mockStore({});
  const component = shallow(<Header store={store} />).first();
  const todoText = 'Hava Lunch';

  component.prop('addTodo')(todoText);

  expect(addTodo).toBeCalledWith(todoText);
});

2.視覚コンポーネント(Header)

Headerコンポーネントのinput要素の値を変更した後、Enterキーを入力したとき、Propに付与されたaddTodo()関数を実行するか検証します。

import React from 'react';
import {configure} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import {shallow} from 'enzyme';
import {Header} from '../components/Header';

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

it('should dispatch addTodo when input text', () => {
  const addTodo = jest.fn();
  const wrapper = shallow(<Header addTodo={addTodo} />);
  const todoText = 'Have Lunch';

  const input = wrapper.find('input');
  input.simulate('change', {target: {value: todoText}});
  input.simulate('keydown', {keyCode: 13});

  expect(addTodo).toBeCalledWith(todoText);
});

3.(非同期)アクションコンストラクタ

redux-thunkを使った非同期アクションコンストラクタのaddTodo()関数を実行すると、ADD_TODOアクションを発生させて、サーバーに同期要請を送信するか検証します。

import axios from 'axios';
import {addTodo, ADD_TODO} from '../../src/actions';

it('should dispatch ADD_TODO action and update Server data', () => {
  jest.spyOn(axios, 'put');
  const todos = [{id: 1}];
  const getState = () => ({todos});
  const dispatch = jest.fn();

  const thunkAction = addTodo('Have Lunch');
  thunkAction(dispatch, getState);

  expect(dispatch).toHaveBeenCalledWith({
    type: ADD_TODO,
    text: 'Have Lunch'
  });
  expect(axios.put).toHaveBeenCalledWith('/todos', todos);
});

4.レデューサー

todosレデューサーがADD_TODOアクションを受け取り、既存の配列に新しいタスク項目を追加して、新しい配列を返却するか検証します。

import {todos} from '../../src/reducers';
import {ADD_TODO} from '../actions';

it('should handle ADD_TODO', () => {
  const prevState = [
    {
      id: 1,
      text: 'Have Breakfast',
      completed: true
    }
  ];

  const action = {
    type: ADD_TODO,
    text: 'Have Lunch'
  };

  expect(todos(prevState, action)).toEqual([
    ...prevState,
    {
      id: 2,
      text: 'Have Lunch',
      completed: false
    }
  ]);
});

この方法で作成されたテストコードは、各モジュールを分離してテストするため、モックオブジェクトをたくさん使用します。これによって、不要なコードが増え、実際のモジュール間の連結に対する検証ができません。例えば、Headerコンテナコンポーネントが子コンポーネントに伝達する関数名をaddTodoからappendTodoに変更しても、実際のHeader(視覚)コンポーネントのテストは失敗しません。また、todosレデューサーのみを分離してテストするため、combineReducersを使ってルートレデューサーを作成するとき、todosレデューサーが不足していてもこれを検証することができません。

このような理由から、個々のモジュール単位の小さな単体テストをする代わりに、複数のモジュールを組み合わせた形態を1つの単位とみなして、より大きい規模の単体テストを作成することをお勧めします。用語を統一するため、これからは比較的大規模な単体テストを統合テストと呼ぶようにします。

(テストを区分する用語は、実際のところ明確に定義されたものがないので、人によって使い方や意味が若干異なるようです。各用語の詳しい説明は、マーティン・ファウラーの単体テスト統合テストを参照してください。)

統合テストを作成する

統合テストを作成するには、まず単位を区分する境界を決める必要があります。例えば、アクションのコンストラクタとレデューサー、ストアをまとめてテストしたり、ストアとコンテナコンポーネントだけをまとめてテストすることもできます。分割範囲によってそれぞれ長短があるので、状況に応じて適切な範囲を選択しましょう。ここでは、個別のモジュール単位のテストとの違いを明確にするため、ストアとルーターを生成するメインモジュールを除く、すべてのモジュールをまとめてテストします。

2部では、ストアの状態を操作しながらストアの状態による視覚的な表現を検証しました。そこで今回は、逆にユーザーの入力値に基づいてストアを変更するコードを作ってみましょう。個別モジュールの単体テストと比較するため、今回も「タスク追加」機能のテストを作成します。まず、完成したコードから見てみましょう。

(下のサンプルでは、react-testing-libraryを使って統合テストを作成しました。Enzymeと違い大きな単位の統合テストを指向するAPIを提供します。公式文書で、このライブラリの哲学と使い方を確認できます。)

import React from 'react';
import axios from 'axios';
import {render, fireEvent} from 'react-testing-library';
import {StaticRouter, Route} from 'react-router-dom';
import {Provider} from 'react-redux';
import MockAdapter from 'axios-mock-adapter';
import 'jest-dom/extend-expect';

import {createStore} from '../store';
import App from '../components/App';

it('should append todo item when input new todo', async () => {
  // (1-1) ストアで現在の状態を設定する
  const initialState = {
    todos: [
      {
        id: 1,
        text: 'Have Breakfast',
        completed: true
      }
    ]
  };
  const store = createStore(initialState);

  // (1.2) サーバー同期要請を確認するためのaxiosモック
  jest.spyOn(axios, 'put');

  // (1.3) 実際のアプリケーションレンダリング
  cosnt {getByTestId} = render(
    <Provider store={store}>
      <StaticRouter location="/All" context={{}}>
        <Route path="/:nowShowing" component={App} />
      </StaticRouter>
    </Provider>
  );

  // (2) inputにテキストを入力した後、Enterキー入力
  const todoInput = getByTestId('todo-input');
  fireEvent.change(todoInput, {target: {value: 'Have a Coffee'}});
  fireEvent.keyDown(todoInput, {keyCode: 13});

  // (3-1) ストアの現在の状態を検証する
  expect(store.getState().todos).toEqual([
    ...initialState.todos,
    {
      id: 2,
      text: 'Have a Coffee',
      completed: false
    }
  ]);

  // (3-2) サーバーに同期を要請したか検証する
  await Promise.resolve();
  expect(axios.put.mock.calls[0][1]).toEqual(store.getState().todos);
});

1つの機能テストとしてはコードが長いように見えますが、全体のコードをテスト準備(1)、実行(2)、検証(3)の3段階に分けると、タスクが明確になります。まず、準備段階ではストアの状態を設定して(1-1)、サーバー同期要請をモックし(1-2)、コンポーネントをレンダリング(1-3)します。実行段階では、画面に表示された入力ボックスにテキストを入力した後、Enterキーを入力(2)します。そして最後に、ストアの現在の状態を検証して(3-1)、サーバー同期を要請したか検証(3-2)します。

モジュール別に作成されたテストと統合テストコードを比較してみましょう。まず、不要なモック作業が少ないことから、全体のコード量が減り、テストコードがやるべきことがより明確になりました。また、内部の実装ロジックへの依存性がないため、コンポーネントの構造やPropsが変更されても、テストが影響を受けません。逆に、親が配信するPropsと子が使用するPropsが異なる場合、テストが失敗することでモジュール間の連結も検証できます。

(細部実装をテストするデメリットについては、react-testing-libraryを作成したKent.C.Doddsが、Testing Implementation Detailsという文章で詳しく説明しています。興味のある方はぜひ読んでみてください。)

DOMでアプリケーションの状態を確認する

アプリケーションの状態を基準に、現在の状態を視覚的に表す部分と、ユーザー入力を受けて現在の状態を変更する部分をテストしました。しかし、まだ自動化できるテストが残っています。それがDOMのテストです。

DOMのテストは自動化が難しいので、目で直接確認するのが最も効率的であると紹介しましたが、実際にはこれは半分だけ正しいです。なぜならば、DOMは単にビジュアル要素だけを表現するものではないからです。テストの自動化が困難なビジュアル要素は、具体的にレイアウト、色、フォント、画像などの要素を指し、これはDOMのツリー構造とスタイル(CSS)の情報を合わせたものです。しかし、DOMでこれらのビジュアル要素を分離しても、テキスト、DOMの手順、特定のDOM要素の状態などは、まだデータとして検証できるため、自動化が可能です。

例えば、ストアのtodosの配列状態によって、画面にToDoリストを表示する機能について、考えてみましょう。既存のストーリーブックのテストでは、UIのすべての部分を人が直接目でテストする必要がありました。しかし厳密には、ToDo項目のDOM要素が順に表示されるか、各ToDo項目のテキストがストアに保存された値と等しいか、などはデータを用いて自動化できる部分です。これらを自動化すると、ストーリーブックを使って自分の目でテストするとき、データの部分を気にせずに、残りのビジュアル要素に集中して検証することができます。

実際にコードを見ながら確認してみましょう。画面に表示されたToDoリストの状態を、DOMを使って検証する場合、次のようにテストコードを作成できます。

it('should render todo items', () => {
  const store = createStore({
    todos: [
      {
        id: 1,
        text: 'Have Breakfast',
        completed: true
      },
      {
        id: 2,
        text: 'Have Lunch',
        completed: false
      }
    ]
  });

  const { getAllByTestId } = render(
    <Provider store={store}>
      <StaticRouter location='/All' context={{}}>
        <Route path="/:nowShowing" component={App} />
      </StaticRouter>
    </Provider>
  );

  const todoItems = getAllByTestId('todo-item');

  expect(todoItems.length).toBe(2);
  expect(todoItems[0]).toHaveTextContent('Have Breakfast');
  expect(todoItems[0]).toHaveClass('completed');
  expect(todoItems[1]).toHaveTextContent('Have Lunch');
  expect(todoItems[1]).not.toHaveClass('completed');
});

このテストコードでは、ToDo項目の数と順序は、各項目のテキストとチェックの状態を検証しています。このようなテストで最も注意すべきことは、DOM構造への依存を最小限に抑えることです。例えば、特定のDOM要素の親が誰か、どのようなタグやクラスを使用するかなどは、アプリケーションの状態よりもビジュアル要素の表現に、より関連性があります。安定したテストを作成するには、テストでDOMを検索する際、このような属性の変更に対して、可能な限り影響を受けないようにする必要があります。タグセレクタ、子セレクタ、クラスセレクタなどは使用しない方がよいでしょう。

react-testing-libraryでは、ユーザーに公開されている情報(主にテキスト)を元にDOMを検索することを推奨しています。それだけで検索できない場合は、クラスではなく、data-testidプロパティの使用を推奨しています。このテストでは、項目の個数と順序を確認する必要があり、テキストのみでテストするのが難しいため、すべてのToDo項目のdata-testid属性にtodo-itemという値を指定してテストしています。

なお、各ToDo項目のチェックされた状態を確認するとき、completedクラスを保持するか確認していますが、これはcompletedクラスがビジュアル要素のみならず、クラスの状態を表す役割もしているためです。DOM検索のためクラスを使用するのは避けるべきですが、このように特定のDOM要素の状態を検証するときは、クラスを使用するのが有用です。さらに厳密に分離したい場合は、視覚的表現のクラスと状態を表すクラスを区別して使用することもできます。

統合テスト(Jest)vs E2Eテスト(Cypress)

これですべてのテストが終わりました。小さな範囲の単体テストと比較して大きな範囲の統合テストが持つ利点も理解できたでしょう。実際のところ、Jestとreact-testing-libraryは非常に強力なツールで、この両方をうまく使用すると、あえてCypressを使用しなくても、効率的なテストを作成することができます。では、Cypressはどんな問題が解決できるのでしょうか?

第一に、Jestは実際のブラウザではなく、JSDomを用いた仮想ブラウザ環境で実行されるため、制約があります。例えば、ブラウザのレンダリングエンジンを使用できないことから、実際にレンダリングされた結果であるピクセル情報を受け取ることができません。よって、URLの変更などを処理する方法が異なり、ルーターの動作をテストするのが困難です。以前に作成したコードでStaticRouterを毎回モックして注入している理由は、アプリケーションコードのBrowserRouterをそのまま使用することができないためです。Cypressは実際のブラウザ環境で実行されるので、これらの制約を受けず、ブラウザのすべての機能を利用することができます。

第二は、デバッグのしやすさです。JestのインタラクティブなCLI環境は非常に強力で、テストが失敗すると、有用な情報を提供してくれます。しかし問題は、実際の画面に表示されるUIを見ることができないことです。特にDOMを使って、アプリケーションの状態を検証するとき、テストが失敗した理由を探すためには、console.log()を懸命に取得したり、複雑なHTML文字列を目を皿のようにして確認するしかありません。

一方、Cypressはブラウザで実行されるため、実際の画面に表示されるUIを見ながら、コードを作成したり、デバッグを行うことができます。それだけでなく、テストで実行したすべてのコマンドと、その時点のアプリケーションの状態が、コマンドログに記録されるため、録画されたビデオを巻き戻すように、簡単にデバッグを行うことができます。また、ブラウザの開発ツールをそのまま使用できるので、console.log()に頼らなくても、インタラクティブな環境でデバッグができます。

この他にも、Cypressはユーザーの入力をシミュレートできるAPIを提供して、直接DOMイベントを発生させる必要もなく、直感的にテストコードを作成することができます。サーバーデータをモックできるAPIを提供しており、特定のライブラリに依存しなくても、簡単にサーバーのデータをモッキングできます。

E2EテストとCypress

このようにCypressは、従来のJest基盤の統合テストよりも優れたテスト環境を提供してくれます。従来のE2EテストとCypressの違いについて確認しましょう。

E2Eテストは、通常システム全体をユーザー観点でテストすることを意味します。伝統的なWeb環境でのE2Eテストは、ブラウザを使用してシステム全体をテストすることを意味し、テストツールでは、Selenium、Webドライバがよく使用されています。しかし、Selenium、Webドライバは設定やテストコードの作成が難しく、テストの実行速度が遅いため、開発者ではなくQAなどの専門組織で部分的に活用される場合が多いようです。

一方、Cypressは既存のE2Eテストツールとは異なり、フロントエンドの開発者が開発過程でテストが作成できるようにサポートすることを目的としています。開発過程でテストするときは、迅速なフィードバックを受ける必要があります。Cypressはブラウザと統合した形態の構造となっており、Seleniumよりもはるかに速い速度を提供します。また、フロントエンドテストでシステム全体をそのまま使用するのではなく、バックエンドAPIをモックすることを推奨し、これを支援するために、様々なマーキング機能を提供しています。上記のコマンドログなどのインタラクティブな機能を活用すると、別途開発環境がなくても、Cypressだけで開発ができます。

(バックエンドをモッキングした状態のテストは、E2Eテストというより統合テストに近いと言えます。しかし、前述した用語の定義は、固定されたものではなく、柔軟に適用することができます。また、Cypressは主な目的が統合テストであるだけで、従来のE2Eテストの用途としても十分に使用できます。したがって、この記事ではE2Eテストツールに分類します。)

その他の重要な特徴は、公式の紹介文書に記載されています。既存のE2Eテストツールとの違いや、それに伴う長所と短所も詳しくまとめられているので、ぜひ読んでみてください。

Cypressを開始する

Cypressはnpmを使って簡単にインストールできます。

$ npm install cypress --save-dev

インストールが完了したら、何も設定しなくても、次のコマンドですぐに実行できます。

$ npx cypress open

最初にCypressを実行すると、プロジェクトフォルダにcypressというフォルダが作成されます。そのフォルダには、初回利用者向けに様々なサンプルファイルが保存されていますが、ここではサンプルファイルをすべて削除して、最初からテストを作成してみましょう。

まず、cypress/integrationフォルダにtodo.spec.jsという名前のファイルを作成して、簡単なテストを作成します。CypressのAPIは大体がMochaChaiを基盤に、BDDスタイルの直感的なAPIを提供しており、初めての方も簡単に適応できます。

it("true is true", () => {
  expect(true).to.equal(true);
});

ファイルが生成されると、Cypressのテストランナーから追加されたファイルをすぐに確認することができます。そのファイルをクリックすると、Cypressによって拡張されたChromeブラウザが実行されます。

Cypressテストを作成する

これから本格的にテストを作成してみましょう。Cypressテストは、通常、別のローカルサーバーを実行して、そのURLに直接接続する方式で作成します。ここでは、ローカル開発サーバーだけでなく、APIサーバーまで使用しているので、テストを実行する前に、両方のサーバーが実行されている必要があります。

まず、コマンドラインにnode serverを入力すると、8081ポートにAPIサーバーが実行されます。次のコマンドラインにnpm startを入力すると、3000ポートにwebpack-dev-serverが実行され、設定によってAPIサーバーをプロキシとして連結します。

テストを作成する前に、簡単な設定を追加してみましょう。設定ファイルにベースURLを保存しておくと、テストコードのローカルサーバーの完全なURLを毎回作成する必要はなくなり、相対パスを使用することができます。プロジェクトのルートでcypress.jsonファイルに次のようなbaseUrlを追加するだけです。

{
  "baseUrl": "http://localhost:3000"
}

ストアの状態に応じて画面にToDoリストを表示する機能をテストで作成しましょう。サーバーの応答値をモックするため、cy.server()を実行した後、cy.route()を使って目的のURLと応答値を設定します。そして、特定のURLに接続するために、cy.visit()を使用します。

it("should render todo items", () => {
  const todos = [
    {
      id: 1,
      text: "Have Breakfast",
      completed: true
    },
    {
      id: 2,
      text: "Have Lunch",
      completed: false
    }
  ];

  cy.server();
  cy.route("/todos", todos); // /todos GET要請の応答値を変更する。
  cy.visit("/All"); // 実際のローカルサーバーのアドレスに接続する。

  cy.get("[data-testid=todo-item]").within(items => {
    expect(items).to.have.length(2);
    expect(items[0]).to.contain("Have Breakfast");
    expect(items[0]).to.have.class("completed");

    expect(items[1]).to.contain("Have Lunch");
    expect(items[1]).not.to.have.class("completed");
  });
});

最後にDOMの状態を検証する部分は、APIが若干変わったことを除けば、Jestを使って作成したコードとほとんど差がありません。しかし、準備過程では、ストアを作成したり、ルーターを直接組み合わせるコードが消えて、サーバーのデータをモックした上で、次のURLに直接アクセスできるように変更されました。この過程はcy.route()cy.visit()を使って、たった2行で作成ができ、以前よりもはるかにコードが単純化されています。また、実際のコードでは、サーバーからデータを受け取る部分と、ブラウザのルーターを直接使用する部分もテストされており、テストカバレッジが向上しています。このコードを保存すると、次のような画面になるでしょう。

Cypressの利点は、テストの進行履歴と実行画面を同時に見ることができることです。上図のように左側(コマンドログ)には、テストを実行するすべてのコマンドが結果に表示され、右側には、実際のアプリケーションが実行された結果が表示されます。左の各項目をクリックすると、そのコマンドが実行された結果画面を確認できます。また、一部ネットワーク要請がモッキングかどうか、いつどのようなネットワーク要請が発生したか、などの情報も一目で確認できます。

ブラウザのURLに応じたDOMの状態テスト

上のサンプルのように、Cypressを使ったテストでは、ストアの値を操作するのに、ストアを直接生成するよりも、サーバーのデータをモッキングする方がより有用です。ルーターも同じく、ルーターの状態を操作する際、毎回モッキングされたルーターを注入せずに、ブラウザのURLを直接変更すればよいでしょう。上のサンプルを少し発展させて、URLによってToDoリストがフィルタリングされて表示されるか検証してみましょう。

const todos = [
  {
    id: 1,
    text: "Have Breakfast",
    completed: true
  },
  {
    id: 2,
    text: "Have Lunch",
    completed: false
  }
];

beforeEach(() => {
  cy.server();
  cy.route("/todos", todos);
});

describe("Initial Render", () => {
  it("All", () => {
    cy.visit("/All");

    cy.get("[data-testid=todo-item").within(items => {
      expect(items).to.have.length(2);
      expect(items[0]).to.contain("Have Breakfast");
      expect(items[0]).to.have.class("completed");

      expect(items[1]).to.contain("Have Lunch");
      expect(items[1]).not.to.have.class("completed");
    });
  });

  it("Active", () => {
    cy.visit("/Active");

    cy.get("[data-testid=todo-item").within(items => {
      expect(items).to.have.length(1);

      expect(items[0]).to.contain("Have Lunch");
      expect(items[0]).not.to.have.class("completed");
    });
  });

  it("Completed", () => {
    cy.visit("/Completed");

    cy.get("[data-testid=todo-item").within(items => {
      expect(items).to.have.length(1);

      expect(items[0]).to.contain("Have Breakfast");
      expect(items[0]).to.have.class("completed");
    });
  });
});

反復作業を減らすため、共通の初期化コードをbeforeEach()で囲み、describe()を使ってグループを指定しました。それ以外は大きく変更したものはありません。このように、cy.visit()関数の引数を変更して接続するアドレスを変更すると、ルーターの状態に応じてDOMの状態も簡単に検証できます。

タスクを追加する

今回はタスクを追加するテストを作成してみましょう。サーバーに同期要請を送信する値を検証するためには、cy.stub()cy.route()のオブジェクトのオプションを使用する必要があります。cy.route()の詳細オプションは、APIドキュメントを参照してください。

it("Add Todo", () => {
  // 1-1. サーバー同期の要請を確認するstub生成とモッキング
  const reqStub = cy.stub();
  cy.route({
    method: "PUT",
    url: "/todos",
    onRequest: reqStub,
    status: 200
  }).as("sync");

  // 1-2. アプリケーションサーバーに接続
  cy.visit("/All");

  // 2. テキスト入力した後、Enterキー入力
  cy.get('[data-testid="todo-input"]').type("Have a Coffee{enter}");

  // 3-1. ToDoリストが追加されたか確認
  cy.get('[data-testid="todo-item"]').within(items => {
    expect(items).to.have.length(3);

    expect(items[2]).to.contain("Have a Coffee");
    expect(items[2]).not.to.have.class("completed");
  });

  // 3-2. サーバーに同期化要請が送信されたか確認
  cy.wait("@sync").then(() => {
    expect(reqStub.args[0][0].request.body).to.eql([
      ...todos,
      {
        id: 3,
        text: "Have a Coffee",
        completed: false
      }
    ]);
  });
});

統合テストとの比較のため、コメントに同一番号と説明を追加しました。まず準備(1)過程では、サーバーの要請をモッキングするとき、axiosと呼ばれる特定のライブラリに依存せず、ネットワーク要請を直接モッキングできる利点があります。レンダリングを直接する必要がなく、サーバーのURLに接続しさえすればよいという利点があります。実行(2)過程でもchangeイベントとkeydownイベントを直接発生させることなく、cy.type()を使って、あたかもユーザーが入力するようにコードを記述することができます。最後の検証(3)過程では、ストアの値を直接確認することなく、DOMの状態を利用して、アプリケーションの状態を検証しています。

バランスの取れたE2Eテストを作成する

これまでテストの範囲を徐々に広げて、単体テスト、統合テスト、E2Eテストを作成してみました。テストの範囲が大きくなるほど不要なモッキングが減り、テストカバレッジが向上しています。通常は、単体テストが作成しやすく、コードも簡単だと考えられていますが、実際はほとんどのモッキングコードが消えるので、E2Eテストコードが簡潔で明確なことが多いです。また、E2Eテストは、内部の実装状態にほとんど影響を受けないので、機能が変更されない限り、内部コード全体を変更しても依然としてテストは成功します。したがって、よく作られたE2Eテストであれば、大規模なリファクタリングもテストを信じて進行することができます。

だからといって、すべてのテストをE2Eテストのみで作成する必要はありません。場合によっては、内部ストアの値を直接確認する必要があり、特定のネットワーク要請(ウェブソケットなど)を制御するために、実際の通信を担当するモジュールをモッキングする必要があります。全体UIをテストする代わりに、特定のコンポーネントのUIのみテストするのが効率的なときもあります。また、複雑な演算などを含む様々な入力値を確認する必要があるモジュールをテストする場合は、単体テストがはるかに効率的です。

幸いにも、Cypressは単体テストや統合テストを作成する方法も提供しています。cy.visit()を使わずに、直接特定のモジュールをimportしてテストすると、Jestと同じ方法で単体テストを作成できます。Cypressのブログのテストピラミッドを逆に適用するRuduxストアをテストするなどに、このようなアプローチが詳しく説明されています。

(Cypressで単体テストを作成できますが、「正式」には対応していません。これに関しては、GitHubでイシューにあがっているので、参考にしてください。)

ストーリーブックとCypress

最後にまとめると、この記事で推奨している戦略は、次のとおりです。ストーリーブックは、アプリケーションの現在の状態を視覚的に表現する部分のテストを担当し、Cypressは、ユーザー入力やサーバーデータを受けて、アプリケーションの現在の状態を変更する部分のテストを担当しています。このように整理すると、複数の役割が明確に区分されますが、実際には両者間に若干のグレーゾーンが存在します。ストーリーブックもある程度のユーザーアクションを処理することができ、Cypressを使用しても視覚的な部分を検証することができるからです。

まず、ストーリーブックを見てみましょう。ストーリーブックの公式ガイド文書には、インタラクションのテストという項目があります。このトピックで説明されているように、ストーリーブックのSpecsアドオンを使用すると、個別のストーリー内でJestやMochaのAPIを活用してテストを作成することができます。また、Actionsアドオンを使うと、コンポーネントでユーザー入力に応じて一部のアクションが発生したか確認することができ、ユーザー入力を簡単にテストするときに活用できます。

Cypressは、すべてのテスト結果を視覚的に確認できるので、ストーリーブックがなくても視覚的な検証を行うことができます。ただしストーリーブックのように、それぞれの状態が一目で整理されていないので、手動検証で使用するにはストーリーブックに比べてはるかに手間がかかります。代わりにスクリーンショット機能を活用して、視覚的回帰テストをすれば、より便利に利用できますが、Cypressは画像を比較する機能を提供していないため、直接実装するか、cypress-image-snapshotのようなプラグインを一緒に使用する必要があります。

(2部で紹介した視覚的テストツールは、ストーリーブックだけでなく、Cypressとの連動機能も提供しています。興味のある方は、ApplitoolsPercyの文書を参照してください。)

まとめ

ここまで3部に亘り、フロントエンドのテスト戦略について紹介しました。良いテストの条件、視覚的なテストの自動化、ストーリーブックのテスト、単体テスト、統合テスト、E2Eテスト、Cypressなど、多くのトピックがありましたね。

よく使われているテストピラミッド図では、「単体テスト」>「統合テスト」>「E2Eテスト」の順にテストを作成することが推奨されています。主に単体テストを作成して、統合テストやE2Eテストを補助的に活用するということですね。しかし、この記事では正反対のアプローチを推奨しています。E2Eテストを主に作成して、場合によっては、単体テストや統合テストを補助的に活用するというものです。また、ビジュアル要素のテストは自動化で排除し、ストーリーブックを活用して、目で直接確認することを推奨します。

フロントエンドのコードは、単にデータを扱うのではなく、ユーザーに表示される画面を扱うため、一般的なテスト戦略とは異なる戦略を立てる必要があります。ストーリーブックとCypressという素晴らしいツールの登場によって、フロントエンドテストに向け、より効率的な戦略を立てることができるようになりました。まだ数年しか経っていませんが、フロントエンドのコードをテストする方法に多くの変化を与えており、まだ多くの可能性を秘めているようにも感じられます。この記事で推奨した方式がすべてにおいて最善ではありませんが、少しでも参考になればうれしいです。

NHN Cloud Meetup 編集部

NHN Cloudの技術ナレッジやお得なイベント情報を発信していきます
pagetop