Cypressのカスタムコマンドを用いたVue.jsの効率的なE2Eテスト実装

ogp_image

はじめに

こんにちは、EC基盤本部・MA部・MA基盤チームでマーケティングオートメーションのシステムを開発している長澤(@snagasawa_)です。この記事では、CypressによるE2EテストをVue.jsプロジェクトへ導入した取り組みについて、実際の画面を交えてご紹介します。このE2Eテストによって、複雑な入力フォームを自動でテストできるようになり、修正後のバグを検知しやすくなりました。E2Eテストの導入を検討されている方の参考になれば幸いです。

Vue.jsプロジェクトの技術スタック

今回Cypressを導入したプロジェクトの主な技術スタックは以下の通りです。

  • Vue.js
  • TypeScript
  • Vuetify
  • Open API

導入背景

E2Eテスト導入の理由は、複雑な入力フォームを動作保証するためです。

我々のチームでは、Line Friendship Manager(以下、LFM)という名前のLINEメッセージ配信ツールを開発・運用しています。詳細は割愛しますが、任意のユーザーセグメントに対してLINEメッセージを配信できる社内向けのマーケティングツールです。このプロダクトでは以下のような複雑な入力フォームが複数画面に渡って存在しています。

  • 入力された値によって動的にフィールドの種類や数が変わる
  • フィールドの中にフィールドが存在する入れ子構造になっている

使用するユーザーが限られた管理画面ということもあり、開発リソースは最小限でした。開発メンバーにはデザイナーもフロントエンドを専門とするエンジニアもおらず、1・2名のエンジニアのみでサーバーサイドとインフラを兼任してフロントエンドを開発していました。そのため、UIもCSSは最低限の実装のみで、基本的にはVuetifyのコンポーネントを組み合わせて開発していました。

一方で、上に挙げたような画面の複雑さがあり、修正の度に入力フォームの正しい挙動を担保する必要がありました。E2Eテスト導入までは手動で動作確認を行なっていましたが、検証パターンの抜け漏れが懸念でした。このため、コンポーネント単位のユニットテストよりも、コンポーネントの状態を組み合わせた複雑な画面変化のパターンを網羅しうるE2Eテストが必要でした。

Cypressを採用した理由

E2EテストのフレームワークはCypress以外にも複数存在しますが、Cypressを選択した理由は次の2つからです。

  • GUIテストランナー
  • カスタムコマンド機能

1つ目のCypressのテストランナーは、ブラウザ上で画面のレンダリングとテストコードによるDOM操作をリアルタイムに再生する機能です。

docs.cypress.io

docs.cypress.io

具体的には以下のような操作が可能で、デバッグがしやすくなります。

  • テスト実行中にDOM操作の再生を中断
  • テストが失敗した時の画面のレンダリングの確認
  • クリックした要素の取得やDeveloper ToolsのConsole Logへの出力
  • テスト実行後に指定のDOM操作まで巻き戻し

このデバッグのしやすさが、今回のプロダクトのようにフォームが複雑であっても、意図しない画面状態を視認する一助になってくれるだろうという期待がありました。

2つ目はカスタムコマンド機能です。Cypressで事前定義されているコマンドを組み合わせて、独自のDOM操作を定義し再利用することで、効率的にテストを実装できます。後ほど実際に使用しているカスタムコマンドをご紹介します。

docs.cypress.io

CypressのVue.js + TypeScript環境構築

ここからは実際の画面をもとに、導入からテスト実装の流れを説明します。

はじめにCypressをインストールし、cypress open を実行します。そうするとデフォルトのディレクトリとファイルが作成され、テストランナーが起動します。TypeScriptの場合は、tsconfig.jsonを追加し、各種ファイルの拡張子を変更します。デフォルトのファイルを変更した場合は、cypress.jsonでもパスを修正します。

$ npm install cypress --save-dev
$ npx cypress open
$ tree ./cypress
cypress
├── fixtures
│   └── example.json
├── integration
├── plugins
│   └── index.js
├── screenshots
├── support
│   ├── commands.js
│   └── index.js
└── videos

$ mv cypress/plugins/index.js cypress/plugins/index.ts
$ mv cypress/support/index.js cypress/support/index.ts
$ mv cypress/support/commands.js cypress/support/commands.ts
  • cypress/tsconfig.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "types": [
      "cypress"
    ]
  },
  "include": [
    "../node_modules/cypress",
    "./**/*.ts"
  ]
}
  • cypress.json
{
  "baseUrl": "http://localhost:8082",
  "pluginsFile": "cypress/plugins/index.ts",
  "supportFile": "cypress/support/index.ts",
  "video": false,
  "screenshotOnRunFailure": false
}

補足すると、Vue.jsプロジェクトではCypressをvue-cliのプラグインとしてもインストールできます。しかし、プラグインはバージョンが本家よりも古いためオススメしません。記事公開の時点ではCypressの最新バージョンはv8.3.0ですが、プラグインではv7.1.xとなっています。Cypressはリリースが早く、次々と新しい機能が追加されるため、新しいものを使うほうが便利です。ただ、バグも多いので新規機能の追加直後の利用はご注意ください。

テスト実装の流れ

環境構築が終わったら実際にテストを実装していきます。

前述のLFMから、配信対象となるユーザーのセグメントを登録する「セグメント画面」をテストコードの題材にします。下の画像が画面のキャプチャです。

segment_form

このセグメント画面で、条件を組み合わせてセグメントのユーザー数を確認・登録すると、その条件に基づきLINEメッセージが配信されます。例えば、「性別: 男性」「年齢: 20歳以上」「ZOZOカードを利用している」といった条件を指定できます。Google Analyticsをご存知の方であれば、セグメントビルダーを思い出していただければイメージしやすいかと思います。

このように複数の条件を組み合わせて登録する画面のため、フィールドの数が増減したり、条件の指標次第でフィールドが動的に切り替わる仕様となっています。また、これらのフィールドの状態をチェックしてボタンの活性・非活性を制御する必要もあります。

画面操作の主な流れは以下の通りです。

  1. 条件を指定する
  2. ターゲットを抽出してユーザー数を確認する
  3. 登録する

これに沿ったテストの実装の流れは次の通りです。

  1. 画面にアクセスした時のリクエストのレスポンスをスタブにする
  2. テストデータを生成するFactoryを定義する
  3. カスタムコマンドを定義し、DOM操作のコマンドを書く
  4. 操作完了時の画面の遷移をチェックする

interceptによるレスポンスのスタブ

まずはじめに、画面からリクエストされるAPIのスタブを行います。cy.visit()で画面へアクセスする際に発生するAPIリクエストのレスポンスをcy.intercept()でスタブにします。

cy.intercept()はリクエストのメソッド・URL・レスポンスを引数に渡すことで、それにマッチしたリクエストへ任意のレスポンスを返すことができます。

docs.cypress.io

今回は「ターゲット抽出」と「登録」の2つのリクエストで指定したレスポンスを返し、それ以外のリクエストは空のレスポンスを返すようにします。下はサンプルコードですが、第3引数に渡しているテストデータのsegment変数については後述します。

beforeEach(() => {
  // APIはデフォルトで空のレスポンスを返す
  cy.intercept(/api/, []);
  
  // ターゲット抽出APIと登録APIにアクセスするとsegmentを返す
  cy.intercept('POST', '/api/segments/count', segment).as('countTarget');
  cy.intercept('POST', '/api/segments', segment).as('saveSegment');

  // セグメント画面へアクセス
  cy.visit(`${Cypress.config().baseUrl}/#/segments/new`);
});

スタブしたリクエストは.as()でエイリアスを指定できます。エイリアスはcy.wait()でそのリクエストが完了するまで自動待機する時などに利用できます。

テストデータの生成

続いてテストで利用するテストデータを生成します。先ほどのsegment変数はスタブしたAPIのレスポンスであり、セグメントの条件やターゲットを抽出した結果のユーザー数を保持するオブジェクトが格納されます。今回はこのオブジェクトをテストデータのFactoryによって生成します。

cy.intercept()のレスポンスには、オブジェクトやcy.fixture()で生成したテストデータなどを渡すことができます。簡単な方法では、単に固定のJSONファイルをcy.intercept()fixtureオプションに渡すことも可能です。

しかし、LFMではOpen APIの定義からフロントエンドの型定義を生成しているため、テストデータもこの型定義と一致させるようにFactoryを定義しています。これによって、Open APIのスキーマ変更後にプロダクトコードのみを修正してFactoryの修正を忘れた場合は、テスト実行前のビルドが型定義のエラーによって異常終了するようにしています。

import * as faker from 'faker';

// Open APIで生成した型定義
import { Segment, SegmentCondtion, SegmentTargetCount } from '@/services/api/types/segment';

// Factoryの型定義
interface Factory<T> {
  create(params?: Partial<T>): T;
  createList(num: number): T[];
}

// Factoryの定義
const segmentFactory: Factory<Segment> = {
  create(params) {
    return {
      id: faker.random.number(),
      name: `セグメント${faker.random.number()}`,
      status: SegmentStatus.FIXED.value,
      segmentConditions: [segmentConditionFactory.create()],
      segmentTargetCount: segmentTargetCountFactory.create(),
      createdTime: new Date(),
      updatedTime: new Date(),
      ...params,
    };
  },
  createList(num: number) {
    return [...Array(num)].map(n => this.create());
  },
};

// segmentに含まれるオブジェクト用のFactory
const segmentConditionFactory: Factory<SegmentCondition> = { /* 省略 */ };
const segmentTargetCountFactory: Factory<SegmentTargetCount> = { /* 省略 */ };

// テストデータの生成
const segment = segmentFactory.create();

カスタムコマンドによるDOM操作

最後にDOM操作とアサーションを追加していき、テストコードを完成させます。

テストコードを書いていると同じような実装が頻繁に登場します。特にVuetifyのようなUIコンポーネントライブラリを利用している場合は尚更です。そこでテストコードの実装をDRYにしたい場合、カスタムコマンドを定義して再利用するという方法があります。

公式ドキュメントでは、要素のセレクターのベストプラクティスとしてdata-*をコンポーネントの属性に追加し、CSSやJavaScriptの変更の影響を受けないように記述することが推奨されています。しかし、UIコンポーネントライブラリを利用している場合は、コンポーネントごとにカスタムコマンドを作ることでDOM操作をDRYに書けます。そのため、必ずしもこのプラクティスを厳守せずに、適宜カスタムコマンドを組み合わせて使っています。

実際に利用しているカスタムコマンドの一部です。このように小さなDOM操作を組み合わせてカスタムコマンドを定義しています。

  • cypress/support/commands.ts
// 頻出する操作をカスタムコマンドとして定義
// 操作:ボタン要素の特定・テキストエリアの特定・ボタンを押す・テキストエリアを埋める
Cypress.Commands.add('getVBtn', (content: string) => {
  cy.get('.v-btn')
    .contains('span', content)
    .parent();
});
Cypress.Commands.add('getVTextField', (label: string) =>
  cy.contains('label', label).next('input')
);
Cypress.Commands.add('clickVBtn', (content: string) =>
  cy.getVBtn(content).click({ force: true })
);
Cypress.Commands.add(
  'fillInVTextField',
  (label: string, value: string | number) => {
    return cy
      .getVTextField(label)
      .clear()
      .type(`${value}`);
  }
);

// カスタムコマンドの型定義
// これを行わないと、cyの後のメソッド呼び出しでエラーになる
declare namespace Cypress {
  interface Chainable<Subject> {
    getVBtn: (content: string) => Chainable<Subject>;
    getVTextField: (label: string) => Chainable<Subject>;
    clickVBtn: (content: string) => Chainable<Subject>;
    fillInVTextField: (
      label: string,
      value: string | number
    ) => Chainable<Subject>;
  }
}

カスタムコマンドを定義したら、実際にDOM操作のコードを書いていきます。下のコードは、セグメント画面の名前入力から条件指定までの操作をコード化したものです。

filling_out_form

const fillOutForm = () => {
  // ① 名前の入力
  cy.fillInVTextField('名前', 'セグメント01');

  // ② 「条件を追加する」ボタンの押下
  cy.clickVBtn('条件を追加する');

  // 条件のフィールドが1行追加されたことを確認
  cy.get('[data-cy=segment-condition-field]').should('have.length', 1);

  // ③ 条件の指標の選択
  selectSegmentConditionDimension('年齢');

  // ④ 条件の値の入力
  inputSegmentConditionValue(20);

  // ⑤ 条件の演算子の選択
  selectSegmentConditionOperator('以上である');
};

const selectSegmentConditionDimension = (text: string, index: number = 0) => {
  cy.get('[data-cy=segment-condition-dimension]')
    .eq(index)
    .parent()
    .click();
  cy.contains(text).click();
};

const inputSegmentConditionValue = (
  value: string | number,
  index: number = 0
) => {
  cy.get('[data-cy=segment-condition-value]')
    .eq(index)
    .type(`${value} {enter}`);
};

const selectSegmentConditionOperator = (text: string, index: number = 0) => {
  cy.get('[data-cy=segment-condition-operator]')
    .eq(index)
    .parent()
    .click();
  cy.contains(text).click();
};

このように要素の取得と、それに対する操作やアサーションの記述を繰り返していきます。

続いて、条件指定後の登録成功時の画面遷移までのコードです。

submitting_form

describe('フォーム入力から登録ボタンを押すまで', () => {
  context('response status: 200', () => {
    it('スナックバーが表示され、セグメント一覧画面に遷移する', () => {
      // ①〜⑤ フォーム入力
      fillOutForm();

      // ⑥ 「ターゲット数抽出」ボタンの押下
      cy.getVBtn('ターゲット数抽出')
        .should('not.be.disabled')
        .click();

      // ターゲット抽出APIのレスポンス待機
      cy.wait('@countTarget', { timeout: 5000 });

      // ⑦ 抽出結果の表示確認
      cy.get('[data-cy=segment-target-count__target-count]').contains(
        segmentTargetCount.targetCount
      );
      cy.get('[data-cy=segment-target-count__segmented-at]').contains(
        formatDate(segmentTargetCount.segmentedAt)
      );

      // ⑧ 登録ボタンの押下
      cy.getVBtn('登録')
        .should('not.be.disabled')
        .click();

      // 登録APIのレスポンス待機
      cy.wait('@saveSegment', { timeout: 5000 });

      // 登録成功メッセージの表示確認
      cy.get('.v-snack__content').should('include.text', '登録しました');

      // 登録成功時の一覧画面遷移の確認
      cy.url({ timeout: 5000 }).should(location =>
        expect(location).to.include('/#/segments?tab=')
      );
    });
  });
});

これまでのコードをまとめた最終的なテストコードは以下の通りです。

import { Operator } from '@/enum/segment';
import { formatDate } from '@/services/formatter';
import {
  segmentConditionFactory,
  segmentFactory,
} from '@/factories';

// テストデータの生成
const segmentCondition = segmentConditionFactory.create({
  dimension: '年齢',
  value: 20,
  operator: Operator.GREATER_THAN_OR_EQUAL_TO.value,
});
const segment = segmentFactory.create({
  name: 'セグメント01',
  segmentConditions: [segmentCondition],
});
const segmentTargetCount = segment.segmentTargetCount;


const fillOutForm = () => {
  // ① 名前の入力
  cy.fillInVTextField('名前', 'セグメント01');

  // ② 「条件を追加する」ボタンの押下
  cy.clickVBtn('条件を追加する');

  // 条件のフィールドが1行追加されたことを確認
  cy.get('[data-cy=segment-condition-field]').should('have.length', 1);

  // ③ 条件の指標の選択
  selectSegmentConditionDimension('年齢');

  // ④ 条件の値の入力
  inputSegmentConditionValue(20);

  // ⑤ 条件の演算子の選択
  selectSegmentConditionOperator('以上である');
};

const selectSegmentConditionDimension = (text: string, index: number = 0) => {
  cy.get('[data-cy=segment-condition-dimension]')
    .eq(index)
    .parent()
    .click();
  cy.contains(text).click();
};

const inputSegmentConditionValue = (
  value: string | number,
  index: number = 0
) => {
  cy.get('[data-cy=segment-condition-value]')
    .eq(index)
    .type(`${value} {enter}`);
};

const selectSegmentConditionOperator = (text: string, index: number = 0) => {
  cy.get('[data-cy=segment-condition-operator]')
    .eq(index)
    .parent()
    .click();
  cy.contains(text).click();
};


// レスポンスのスタブと画面へのアクセス
beforeEach(() => {
  cy.intercept(/api/, []);
  cy.intercept('POST', '/api/segments/count', segment).as('countTarget');
  cy.intercept('POST', '/api/segments', segment).as('saveSegment');

  cy.visit(`${Cypress.config().baseUrl}/#/segments/new`);
});


describe('フォーム入力から登録ボタンを押すまで', () => {
  context('response status: 200', () => {
    it('スナックバーが表示され、セグメント一覧画面に遷移する', () => {
      // ①〜⑤ フォーム入力
      fillOutForm();

      // ⑥ 「ターゲット数抽出」ボタンの押下
      cy.getVBtn('ターゲット数抽出')
        .should('not.be.disabled')
        .click();

      // ターゲット抽出APIのレスポンス待機
      cy.wait('@countTarget', { timeout: 5000 });

      // ⑦ 抽出結果の表示確認
      cy.get('[data-cy=segment-target-count__target-count]').contains(
        segmentTargetCount.targetCount
      );
      cy.get('[data-cy=segment-target-count__segmented-at]').contains(
        formatDate(segmentTargetCount.segmentedAt)
      );

      // ⑧ 登録ボタンの押下
      cy.getVBtn('登録')
        .should('not.be.disabled')
        .click();

      // 登録APIのレスポンス待機
      cy.wait('@saveSegment', { timeout: 5000 });

      // 登録成功メッセージの表示確認
      cy.get('.v-snack__content').should('include.text', '登録しました');

      // 登録成功時の一覧画面遷移の確認
      cy.url({ timeout: 5000 }).should(location =>
        expect(location).to.include('/#/segments?tab=')
      );
    });
  });
});

上記のテストコードではカスタムコマンドがシンプルなボタン操作やフィールド入力のみのため、もしかするとその恩恵が分かりづらいかもしれません。しかし、テスト対象のコンポーネントの数が増え、同じ操作が繰り返される場合にはそのメリットを実感できます。もう少し複雑な例では、TimePickerやDatePicker用のカスタムコマンドも定義しました。このような複数のステップに渡る操作の場合はカスタムコマンド化によるメリットがより大きくなります。

Cypress.Commands.add('clickOnVDatePicker', (label: string, date: string) => {
  cy.getVTextField(label).click({ force: true });

  cy.get('.v-menu__content.menuable__content__active > .v-picker--date')
    .first()
    .within(() => {
      cy.contains('.v-btn', date).click({ force: true });
      cy.clickVBtn('OK');
    });
});

Cypress.Commands.add(
  'clickOnVTimePicker',
  (label: string, hour: string, minute: string) => {
    cy.getVTextField(label).click({ force: true });

    cy.get('.v-menu__content.menuable__content__active > .v-picker--time')
      .first()
      .within(() => {
        cy.contains('.v-time-picker-clock__item', hour).click({ force: true });
        cy.contains('.v-time-picker-clock__item', minute).click({
          force: true,
        });
        cy.clickVBtn('OK');
      });
  }
);

E2Eテストによる効果

テスト実装後はCircleCIに設定を追加し、GitHubのPRにコミットがプッシュされるたびにテストが実行されるようにしました。これによって手動確認していた検証パターンの一部を代替し、当初の懸念であった抜け漏れの発生する可能性を低減できました。実際にテストを導入した画面では、その後バグが1件も発生していません。また、手動での確認時間を削減できた分、以前より開発サイクルが早くなりました。

まとめ

Vuetifyを利用したVue.jsプロジェクトへ、CypressによるE2Eテストを導入する取り組みについてご紹介しました。また、Cypressのテストランナーやカスタムコマンドによる開発効率化についても紹介しました。効率的に画面開発の保守性を高める必要に迫られた時にはCypressの導入検討をオススメします。

さいごに

ZOZOテクノロジーズでは一緒にプロダクトを開発してくれるエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください!

tech.zozo.com

カテゴリー