ROIを意識したフロントエンドのテスト戦略[サンプルコードあり]

Masao Okamoto
The Finatext Tech Blog
24 min readApr 25, 2023

--

Photo by Nate Grant on Unsplash

0. はじめに

こんにちは、Finatextグループでソフトウェアエンジニアをしている @1010real です。

現在、新規事業として立ち上がったばかりのプロジェクトにて、主にフロントエンドの設計・開発を担当しております。前職でQAチームを立ち上げた経験もあり、特にテストについてはROIを意識した戦略を立てて設計することが非常に大事だと思っています。

そこで今回は、私が考える最効率なフロントエンドのテスト戦略と、担当プロジェクトがそれをどんなかたちで実現しているのかについて紹介します。使用したライブラリやサンプルコードもAppendixにまとめたので、少し長いですがぜひ最後まで読んでいただけるとうれしいです。

1. 「フロントエンドのテスト」とは

いきなり戦略の話に入る前に、テストの種類について整理しておきたいと思います。

1–1. V字モデル

以下は、一般的なV字モデルを示した図です。

みんな大好き(?)V字モデル

ここで質問です。E2Eテストは果たしてどの段階のテストに該当するのでしょうか?システムテスト?結合テスト?

答えは、個人や会社、プロジェクトによって違ってきます。

これは結合(インテグレーション)テストや単体(ユニット)テストにおいても同じです。フルスタックなエンジニアからしたら、APIをモックしていたらフロントエンドだけのテストなので「結合テストじゃなくて単体テストでしょ?」と思うかもしれません。他方で、バックエンドエンジニアからしたら、「PostmanでAPIリクエストに対するレスポンスを確認するテストをE2Eテストと呼んでいる」となるわけです。(ちなみに採用面接での実体験です。)

このように、同じ呼称のテストでも人によって認識している内容に差異があります。

1–2. この記事で言及するテストの呼称とその内容

そこで、この記事で言及するテストを3つに分類し、それぞれ以下のように呼称と内容を定めておきたいと思います。

  1. メソッド/コンポーネント単位のテスト
    メソッドやコンポーネント単位で振る舞いを検査するテスト(Jest, Testing Libraryその他)
  2. 画像回帰テスト
    変更前後のレンダリング結果を画像比較して差分をチェックする画像回帰テスト(reg他)
  3. ブラウザ操作系のテスト
    ブラウザ操作をコード化し、ヘッドレスブラウザを使って手動QAを自動化するテスト(Autify, Playwright, Cypressその他)

2. テスト効率を考えるための2つのポイント

2–1. 各テストの性質を理解する

さて、これら3つのテストを、カバーできる検証の範囲や内容のサイズで比較すると、以下のような順番になります。

メソッド単位のテスト < コンポーネント単位のテスト < ブラウザ操作系のテスト
※画像回帰テストは画像キャプチャをどの単位(コンポーネントor ページ)で撮るかによります。

そして一般的に、テストサイズとその性質には以下のような相関があります。

テストのサイズが大きくなればなるほど、

  • 実行時間が増える(=フィードバックが遅くなる)
  • 実装時間およびメンテナンスコストが増える
  • 要求定義・要件定義などの仕様に近い部分をテストできるが、粒度が粗くなる

すべての要求定義・要件定義を満たすようなユーザシナリオを洗い出し、アプリケーション全体に対するブラウザ操作系のテストを都度実施できれば、担保できる品質としてはベストですが、実行時間が長くなりフィードバックが遅くなりますので、開発効率が落ちてしまいます。

例えば、コミット毎に実行されるテストに20分かかるとしたらどうでしょうか?最初は待てるかもしれませんが、テストが失敗する度にまた20分待たなければいけないので、うんざりすることでしょう。開発に近いテストであればあるほど、すぐに終わらせてフィードバックを得たいですよね。

そのためには、各テストの性質を理解した上で、テストで担保したいこと(品質)は何なのか、それをより効果的・効率的に実装するにはどのテストが最適なのか、を見極める必要があります。

2–2. 何を担保したいのかを明確にする

テストで担保すべきこととして、どんなプロダクトでも共通して明確なことは、「アプリケーションが仕様通り動いているか(=デグレしていないか)」 に尽きます。

アプリケーションのフロントエンド開発における仕様の大半は、「この画面でこの操作を行ったらこの結果になる」で定義できます。これを担保したければ、画面毎にブラウザ操作系のテストを積極的に書いていくのがよさそうです。

もちろん、メソッド/コンポーネント単位のテストも大事ですが、アプリケーション開発において、これらはあくまで実装の詳細でしかないことは認識しておきたいところです。このテストをパスしても、アプリケーションの仕様を担保することはできません。また、リファクタリングによって不要になる可能性のあるテストでもあります。この辺の話は、以下の記事がとても参考になります。

3. ROIの3つの観点と実行のTips

ここからは、ROIを3つの観点に分け、テストを実行する際に活用したいことや気をつけたいことを紹介します。

3–1. 実行時間を短く、フィードバックを速くする

フィードバックの速さは開発速度の向上につながるので、とても大事なポイントです。

・実行フェーズに沿った実行時間を守る

ブラウザ操作系のテストはリアルタイムでは実行できないので、手動もしくはCIで実行することになると思います。CIで動かすのであれば、長くて10分、できれば5分以内で納めたいですね。

・並列実行

機能が増えるほどテストも増えていきますので、時間を一定以下に保つには必須の機能です。テスティングライブラリが並列実行に対応していることをまず確認する必要があります。またできる限り各テストを独立でかつ実行できるように設計することも重要です。

※テストの文脈からは外れますが、コードを書いている最中にリアルタイムでフィードバックをくれるLinterや型による制約は、積極的に活用したいですね。

3–2. 初期実装およびメンテナンスのコストを低くし、継続的に管理する

テストの継続的なメンテナンスがどれだけ難しいかについてはそれだけで別のブログが書けてしまいますので、Tipsを2点ほど紹介するに留めます。

  • ブラウザ操作系のテストを採用する

メソッド/コンポーネント単位のテストは、そのメソッドやコンポーネントがどのような実装なのかを理解するため、テストを書き始める前にコードリーディングの時間が必要です。実装の中身を理解した上で、どんなテストケースが望ましいかを考え、そのテストケースの実行に必要な状態を再現します(APIのモックや依存コンポーネントのスタブ化、あるいはテスト開始時の状態(State)を再現する等)。

一方で、ブラウザ操作系のテストはブラウザを介したアプリケーションの挙動をテストすればよく、内部実装を細かく知る必要はありません。新しくジョインしたメンバーが足りないテストを補完する場合も、現状のアプリケーションの挙動をそのままテストケースに起こせばデグレを防ぐことができます。メソッド/コンポーネント単位のテストに比べて初期導入のコストは少しかかるかもしれませんが、テストケースの実装に関するコストはむしろ下がると思っています。

  • APIのモックを実装時に定義する

フロントエンドに絞ったテストを行うにあたってはバックエンド部分(=API)をモックする必要がありますが、実装時に定義する開発プロセスを経ることでテストのハードルを下げられます(詳しくは後述します)。

3–3. アプリケーションの仕様を満たすテストケースを効率よく洗い出す

テストカバレッジを計測すればテストに漏れがないかどうかを判断できますが、ここでも工夫が必要です。

単一のテストでカバレッジ100%を目指さない

前述したようにテストのサイズによってカバーしやすい範囲や粒度があることを考えると、単一のテストでカバレッジ100%を目指すことはあまり効率的ではありません。そこで、まずサイズの大きなテストで大まかにアプリケーションの挙動を確認し、細かい挙動は適度に小さいサイズのテストでカバーしていくのがもっとも効率的です。

・複数のテストを組み合わせる

私の担当プロジェクトでは、メソッド/コンポーネント単位のテスト+ブラウザ操作系のテスト+サーバサイドも含めたアプリケーション全体に対するテストを組み合わせて、アプリケーションの仕様を満たすことを目標にしています。

このようなやり方でカバレッジを把握する場合、書くべきテストケースを正しく洗い出す必要があります。その点、ページ単位のブラウザ操作系テストは「この画面でこの操作を行ったらこの結果になる」と定義された仕様ですから、画面設計の段階でほぼ洗い出せているはずです。あとは、ブラウザ操作系テストでカバーできない細かな実装をメソッド/コンポーネント単位のテストでカバーすればOKです。(余談ですが、Codecovに複数のカバレッジをアップロードすると合算してくれるという記事を見かけたので、今度それを試してみたいと思っています。)

組み合わせの割合としては圧倒的にブラウザ操作系テストの比率が高く(ただしAPIはモック)、現状では約9割がブラウザ操作系、残りの1割がメソッド/コンポーネント単位のテストです。今のところこれがベストだと感じています。

ちなみに、ROIを考慮したフロントエンドのテストにおける割合については、@kentcdodds のテスティングトロフィーも参考になります。

※上記の記事における「Integration Test」が本記事でいうブラウザ操作系のテストに相当します。

4. Googleによるテスト分類

ちなみに、ここまでテストの内容をもとに分類してきましたが、実はGoogleがサイズと制約によってテストを分類しています。

Googleの分類によると、SmallサイズのテストはネットワークアクセスやDBアクセス等が無く、単体として行うテストと定義されています。また、Mediumサイズのテストは、ネットワーク層をlocalhost onlyとし、それ以外はできる限りLargeと同じ(本番環境と同じ状態)で行うテストと定義されています。これを私が整理した分類に当てはめてみると、以下のようにきれいに整理することができました。

  • Small … メソッド/コンポーネント単位のテスト
  • Medium …ブラウザ操作系のテスト
  • Large …サーバサイドも含めたアプリケーション全体に対するテスト

特にMediumテストについては、現在のプロジェクトではネットワーク層をサービスワーカーを使ってlocalhost onlyにしたテストとして実装しているため、Googleの定義に忠実に沿うものとなっています。効率を求めて独自にテスト戦略を考えた結果、大部分がGoogleでいうMediumなテストをマッチョにやっているという結果になりました。

現在の担当プロジェクトにおけるテストの全体像(イメージ)

5. 担当プロジェクトでのテスト方針とその実装

前提の整理がとても長くなってしまいましたが、私の担当プロジェクトでは、以下のようにテスト方針を定め、実装しています。

5–1. テスト方針

「2. テスト効率を考えるための2つのポイント」を踏まえて、以下の方針を定めました。

  1. エンドユーザの関心事であるアプリケーションの振る舞いを担保するため、ブラウザ操作系のテストを画面毎に行うことを最優先とする
  2. メソッド/コンポーネント単位のテストは複数箇所から呼ばれる重要な共通モジュール等に絞って書く

※無論、不特定多数の方から使われるライブラリ・OSS開発においては、エンドユーザの関心事はライブラリのモジュールの挙動になるので方針も違ってきます。

5–2. 実装

フロントエンドのテストにフォーカスしているので、Largeサイズのテストについては割愛します。

・メソッド/コンポーネント単位のテスト(Smallサイズ)

Vitest+vue/test-utilsに使用してテストを記述しています(量は少ないです)。Vue.jsだと、割と一般的な技術スタックだと思います。

  • ブラウザ操作系のテスト(Mediumサイズ)

Playwright + Mock Service Worker(以降MSW)によるブラウザ操作系テストと、その過程で取得したスクリーンショットを使ってregによる画像回帰テストを行っています。

ブラウザ操作系のテスティングライブラリについては、単体で並列実行が可能なものなら何でもいいと思います。今回Playwrightを選んだ理由は、playwright-mswという、開発時にMSWで作成したモックをそのままPlaywright上に持ってこれるライブラリがあったことが大きいです(Cypressなどでもあるかもしれません)。MSW、playwright-mswについては、この記事のAppendixで簡単に紹介します。

また、regによる画像回帰テストを導入した背景ですが、今回開発中のアプリケーションは社外ユーザの利用を想定しているため、明らかなレイアウト崩れを避ける(社外のユーザを不安にさせたくない)ために入れています。

5–3. 余談

Playwrightは、初期設定だとCI上ではService Workerの数が1つになる設定になっています。おそらくGitHub workflow上で実行する際のスペックを考慮したものだと思っているのですが、詳しくは調べていません(ご存じの方いれば教えてください)。現状、待てない時間ではないのでそのままで運用し、テストがコケた場合は、手元でPlaywrightによるテストを実行しながらデバッグしています(並列実行なのですぐ終わります)。

使用したライブラリの説明とテスト導入時のトラブルシューティングについては、この記事末尾のAppendixに軽くまとめたので、いざ実装してみようという方はそちらを参照ください。

最後に

ここまでつらつらと書いてきましたが、やはりテスト設計は難しいです。ただどんなテストにも言えることですが、継続的にメンテされ、結果が信頼できるのものであり続けなければなりません。テストを形骸化させないためにも、できる限りハードルを下げて効果的なテストを効率的に書いていくことで、みんながテストを書くことを苦に思わないようにしていくことが、とても大事ではないでしょうか?

私は2023年2月にFinatextに入社しましたが、テスト設計についても議論ができる、本当に質の高いメンバーに囲まれ、日々刺激的な毎日を過ごしております。少しでも興味が湧いた方は、ぜひご連絡ください!(私の Twitter @1010real に直接ご連絡いただいてもOKです!)

▼Finatext開発チーム 採用情報

▼募集一覧

▼カジュアル面談の応募フォーム
https://forms.gle/r69LwG3cLAjiGvY66

↓ここからAppendixです。

Appendix1: MSWとplaywright-mswについて

Mock Service Worker(MSW)とは

その名の通り、サービスワーカーを使ってモックするライブラリです。
https://mswjs.io/

公式を見て頂いた方が確実ですが、簡単にMSWのメリットを紹介すると

  • プロダクトコードはそのまま
    サービスワーカーレベルでモックレスポンスを返すことで、プロダクトのコードに対するモック用の変更を加える必要がない
  • バックエンドがまだでも開発できる
    スキーマさえ決まっていればガンガン開発できる
  • テストにも使える&テストコードもスリムになる
    APIのモックをそのまま使える。ブラウザ操作系テストでは特に威力を発揮する
  • モックがメンテされやすい
    開発時だけではなく、テストにも同じモックが使用されることで、継続的にメンテされる信頼性の高いモックとなる

この中でも特に大事なのは、開発とテストで同じモックを共有しているというところだと個人的には思っています。
モックを書くという行為が開発の一端になることで、いざテストコードを書く時に、すぐにテストシナリオの記述から始められるため、ハードルも下がります。
また、APIの仕様が変わった場合にモックを変更すればテストでちゃんとコケてくれるので、実装時に修正すべきテストをすぐに洗いだせるのもメリットです。

playwright-mswとは

Playwright上で、MSWで定義したモックをそのまま利用できるようにするライブラリです。
https://www.npmjs.com/package/playwright-msw

設定さえしてしまえば、テストケースを書いている上で特に意識しなくても、APIレスポンスがMSWで定義したモックレスポンスに置き換わります。そのため正常系のテストであれば、再度モックを定義する必要はありません。
もちろん、イレギュラーなテストケースにて、テストケース内で特定のエンドポイントのレスポンスを変更することも可能です。

Appendix2: MSWを使用する際によくある問題とトラブルシューティング

Service Workerが立ち上がる前にリクエストが飛んでしまう

Service workerが立ち上がり、かつモックしたHandlerのロードも待ってからアプリを立ち上げると良いです。スニペット的に以下のようなprepare()関数をアプリの起動処理の前に挟むと良いです。

// Vue.js(+Vite)の場合
import { createApp } from 'vue'
import App from './App.vue'
import { setupWorker } from 'msw'
import { handlers } from './mocks/handler'

const worker = setupWorker(...handlers)

async function prepare() {
if ('production' !== import.meta.env.MODE) {
await import('../public/mockServiceWorker.js?worker')

return worker.start({}).then(() => {
console.groupCollapsed('[MSW] Loaded with handlers 🎉')
worker.printHandlers()
console.groupEnd()
return null
})
}
}

void prepare().then(() => {
createApp(App).mount('#app')
})
// React(+Vite)の場合(最後だけ)
void prepare().then(() => {
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
})

サーバ側でService Workerが使えなくて死ぬ

MSWはサーバでも使えるのですが、その場合はService WorkerとしてではなくNodeJs環境でリクエストをインターセプトしてモックレスポンスを返すようになります。(つまりMSWはAPI開発でも威力を発揮できます)

ただブラウザ環境とNodeJs環境でセットアップ方法が違うので、導入時は今どんな環境(ブラウザ or NodeJs)で動いているかによって使い分ける必要があります。

特にSSRの場合は両方の環境を考慮する必要があるため、割と手間なので、以下にコードスニペットを貼っておきます。

例)Next.jsへの導入方法

startup.js

import type { SetupWorker } from 'msw'
import type { SetupServer } from 'msw/lib/node'

type SetupWorkerType = SetupWorker | SetupServer | null

let _worker: SetupWorkerType = null

export type Callback<T> = (arg?: SetupWorker | SetupServer | null | undefined) => T

export const prepare = async <T>(cb: Callback<T>) => {
return _worker ? cb(_worker) : await startup(cb)
}

const startup = async <T>(callback: Callback<T>): Promise<ReturnType<Callback<T>>> => {
if ('active' === process.env.NEXT_PUBLIC_MSW) {
if (typeof window != 'undefined') {
const { worker } = await import('./browser')
return worker.start({}).then(() => {
console.groupCollapsed('[MSW] Loaded with handlers 🎉')
worker.printHandlers()
console.groupEnd()
_worker = worker
return callback(worker)
})
} else {
const { server } = await import('./server')
server.listen()
_worker = server
return callback(server)
}
}
return callback(null)
}

_app.tsx

import { AppProps } from 'next/app'
import { prepare } from 'tests/mocks/startup'

export default function App({ Component, pageProps }: AppProps) {
prepare(() => null)
return (
<Component {...pageProps} />
)
}

上記はアプリ起動時にMSWのセットアップを行なっていますが、ここでMSWの起動を待つためにprepare()をawaitする(=Appを非同期関数にする)と、それは正しいReact ChildじゃないとNext.jsに怒られてしまうので、Apiクライアント生成時にMSWの起動を待つための設定を入れておきます(以下)

apiclient.ts

import axios, { AxiosInstance } from 'axios'
import { prepare } from 'tests/mocks/startup'

let client: AxiosInstance | null = null

const createAxiosInstance = () => {
client = axios.create({})
return client
}

export const getAxiosInstance = async () => {
return client ?? prepare(createAxiosInstance)
}

Appendix3: playwright上でMSWを利用する方法

  1. 以下のようなファイルを用意します。

test.ts

import { test as base, expect } from '@playwright/test'
import type { MockServiceWorker } from 'playwright-msw'
import { createWorkerFixture } from 'playwright-msw'

import { handlers } from './handler'

const test = base.extend<{
worker: MockServiceWorker
}>({
worker: createWorkerFixture(handlers),
})

export { test, expect }

2. テストケース内でこのtestを使ってテストケースを書きます。

sample.spec.ts

import { expect, test } from 'test.ts'

test('should be 1', async ({ page, worker }) => {
await page.goto('/')
await expect(page.locator('[data-testid="fetch-test"]')).toHaveText('1')
})

以上です。Apiをモックするコードが入らないとこんなにもスッキリするんですね。読みやすくて良いですね。

また、以下のようにテストケース毎にモックレスポンスを上書きもできます。

sample2.spec.ts

import { rest } from 'msw'
import { expect, test } from '../src/mocks/test'

test('should be 1', async ({ page, worker }) => {
await worker.use(
rest.post('/test', (request, response, context) =>
response(
context.status(200),
context.json({
id: 'a',
title: 'apple',
})
)
)
)
await page.goto('/')
await expect(page.locator('[data-testid="fetch-test"]')).toHaveText('a')
})

参考までに、こちらにReactとVue.jsのサンプルを作っていますので、興味がある方は参考にしてみてください。
https://github.com/1010real/react-vite-playwright-msw
https://github.com/1010real/vue3-vite-playwright-msw

--

--