自動テスト基盤整備による安心して改善できる状態の確立

  • jest
    jest
  • playwright
    playwright
  • testinglibrary
    testinglibrary
  • react
    react
  • expressjs
    expressjs
2023/11/21に公開

まとめ

観点 内容
課題 手動検証に依存しており、リグレッション発生・検証コスト増・手順の属人化により、改善スピードと大型アップグレードが停滞していた。
対応 ユニット / 結合 / E2E の3層を明確に定義し、テストが実装しやすい実行環境(CI・DB・モック・データ投入)を整備。
運用 テストテンプレート化 と 共通 Fixture / Builder により、記述と保守コストを圧縮。失敗時に原因が一読で分かる命名規則を整備。
結果・成果 “壊していない確信” を得られる状態により、リファクタリングや依存アップグレードを安心して実施可能に。手動確認も大幅に削減。

背景と課題

リリース前の品質確認をすべて手動で行っていたため

  • 機能追加やリファクタリングのたびに確認コストが増大
  • 細かな仕様変更に伴うリグレッション(既存機能の破壊)を防ぎきれない
  • 属人化した「確認手順」の共有が難しく、再現性のある品質保証プロセスを維持できない

といった課題が顕在化していた。

また、TypeScript導入によって型安全性は一定レベルまで確保できていたものの、実際の挙動確認まではカバーできず、「壊れたことに気づけない」領域が依然として残っていた。

結果として、開発者は安心してリファクタリングできず、チームの改善スピードが頭打ちになっていた。

また影響範囲の大きなライブラリ(React、webpack、expressなど)のアップグレードも進められなかった。

調査・測定フェーズ

自動テストの導入に先立ち、「何を守るべきか」を明確にするため、既存の品質保証プロセスとリスク構造を可視化した。
目的は、単にテスト数を増やすことではなく、システムの動作保証を最小コストで担保することに置いた。

品質保証プロセスの棚卸し

手動確認リストを起点に、変更頻度・障害発生率・ユーザー影響度の3軸でマッピングを実施。
これにより「頻繁に変更され、かつ障害時の影響が大きい機能」を高優先度と定義した。
各領域を「動作保証の優先順位」としてランク付けし、テスト整備の投資対象を明確化。

テスト容易性(Testability)の評価

React/Expressそれぞれの主要モジュールを対象に、副作用の多い関数・状態依存が強い箇所を抽出。
テスト容易性を妨げる構造は、関数分離や依存注入(DI)による改善を計画的に実施。継続的テスト整備の土台を形成した。

導入・設計(仕組み整備)

テストを「書く」前に、まずテストが正しく動く環境と設計を整えることを優先した。

テスト基盤とレイヤー構成の設計

ユニットテスト・結合テスト・E2Eの3層構成を定義し、それぞれの役割を明確化。

主目的 検証範囲 実行対象の粒度 主な検証観点
Unit(単体) ロジック・純関数・メソッドの正当性 単一モジュール(外部依存なし) 関数・クラス単位 条件分岐・入力と出力の整合性・例外処理
Integration(結合) モジュール間連携とデータフロー DB・API・外部サービスを含む コンポーネント単位・APIエンドポイント リクエスト/レスポンス整合
E2E(シナリオ) 実際のユーザー操作とシステム全体の整合 ブラウザ+サーバ 画面操作・シナリオ単位 UIフロー・状態遷移・UX再現性

層ごとの責務をドキュメント化し、テストコードの粒度や実行範囲を統一。
また、副作用を外部注入できる構造に再設計し、テスト容易性を設計段階から担保した。

例)

実装側

// app/http/HttpClient.ts
export interface HttpClient {
  get<T>(url: string): Promise<T>
  post<T>(url: string, body: unknown): Promise<T>
}

export const fetchClient: HttpClient = {
  async get(url)  { const r = await fetch(url); return r.json() },
  async post(url, body) { const r = await fetch(url,{method:"POST",body:JSON.stringify(body)}); return r.json() }
}

// domainで直接fetchはしない
export class ListingQuery {
  constructor(private http: HttpClient) {}
  async byId(id: string) { return this.http.get(`/api/listings/${id}`) }
}

テストコード

const mockClient: HttpClient = {
  get: jest.fn().mockResolvedValue({ id: 1, title: "mock" }),
  post: jest.fn().mockResolvedValue({ ok: true }),
}

const query = new ListingQuery(mockClient)
expect(await query.byId("1")).toEqual({ id: 1, title: "mock" })

実行環境・運用基盤の整備

テスト基盤には Jest(ユニット・結合) と Playwright(E2E) を採用。
「安定して失敗を再現できる」ことを最優先に、テスト環境とCI基盤を設計した。

再現性を保つための工夫

  • 依存関係のバージョン固定(package-lockによる環境差異の排除)
  • テストデータの初期化と seed 固定による状態の一貫性保持
  • 外部APIのモック化(msw / nock)でネットワーク依存を除外
  • 時刻・乱数の固定による非決定的挙動の抑制

これにより、ローカルとCIの両方で「同じ条件で失敗が再現できる」テスト環境を実現した。

確実に再走できるための仕組み

  • 並列実行性能とキャッシュ特性を考慮したジョブ設計(CI上での安定性向上)
  • タイムアウト・リトライ設定を柔軟に調整し、環境負荷に左右されない実行制御
  • 失敗時のログ・スクリーンショット・トレースを自動保存し、再実行時のデバッグを容易化

CI環境では、GitHub Actions 上で PR 単位の自動実行を構築。
テストが失敗しても同じ条件下で再実行・分析できる再現性重視の運用を実現した。

実装(テスト記述と運用)

設計フェーズで整えた基盤をもとに、テストを「積み上げる」段階へ移行。
目的は、単に網羅率を上げることではなく、壊れたときに確実に気づける仕組みを構築することにあった。

ユニットテストの整備

関数を中心に、入力と出力の対応関係を厳密に検証。

  • テストケースの命名規約と目的(正常系/異常系/境界値)を統一
  • テストファイル構造を実装ファイルと1対1で対応させ、参照容易性を確保

これにより、コード変更時に最小単位で壊れた箇所を即座に特定できる構造を実現。

結合テスト(API・DB・モジュール間連携)

ユニットテストとE2Eテストの中間層として、「モジュール単体では担保できない依存関係」を小さく検証するためのテストを設計。

対象は「1機能全体」ではなく、1モジュール+その直接依存先の範囲に限定した。

  • API層の結合テスト
    supertest を用いて Express のハンドラ単位で実リクエストを発行。ビジネスロジック層や認証ミドルウェアとの接続を確認し、ステータスコード・レスポンス構造・バリデーションエラーの整合性を検証。

  • DBアクセス層の結合テスト
    実際の MongoDB(ローカル/コンテナ環境)に対して CRUD を実行。スキーマ変更やインデックス設定の影響を確認し、型定義・永続化・復元の一貫性を担保。

  • 外部連携モジュールの結合テスト
    Webhookや外部APIの呼び出し部分は msw/node を利用してスタブ化。実際のHTTPリクエストをフックし、リトライ制御・エラーハンドリング・リクエスト構造の整合性を検証した。通信層を完全にモックせず、HTTPレベルのインタラクションを残すことで、実環境に近い形での連携保証を実現。

また、並列実行時のデータ競合を防ぐため、テストごとに独立したスキーマ名や一時データを生成し、再実行可能なテスト設計を徹底した。

このレイヤーによって、「E2Eでしか気づけなかった連携不整合」を事前に検知できる構造を実現した。

E2Eテスト(自己完結・再現性重視)

E2Eは「自己完結」と「完全再現」を原則に設計。外部環境や手動操作に依存せず、CIの1ジョブ内で完結する構成とした。

実行パイプライン(GitHub Actions上での1ジョブ完結)

  1. 依存インストール & ビルド
  2. アプリ起動(バックグラウンド)
  3. データ初期化
  4. Playwright実行
    • トレース収集:on-first-retry
    • レポート出力:html
  5. アーティファクト収集 & クリーンアップ
    • スクリーンショット/レポート/トレース保存
    • 最終ステップでプロセスを確実に終了(finally相当)

並列戦略(不安定化を避ける方針)

  • シャーディング優先:テスト全体を複数ジョブに分割(shard)して短縮。
    → CIのマトリクスで安全にスケール。ローカルとの差異を生みにくい。
  • workerは慎重に:workers=1 を基本。
    → ポート競合/共有状態/I/O負荷による テストの揺らぎ(不安定化) を避ける

運用・改善フェーズ

テストは導入後は回帰(リグレッション)を継続的に検知・抑止する運用に重心を置いた。

整備中・整備後に気をつけたこと

  • 「テストを書くためのテスト」にならないようにする
    実際のバグや要件変更を起点に、必要な範囲だけテストを追加。テストを目的化せず、品質保証のための手段として位置づけた。

  • 失敗時に原因がわかるテストを書く
    テスト名と出力メッセージを明確化。例:should return 400 when missing header のように、意図と前提が1行で伝わる命名を徹底。

  • テストのメンテナンスコストを下げる
    共通 fixturebuilder を導入し、テストデータを中央管理。
    変更時の追従を1箇所に集約し、リファクタ耐性を高めた。

  • 「網羅率」より「信頼性」を指標にする
    カバレッジ数値を追うのではなく、「壊れたら確実に気づけるか」を主指標に採用。
    テストレビューでも「気づける保証」があるかを議論対象とした。

  • CIの実行時間とのバランスを取る
    並列実行やキャッシュ戦略を最適化し、10分以内で完走するテスト基盤を維持。

不安定テストの検出と隔離

  • 失敗時の再実行ポリシー:retry: 2on-first-retry: trace
  • フレーク率のしきい値:X% 超過で quarantine ラベルを付与、E2E スイートから除外して別枠改善。
  • 失敗ログの標準化:スクショ・動画・トレースをArtifactsに常時保存、再現手順を自動添付。

スローなテストの削減

  • ボトルネックの類型化:ネットワーク待ち/DB初期化/過剰レンダリング/過度な E2E 依存。
  • 対策カタログ:
    • API/DB の結合テスト化(E2E 依存の縮退)
    • フィクスチャの差分初期化
      毎回全データをリセットせず、テストで必要な範囲だけ初期化。
      同じ処理を何度実行しても状態が壊れないように設計し、再現性を保ったまま実行時間を大幅に短縮した。

成果

  • 「壊していない確信」が得られ、大きなリファクタや依存アップグレードの意思決定が速くなった。
  • 障害対応を通じて得た知見や仕様の理解を、ドキュメントではなくテストコードとして形式知化。

今後

差分テスト実行(テスト選択)の導入

すべてのテストを毎回実行せず、変更差分(パス・コミット履歴・依存グラフ)をもとに影響範囲だけ再実行する仕組みを導入。

  • コード変更に対応するテストファイルを自動解析
  • テストの依存関係グラフをキャッシュして選択実行
  • 結果をメタデータとして蓄積し、影響範囲の精度を高める

これにより、CI時間を短縮しつつリグレッション検知精度を維持することを狙う。

CI 並列度の動的最適化(履歴ベース)

テストの実行履歴をもとに、CI の並列度を動的に最適化する。

  • 各テストスイートの平均・P95実行時間を収集
  • 実行履歴を解析し、次回ジョブで --shard 数や workers 数を自動調整
  • 一定期間でリバランスを実施し、リソース使用率を可視化

これにより、CI負荷を固定値でなくデータ駆動で制御し、時間・リソース・信頼性のバランスを最適化する。

関連ブログ

https://shinagawa-web.com/blogs/test-automation-enhancement

https://shinagawa-web.com/blogs/nextjs-app-router-testing-setup

参考

スローなテストの洗い出しと改善

1. ネットワーク/外部依存(DB・API・S3等)

症状:HTTP待ち・DNS遅延・外部レート制限で数秒単位のブロック。

対策(TypeScript)

  • HTTP は nock / msw でモック化
  • Playwright の page.route() で外部 API を遮断
  • DB は mongodb-memory-server / SQLite(インメモリ)で高速化
  • マイグレーションはスイート前に一度だけ実行

対策(Go)

  • httptest.Server で外部 API をローカルに閉じる
  • testcontainers / dockertest はコンテナ再利用をデフォルト化(起動オーバーヘッド削減)

2. sleep/タイムアウト待ち・ポーリング

症状:sleep(1000) の積み重ねでテスト全体が分単位化。

対策(TypeScript)

  • fake timers(Jest/Vitest)を利用
  • waitFor の最小タイムアウトを明示指定

対策(Go)

  • 時間依存を Clock インターフェースに抽象化して注入
  • time.After の直書きを排除し、テストで高速クロックを使用

3. 重い暗号・ハッシュ・パスワード処理

症状:bcrypt / argon2 のコストで 1 ケースが数百 ms〜秒単位。

対策

  • テスト時はコスト係数を下げる
  • ハッシュ関数を高速実装に差し替え(DI で切り替え)

4. E2E の過剰利用

症状:E2E が主力になり、分単位の実行時間に。

対策

  • テストピラミッドの最適化:E2E → Integration に縮退
  • E2E はクリティカルパスに厳選
  • 不要な waitForTimeout の排除

測定と可視化(TypeScript)

jest-slow-test-reporterで特に遅いテストを抽出可能

https://github.com/jodonnell/jest-slow-test-reporter

またリソース消費やハンドルリークもjestのオプションとして提供されている。

  • --logHeapUsage
    各テストファイル終了時のヒープ使用量を出力。
    メモリリークやキャッシュ肥大を早期検知し、重いテストを特定。

  • --detectOpenHandles
    実行終了後も解放されていないハンドル(未クローズのSocket・Timerなど)を検出。
    非同期処理の不適切なawait漏れを発見でき、E2E・結合テストの安定化に寄与。
    ※常用はせず、デバッグ時に限定して実行。