Merpay Advent Calendar 2021 の 8 日目はメルペイフロントエンドチーム の @tanakaworld がお送りします。
はじめに
メルペイは金融サービスであり、品質の維持・向上に日々取り組んでいます。フロントエンドチームでは、約 2 年前からリグレッションテストの自動化に取り組み始め、直近の 1 年間はインテグレーションテストの自動化にもチャレンジしてきました。本記事ではメルペイフロントエンドチームに於けるテスト自動化の方針とその全体像について振り返ってみたいと思います。
フロントエンドプロダクトに関わるテストは次のものが挙げられます。これらをひとつずつ順番に見ていきたいと思います。
- ユニットテスト
- インテグレーションテスト
- シナリオテスト
- リグレッションテスト
1. ユニットテスト
ユニットテストは Jest を用いて、主に TypeScript で記述された関数ロジックをテストします。メルペイの大半のフロントエンドアプリケーションは Nuxt (Vue.js) と TypeScript で開発しています。
テスト観点
コンポーネントテストを書くかどうかは、対象がアプリケーションかライブラリかによって判断しています。
アプリケーションの場合
アプリケーションリポジトリの場合は、Vue コンポーネントと Vuex に依存するロジックのテストは基本的に書いていません。本来ならば自分で作った機能のテストに集中すべきなのに、それらのテストを書くことはライブラリ自体のロジックをテストすることになりがちだからです。また、テストを書くための設定が多くなったり、テスト粒度が細かくなり過ぎてしまい、テスト自体のメンテナビリティが損なわれてしまう可能性もあります。
代わりに、後述のインテグレーションテストでこれらの動作を担保しています。コンポーネントに依存しないロジックは極力 .ts ファイルに切り出し、ユニットテストを書くことを推奨しています。
// Info.vue
<template>
<div>
Date: {{ formatGoogleDate(date) }}
</div>
</template>
<script lang='ts'>
import Vue from 'vue';
import type { PropType } from 'vue';
import { formatGoogleDate, GoogleDate } from '~/utils';
export default Vue.extend({
props: {
date: {
type: Object as PropType<GoogleDate>,
required: true
}
},
methods: {
// 関数はコンポーネントの外に定義する
formatGoogleDate
}
})
</script>
// utils.ts
export type GoogleDate = {
year?: number;
month?: number;
day?: number;
}
// この関数のユニットテストを書く
export function formatGoogleDate(date: GoogleDate) {
return `${date.year} / ${date.month} / ${date.day}`
}
共通ライブラリの場合
複数のアプリケーションが共有するものをライブラリ化し、社内の private npm registry で管理しています。コンポーネント実装を伴うデザインシステムや、API クライアント、バリデーションルールなどがそれに該当します。このようなケースでは Vue Test Utils を用いてコンポーネントテストを書いています。
テスト観点は次の通りです。共通ライブラリとして提供されることを想定し、比較的細かめのテストを書きます。
- Props
- props の指定有無による描画出し分け
- props の明示的な指定がない場合の default の挙動
- Events
- コンポーネントが持つイベントハンドラーに対するテスト
- フォーム要素のバリデーションロジック
// Button.test.ts
import { mount } from '@vue/test-utils';
import Button from '~/components/Button.vue';
describe('Button.vue', () => {
describe('props', () => {
describe('disabled', () => {
describe('false (default)', () => {
const onClick = jest.fn();
const wrapper = mount(Button, {
listeners: {
click: onClick,
},
});
const $button = wrapper.find('button');
test('able to click button', async () => {
await $button.trigger('click');
expect(onClick).toHaveBeenCalled();
});
});
describe('true', () => {
const onClick = jest.fn();
const wrapper = mount(Button, {
propsData: { disabled: true },
listeners: {
click: onClick,
},
});
const $button = wrapper.find('button');
test('unable to click', async () => {
await $button.trigger('click');
expect(onClick).not.toHaveBeenCalled();
});
});
});
});
});
モックデータ
テストデータの作成は煩雑になりがちです。フロントエンドチームでは、モデルの型に対応したテストデータを作成するファクトリ関数を用意しています。次のようなイメージです。
type Customer = {
name?: string;
email?: string;
}
export function mockCustomer(modification: Customer = {}): Customer {
return {
name: 'customer-1',
email: 'customer-1@example.com',
...modification,
};
}
モックデータの実体は自動生成ではなく手で書くようにしています。intermock のような型に応じたデータを自動生成してくれるツールも魅力的ですが、なるべく本番データに近い意味のあるデータをテストで使用するために手書きにしています。一度定義してしまえば再利用できるため、手書きの手間は問題にはなっていません。
次のようにテストコードから使用します。引数の modification
によって任意の値に変更することも可能です。
import { mockCustomer } from '~/mock/customer';
describe('customer', () => {
describe('customerToFormData()', () => {
it('should make a customer form data correctly', () => {
const customer1 = mockCustomer();
expect(customerToFormData(customer1)).toStrictEqual({
formData: {
name: 'customer-1',
email: 'customer-1@example.com'
}
})
// modification で任意の値に変更
const customer2 = mockCustomer({
name: 'customer-renamed'
});
expect(customerToFormData(customer2)).toStrictEqual({
formData: {
name: 'customer-renamed',
email: 'customer-1@example.com'
}
})
})
})
})
2. インテグレーションテスト
インテグレーションテストは主に Web ページ単位のテストで、Nuxt でいうところの pages
配下のページコンポーネント単位のテストに相当します。Vue コンポーネントやユーティリティ関数、Nuxt middleware や plugin、Vuex Store などページに含まれるものすべてが統合されます。ユニットテストで対象外としていた、コンポーネントや Vuex Store ロジックもここで担保されます。Cypress を用いており、該当のアプリケーションと同じリポジトリにテストコードも置きます。
テスト観点
基本的に、仕様書をベースにアプリケーションの振る舞いが期待通りかをテストします。実装の詳細は考慮しません。インテグレーションテストが仕様書と対応し、テストコードを見るとアプリケーション挙動がわかるように管理されると理想的です。
テスト観点は次の通りです。
- Rendering
- 画面の初期表示
- 権限による表示出し分け
- API レスポンス形式に応じた表示出し分け
- API エラー時の画面表示
- Action
- ユーザー操作の結果に応じた画面の変化
- フォームサブミット時の API 呼び出しの結果
- ページナビゲーション
- Validation
- フォームのバリデーション
下記はサンプルコードです。cy.mock
は内製のカスタムコマンドで cy.intercept
を内包しており、特定の API レスポンスを任意の値でモックします。API をモックすることで API エラー時の挙動もテストすることが可能です。また、インテグレーションテストはアプリケーションと同じリポジトリに置いているため、先述のユニットテストでも使用していたファクトリ関数 mockCustomer
をここでも利用することができます。
import { mockCustomer } from '~/mock/customer';
describe('customer detail screen', () => {
describe('rendering', () => {
it('should render a customer info', () => {
// mock
const customer = mockCustomer();
cy.mock(API.GetCustomer, customer);
// assertion
cy.visit('/customers/1');
cy.findByText('customer-1');
cy.findByText('customer-1@example.com');
})
})
describe('action', () => {})
describe('validation', () => {})
})
Cypress
元々は Vue Test Utils などを用いてインテグレーションテストを行っていました。しかし、Nuxt 固有の処理や Vuex State、ブラウザ固有の処理のモックなど、テストのための設定が多くなってしまっていまいテスト運用に難がありました。代わりに Cypress を用いる方針に現在はシフトしています。Cypress の機能で API レスポンスだけモックし、それ以外はリアルなアプリケーションを実際のブラウザ上でテストすることができます。
Cypress はテストの実行中にアプリケーションで実行されているプロセスの詳細をリアルタイムに GUI で表示してくれます。新しいメンバーにとって、コードがどのように動作するかを理解しやすく、その結果オンボーディングプロセスを迅速に進めることができます。また、 GUI はデバッグにも役立ちます。
その他、Cypress をプラットフォームとして活用している詳細は、 @Wilson による Cypress as a Testing Platform – Merpay Tech Fest 2021 をご覧ください。(動画の音声は英語ですが、日本語字幕があります。また、日本語での書き起こし記事はこちらです。)
インテグレーションテストのメリット・デメリット
インテグレーションテストが整備されたおかげで、継続的に運用するアプリケーションの保守性が上がりました。導入の効果として下記のメリット・デメリットが挙げられます。
メリット:
- リファクタリング・パッケージ更新が容易
- 振る舞いをテストしているため、実装を変更してもテストが壊れづらい
- フレームワークに破壊的変更があっても気づくことができる
- 手作業で動作確認する手間が少なくなり、npm パッケージの更新が速く、安全にできるようになった
- 属人性の排除と時間短縮
- テストを読む、または実行して結果を見ることでオンボーディングに役立つ
- 実装を変更したときに、テストがエラーになることで影響範囲が把握できる
- 自分が書いていないコードでもテストで動作が保証されており、気兼ねなく開発ができる
- 未然のバグ防止
- 画面の出し分けパターンや API エラー時の挙動も容易にテストできる
- 実際に QA フェーズでのバグの数が大きく減った
デメリット:
- テストにかける工数も考慮が必要
- 当然、テストを追加・変更することにも工数はかかる
- 既存のアプリケーションにゼロからテストを追加する場合、それなりに時間はかかる
- 仕様書とテストコードの対応付けに気を配る必要がある
- 仕様書からテストコードに落とすときに対応を考慮する必要がある
開発者が手作業で動作確認をする、もしくは自動テストに確認をさせる、それぞれメリット・デメリットがありトレードオフだと思います。アプリケーションの規模が大きくなり、開発者が増えてくると、インテグレーションテストは自動化されている方に理がありそうです。複数の開発者が一つのアプリケーションを開発しているときに、アプリケーションが壊れる可能性が低くなり、安全かつ迅速に開発することができます。
3. シナリオテスト
シナリオテストは予期している挙動すべてを確認するテストで、QA チームがマニュアルで実施します。ある機能が追加・変更されたときに、全パターンを網羅的にテストします。逆に言うと、ある機能が追加・変更されない限り、繰り返し実施されることはありません。現在、シナリオテストは自動化をしない方針です。仕様に変更があった場合の更新コストが大きく、かつ CI として実行し続けるにはシナリオテストケース数が膨大でテスト実行に時間がかかるためです。
インテグレーションテストとの違いはテスト対象です。インテグレーションテストはあくまでフロントエンドアプリケーション単体をテストするのに対して、シナリオテストは本番相当の環境を用いて、フロントエンドアプリケーションを介してバックエンドやインフラも総合してテストをします。
4. リグレッションテスト
リグレッションテストは、実装を変更した結果、アプリケーション全体の振る舞いに予期せぬ影響がないかどうかを確認するテストです。基本的に Cypress を用いて自動化しつつ、事情により自動化できないテストケースは手動でテストしています。TestRail を用いることで、自動化と手動テスト結果を一元管理しています。
リグレッションテストの自動化とワークフローのカイゼンは、昨年、Cypress + TestRail による Frontend E2E テストの効率化についてに書きました。現在はワークフローが刷新され、Slack コマンド経由でテストを実行することができるようになっています。
テスト観点
リグレッションテストはリリース前にデグレがないかを確認します。リリース前に必ず実行されるテストで、想定されるケースをすべてテストしていたシナリオテストから主要なテストケースをピックアップして自動化します。例えば、データの状態による画面分岐パターンが複数個あるとき、その中で一つだけ自動化しておくようなイメージです。テストケースは TestRail を用いて管理しています。
リグレッションテストは Cypress を用いており、アプリケーションリポジトリとは別リポジトリに置いています。コードオーナーが QA チームであり、フロントエンドだけでなくバックエンドなども含めたエンドツーエンドのテストであるためです。リグレッションテストケースの選定は QA チームが担当します。またす、この 1 年間で QA チームを中心にテスト自動化自体もなされるようになってきました。
フロントエンドチームとしては Cypress や TypeScript、CI などのサポートを行っています。例えば、テストが安定しないことや、テスト実行に時間がかかっていることが、課題に挙がっていました。安定化については、 @tokuda109 による Frontend E2Eテストの安定化の取り組みに詳細が書かれています。
おわりに
フロントエンドに関連する各テストについて全体像をまとめてみました。どのテストを自動化するかしないかの判断は非常に重要です。テストが全くなくて開発効率が上がらなかったり、逆にテストを書きすぎることで自分たちを守るべきテストが足かせになってしまったりすることもあります。開発しているプロダクトのフェーズや、開発内容、開発者が誰かなどを考慮して、その時に合った方針にすることが大切だと考えています。
明日の記事は @fivestar さんです。引き続きお楽しみください。