こんにちは。Webアプリケーションエンジニアのきのすけです。
フロントエンド開発において、適切なテスト戦略を選択し実装することは、品質を担保する上で重要な課題です。この記事では、ロリポップ for Gamersにおけるフロントエンドのテスト拡充の取り組みについて紹介したいと思います。
背景
GMOペパボでは、2024年に「ロリポップ for Gamers」というサービスをリリースしました。これは、VPSをベースに「ゲームのマルチプレイが簡単にできる環境」を提供するサービスです。技術スタックとしては、フロントエンドにNext.js、バックエンドにGoを採用しています。
プロジェクト初期において、フロントエンドの経験が豊富なチームメンバーはいませんでした。しかし、迅速な市場参入を実現するために、非常に限られた時間の中で、可能な限りフロントエンドのキャッチアップを行い実装を行いました。
結果として、プロジェクトの立ち上げから13営業日で初期リリースを実現することができました。
課題
プロジェクト初期において、フロントエンドのテストは表示やフックに関わらないロジック部分のユニットテストのみを実装していました。しかし、実際の開発を進める中で、以下のような課題が見えてきました。
- ユーザーの実際の操作(ボタンクリックなど)に対する動作確認ができていない
- APIリクエストの成功/失敗時の挙動を確認できていない
- 画面表示が意図通りに更新されるかを確認できていない
非常に簡略化をすると以下のようなイメージです。
// sample.tsx
import { useState } from 'react'
export const wrapText = (text: string) => {
return `${text}!!!!!!!`
}
const reqApi = (): Promise<string> => {
// サンプルのため、API呼び出しをPromiseで代替
return new Promise<string>((resolve) => {
setTimeout(() => {
resolve('Hello, World')
}, 100)
})
}
export const Sample = () => {
const [value, setValue] = useState('')
const handleClick = () => {
reqApi().then((v) => {
setValue(wrapText(v))
})
}
return (
<div>
<button data-testid='sample-button' onClick={handleClick}>
Fetch
</button>
<span data-testid='sample-text'>{value}</span>
</div>
)
}
// api.ts
export const reqApi = (): Promise<string> => {
// サンプルのため、API呼び出しをPromiseで代替
return new Promise<string>((resolve) => {
setTimeout(() => {
resolve('Hello, World')
}, 100)
})
}
// sample.test.ts
import { describe, expect, it } from 'vitest'
import { wrapText } from './sample'
// このくらいしか書けない...
describe('wrapText', () => {
it('should wrap text', () => {
expect(wrapText('Hello, World')).toBe('Hello, World!!!!!!!')
})
})
このテストコードでは、単純な文字列処理(wrapText関数)の検証しかできていません。実際のアプリケーションでは
- ボタンをクリックしたときの動作
- データ取得後の画面表示の更新
- エラー発生時のエラーハンドリング
といった重要な部分が検証できておらず、品質を担保するには不十分な状態でした。
しかし、テストの方針について再考するほど余裕もありません。一旦はそのまま実装を進め、後ほど検討することとしました。
対応したこと
リリースラッシュが一区切りついたタイミングで、チーム内の有志にてフロントエンドの課題を棚卸ししました。この時期にはフロントエンドの経験が豊富なメンバーがジョインしてくれたため、相談をしつつテストコードのあり方についての議論も行いました。
テスト戦略を決める
結論から言うと「testing-libraryを用いたコンポーネントテスト」に重点を置く方針としました。jsdomのようなライブラリを使用してメモリ上にコンポーネントをレンダリングし、イベントを発火することで、DOMにどのような変化が発生したかをチェックするテストになります。
テストコードの例は以下のようになります。
// sample.test.ts
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { reqApi } from './api'
import { Sample } from './sample'
// APIをモック
vi.mock('./api', () => ({
reqApi: vi.fn(),
}))
describe('Sample', () => {
// 各テストの前にモックをリセット
beforeEach(() => {
vi.clearAllMocks()
})
it('正常系のメッセージが表示されること', async () => {
// APIの成功レスポンスをモック
vi.mocked(reqApi).mockResolvedValue('Hello, World')
// レンダリングする
render(<Sample />)
// レンダリングされた要素を取得する
const button = screen.getByTestId('sample-button')
const text = screen.getByTestId('sample-text')
// クリックする前の表示
expect(text).toHaveTextContent('')
// クリックする
fireEvent.click(button)
// クリック後の表示
await waitFor(() => {
expect(text).toHaveTextContent('Hello, World')
})
})
it('APIがエラーの場合、エラーメッセージが表示されること', async () => {
// APIのエラーレスポンスをモック
const errorMessage = 'API Error'
vi.mocked(reqApi).mockRejectedValue(new Error(errorMessage))
// レンダリング
render(<Sample />)
// ボタンをクリック
const button = screen.getByTestId('sample-button')
fireEvent.click(button)
// エラーメッセージが表示されることを確認
await waitFor(() => {
const text = screen.getByTestId('sample-text')
expect(text).toHaveTextContent(`Error: ${errorMessage}`)
})
})
})
ユーザーのアクションを起点に、画面にどのように反映されるかまで、コンポーネント単位で網羅的にテストができているのではないでしょうか。
コンポーネントテストは 2024年10月の Technology Radar で Adopt となっており、トレンドに即した選定でもあったかと思います。
なお、他に「hook,viewで独立したユニットテストに重きを置く」という手法を検討しましたが、以下の理由のためお見送りしました。
- ふるまいではなく実装をテストしてしまう傾向がありそう。そのため、コンポーネントのリファクタリングに伴いテストも書き換える必要がでてきそう。
- React Hook Form のように、簡単な設定で挙動が変わるコンポーネントのテストができない。
- hooksをテストするライブラリが、コンポーネントテストを推奨していた。
いつやるかを決める
テスト戦略は決まりましたが、いつ実装するかという問題がありました。リリースして間もないサービスです。提供機能を充実させることが非常に重要なフェーズでもありました。
そのような状況下で私がとったアプローチは「まずは自主的な活動として、朝の30分時間を確保してテストコードを書く」ことでした。リリースして間もないサービスで、機能もそれほど多くありません。裁量が効く範囲で時間を確保すれば、おおむねテストコードの拡充ができるのではないかと考えました。また、AIバンドルエディタであるCursorも活用すれば、効率的にテストコードを拡充できる予感もありました。
テストコードの生成
Cursorによるテストコードの生成は、当初は試行錯誤でした。しかし最終的には以下の手法で、ほぼ手直しなく生成できることを確認しました。
-
前準備として data-testid(テストに用いるタグの識別子)を付与するために、以下のようなプロンプトを使用。
'@testing-library/react', 'vitest' を使用して、コンポーネントのテストを書きたいです。まずは xxx.tsx に data-testid を付与してください。
-
テスト対象となるコードと、関連するコードをコンテキストとして追加する。
-
Cursor Composer を用いて、以下のようなプロンプトでテストコード生成を指示する。
'@testing-library/react', 'vitest' を使用して、コンポーネントのテストを書いてください。テストのファイルは xxx.test.tsx としてください。なお、hook は以下の例のように、xxxApi のメソッドをモックするようにしてください。 // ここに例となるコードを記載
-
生成したテストケースが不十分だと感じるときは、以下のプロンプトを用いてチューニングする。
では、この出力を60点とします。60点とした時に100点とはどのようなものですか?100点にするために足りないものを列挙した後に、100点の答えを生成してください。
このような取り組みを経て、効率よくテストコードの拡充を進めることができました。
※ なお、AIによるコード生成領域は進化が著しいため、読者の方がこの記事を目にされたときには、かなり古い手法になっているかもしれません。
みんなで実装できるようにする
ここまでの実装は主に私が個人で進めてきましたが、これではスケールしません。チーム全員が取り組めるようにする必要があります。
人が行動を起こす条件には「動機」「能力」「きっかけ」の3つが必要 という考え方があります。チームメンバーにはテストコードを書いたほうがいいという思いはあるため「動機」は満たしていそうです。あとは「能力」と「きっかけ」を作ったらよいのではないかと考えました。
これらを満たすため、メンバーを集め、コンポーネントテストを書くハンズオンを実施することにしました。実際に手を動かしてコードを書くことで「能力」を醸成し、そしてハンズオンというイベント自体を「きっかけ」とするアプローチです。
ハンズオンは、以下の構成で実施しました。
- React Testing Library Tutorial をベースとした座学
- Cursorを使ってテストコードを生成する手法の紹介
- 実運用されているプロダクトのテストコードを各自で実装
ハンズオンはメンバーからは好評で、各自がフロントエンドのテストを書く土壌を整えることができたのではないかと思います。現在は、わたし以外が書いたテストコードがどんどん増えています。
残された課題
テストを書くのは「手早く安全にコードの変更を実現するため」と考えています。そのためには、複数のコンポーネントを統合したインテグレーションテストや、backendともつなぎ込んだE2Eテストの自動化も検討していく必要がありそうです。今後はそれらも視野にいれて、開発に取り組んでいきたいと思います。
なお、ロリポップ for Gamersの backendにおけるテストについては、「短期間での新規プロダクト開発における「コスパの良い」Goのテスト戦略 」に詳しく記載があります。興味のある方は是非ご覧ください。
まとめ
以上、ロリポップ for Gamersにおけるフロントエンドのテスト拡充の取り組みについて紹介しました。コンポーネントテストを中心としたテスト戦略の選定から、AIを活用した効率的な実装、そしてチーム全体での取り組みへと発展させた経緯をお伝えしました。この事例が、フロントエンドのテスト導入を検討されている方々の参考になれば幸いです。