プロジェクトが佳境に入り、品質担保が急務となった時、私たちのチームは「e2eテストで全体をカバーすれば安心だろう」と考えました。確かに理にかなっています。ユーザーが実際に使う流れをそのままテストできるのですから。
そこで、機能仕様書を元にe2eテスト基盤を構築し、これを品質担保の中核に据えることにしました。3つの主要モデルにCRUD操作、さらに異常ケースやバリエーションテストまで含めると、気がつけば120〜200件のテストケースが出来上がっていました。Excelで管理されたこれらのテスト仕様は、機能が追加されるたびに着実に増え続けていきました。
チームでの技術的挑戦:実行時間との戦い
テストケースが充実したのは良いのですが、現実は甘くありませんでした。全テストの実行に約40分もかかるようになったのです。開発者がちょっとした修正を確認するために40分待つなど、到底現実的ではありません。
チームで「この問題は技術で解決できるはず」と考え、先輩が並列実行のアイデアを提案してくれました。Tiltを使ってKubernetesクラスターをローカルに3つ立ち上げ、Playwrightの並列実行機能と組み合わせる構成です。
# Tiltfile(設定例)
load('ext://helm_resource', 'helm_resource')
for i in range(3):
helm_resource(
f'test-env-{i}',
'charts/app',
port_forwards=[f'{8080+i}:8080'],
resource_deps=['db']
)
// playwright.config.js
export default {
projects: [
{ name: "cluster-1", use: { baseURL: "http://localhost:8080" } },
{ name: "cluster-2", use: { baseURL: "http://localhost:8081" } },
{ name: "cluster-3", use: { baseURL: "http://localhost:8082" } },
],
workers: 3,
};
結果は上々でした。120分かかっていたテストが40分まで短縮されました。並列化によるオーバーヘッドもほとんどなく、クラスター数に比例してほぼ線形にパフォーマンスが向上しました。「やった、これで解決だ」と思ったのも束の間、今度は別の問題が浮上してきました。
次の課題:テストの脆さに直面
画面を少し変更しただけで、大量のテストが赤くなる。そんな経験をお持ちの方も多いのではないでしょうか。私たちも同じ問題に直面していました。HTMLの構造を少し変えただけで、テーブルの「3列目の2行目」を指定していたテストが軒並み破綻します。エラーメッセージを「パスワードが間違っています」から「認証に失敗しました」に変更しただけでも、テストは容赦なく失敗します。
この課題に対して、先輩がPage Object Modelの導入を提案してくれました。画面の操作を抽象化し、変更の影響を局所化しようという作戦です。
// 認証機能をPage Objectとして抽象化
class AuthPage {
constructor(private page: Page) {}
async signIn(userId: string, password: string) {
await this.page.fill('[data-testid="user-id"]', userId);
await this.page.fill('[data-testid="password"]', password);
await this.page.click('[data-testid="sign-in-button"]');
await this.page.waitForSelector('[data-testid="dashboard"]');
}
}
// テストではメソッド呼び出しするだけ
test("商品追加フロー", async ({ page }) => {
const authPage = new AuthPage(page);
const productPage = new ProductPage(page);
await authPage.signIn("admin", "password123");
await productPage.addProduct("新商品", 1000);
await expect(page.locator('[data-testid="product-list"]')).toContainText(
"新商品",
);
});
これで、認証処理はメソッド呼び出しするだけで済むようになりました。異なるユーザーでテストしたい時も、パラメータを変えるだけです。UI変更の影響もPage Objectに集約できます。
しかし、私が見ていて感じたのは、この対策でも根本的な限界がありそうだということでした。Page Object Modelの実装自体が意外と大変で、特に複雑なテーブル操作などは、Playwrightのレコーディング機能で簡単に作れるテストに比べて、実装コストが高くつきます。さらに、並列実行環境ではデバッグも困難で、テストが失敗した時の原因調査に時間がかかるようになっていました。
根本的な疑問
チームで技術的な対策を重ねても、どこか根本的な問題が解決されていない気がしていました。そんな時、私はふと疑問に思ったのです。「そもそも、これだけのテストが本当に必要なのだろうか?」
Excelで管理されたテスト仕様を改めて眺めてみると、重複箇所もあることに気づきました。「商品Aを追加するために、カテゴリBを事前に登録する」「商品Xを削除するために、まず商品Yを登録する」といったパターンが至る所にあります。機能追加のたびに「念のため」「万が一のため」という理由でテストケースが追加され、気がつけば似たようなテストが量産されていました。
チームメンバーも、テストの実行時間とメンテナンスコストの増大に困っていました。新しい機能を作るより、テストの修正に時間を費やすことも珍しくありません。
そこで私は一つの仮説を立てました。「もしかすると、テストを『減らす』ことで、かえって効率が上がるのではないか?」
私の提案:逆転の発想でのテスト削減
この仮説をチームに提案し、ユーザーストーリーベースでのテスト選別を試みることを提案しました。考え方はシンプルです。重複を排除し、本当に重要なユーザーの行動だけをe2eテストでカバーするのです。
具体的には、こんな基準で選別することを提案しました:
- 管理者はシステムにログインできる
- 管理者は商品を削除・追加できる
- 一般ユーザーは商品を閲覧できる
このような主要なユーザーストーリーのみをe2eテストとして残し、エラーハンドリングや境界値テストなどの詳細は、単体テストや結合テストに任せる戦略です。
チームでこの方針を議論した時、意外にもスムーズに合意が取れました。皆、現状の課題を肌で感じていたからです。詳細な資料を作成するのではなく、実際の課題を共有し、「実験的にやってみよう」というスタンスで始めました。
体験を通じたテストピラミッドの理解
実は、テストピラミッドという考え方は以前から知っていました。単体テストを土台として多く配置し、結合テスト、E2Eテストと上に行くほど数を減らしていく、あの三角形の図です。
しかし、それまでのプロジェクトが比較的順調に回っていたこともあり、「理論は理論、実際のプロジェクトでは品質担保が最優先」と考えていました。小規模なチームで、テストケースもそれほど多くない状況では、E2Eテストメインでも特に問題は感じていませんでした。むしろ、E2Eテストならユーザーの操作をそのまま再現できるし、確実に品質を担保できる。そう信じて疑いませんでした。
ところが、プロジェクトの規模が大きくなり、テストケースが200件近くに膨れ上がった今回の経験を通じて、テストピラミッドが単なる理論ではなく、プロジェクトの成長に伴って必然的に直面する課題への実践的な解決策であることを痛感しました。
規模拡大で見えたE2Eテストの「隠れたコスト」
小規模なプロジェクトでは見えなかった、E2Eテストの本当のコストが浮き彫りになりました:
時間的コスト
- 実行時間:120分(並列化後も40分)→小規模時は数分だった
- デバッグ時間:失敗時の原因特定が困難→テスト数が少ない時は許容範囲
- メンテナンス時間:UI変更のたびに大量のテスト修正→以前は数個の修正で済んでいた
技術的コスト
- 環境構築の複雑さ:Kubernetesクラスターが3つ必要→以前は1つで十分だった
- マシーンリソースの制約:ローカルで全体E2Eテストを通すことが困難
組織的コスト
- テスト修正に追われ、新機能開発が進まない
- 「テストが通らない」ことによる開発ブロック
- チーム全体のモチベーション低下
つまり、テストピラミッドの必要性は、プロジェクトの規模と複雑性に比例して高まるということです。小規模なうちはE2Eテスト中心でも回るかもしれませんが、ある閾値を超えると急激に破綻します。私たちはまさにその転換点に立っていたのです。
だからE2Eテストは「必要最小限」にすべき
テストピラミッドが推奨する「E2Eテストは少数精鋭」という原則は、コストの観点から見ても合理的でした。E2Eテストは確かに強力ですが、その力を発揮すべき場面を見極めることが重要です。
今回の経験から、E2Eテストが本当に必要なのは:
- ユーザーの主要な業務フローの検証
- 複数システム間の連携確認
- リリース前の最終的なスモークテスト
これ以外の詳細なロジック検証や異常系テストは、より適切なレイヤーで行うべきだと確信しています。
理論を知っていることと、その価値を体感することは別物でした。テストピラミッドは、単なる理想論ではなく、開発効率と品質のバランスを取るための実践的な指針だったのです。
私が担当した下位レイヤーの基盤作り
e2eテストを減らすなら、その分を単体テストや結合テストで補う必要があります。そこで私が中心となって、下位レイヤーでのテスト基盤を整備しました。
Vitestを導入し、DBテストユーティリティを作成して、テストごとのデータクリーンアップを自動化しました。
// DBテストユーティリティの実装
class TestDBHelper {
async cleanupDB() {
await db.raw("TRUNCATE TABLE products RESTART IDENTITY CASCADE");
await db.raw("TRUNCATE TABLE users RESTART IDENTITY CASCADE");
}
async setupTestData() {
await db("users").insert({ id: 1, role: "admin", name: "テスト管理者" });
await db("categories").insert({ id: 1, name: "テストカテゴリ" });
}
}
この基盤を整備していく中で、私自身が体験した予想外の副次効果がありました。TDDのワークフローが自然に回るようになったのです。
私が体験したTDDの自然な実現
TDDの理論は知っていましたが、実際に実践したことはありませんでした。しかし、テスト基盤が整うと、私は自然とこんなサイクルで開発するようになりました:
- 機能仕様に基づいてテストを先に書く
- テストを実行する(当然失敗)
- テストが通るまで実装を進める
- vitest --watchで保存のたびに即座に結果確認
- 安心してリファクタリング
このサイクルが回り始めると、驚くほど開発効率が向上しました。動作確認の時間が短縮され、デバッグに費やす時間も減り、リファクタリングも安全に行えます。体感的には、機能開発にかかる時間が1/4から1/5程度削減できたと感じています。
結果と私の振り返り
最終的に、以下のような成果を得ることができました:
-
e2eテスト数: 200件から50件へ(75%削減)
-
テスト実行時間: 120分から40分へ(並列化効果)
-
機能開発時間: 従来の1/4〜1/5を短縮
-
技術的には成功でしたが、私が感じた組織的な課題もありました。下位レイヤーでどの部分をテストすべきかの指針を、チーム全体で十分に共有できていませんでした。個人の判断に依存する部分が多く、テスト不足のリスクが残っていました。
また、e2eテスト削減による品質への影響を定量的に測定・共有する仕組みも不足していました。幸い、重要な機能の正常系はe2eテストでカバーできていたため、目に見える品質低下は発生しませんでしたが、より長期的な視点での品質評価が必要だったと振り返っています。
私が学んだこと
この経験を通じて私が学んだのは、技術的な解決策には必ず限界があるということです。並列実行もPage Object Modelも、確かに効果はありましたが、根本的な課題を解決するには至りませんでした。