まとめ
| 観点 | 内容 |
|---|---|
| 課題 | 手動検証に依存しており、リグレッション発生・検証コスト増・手順の属人化により、改善スピードと大型アップグレードが停滞していた。 |
| 対応 | ユニット / 結合 / 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ジョブ完結)
- 依存インストール & ビルド
- アプリ起動(バックグラウンド)
- データ初期化
- Playwright実行
- トレース収集:
on-first-retry - レポート出力:
html
- トレース収集:
- アーティファクト収集 & クリーンアップ
- スクリーンショット/レポート/トレース保存
- 最終ステップでプロセスを確実に終了(
finally相当)
並列戦略(不安定化を避ける方針)
- シャーディング優先:テスト全体を複数ジョブに分割(shard)して短縮。
→ CIのマトリクスで安全にスケール。ローカルとの差異を生みにくい。 - workerは慎重に:
workers=1を基本。
→ ポート競合/共有状態/I/O負荷による テストの揺らぎ(不安定化) を避ける
運用・改善フェーズ
テストは導入後は回帰(リグレッション)を継続的に検知・抑止する運用に重心を置いた。
整備中・整備後に気をつけたこと
-
「テストを書くためのテスト」にならないようにする
実際のバグや要件変更を起点に、必要な範囲だけテストを追加。テストを目的化せず、品質保証のための手段として位置づけた。 -
失敗時に原因がわかるテストを書く
テスト名と出力メッセージを明確化。例:should return 400 when missing headerのように、意図と前提が1行で伝わる命名を徹底。 -
テストのメンテナンスコストを下げる
共通fixture/builderを導入し、テストデータを中央管理。
変更時の追従を1箇所に集約し、リファクタ耐性を高めた。 -
「網羅率」より「信頼性」を指標にする
カバレッジ数値を追うのではなく、「壊れたら確実に気づけるか」を主指標に採用。
テストレビューでも「気づける保証」があるかを議論対象とした。 -
CIの実行時間とのバランスを取る
並列実行やキャッシュ戦略を最適化し、10分以内で完走するテスト基盤を維持。
不安定テストの検出と隔離
- 失敗時の再実行ポリシー:
retry: 2/on-first-retry: trace。 - フレーク率のしきい値:X% 超過で quarantine ラベルを付与、E2E スイートから除外して別枠改善。
- 失敗ログの標準化:スクショ・動画・トレースをArtifactsに常時保存、再現手順を自動添付。
スローなテストの削減
- ボトルネックの類型化:ネットワーク待ち/DB初期化/過剰レンダリング/過度な E2E 依存。
- 対策カタログ:
- API/DB の結合テスト化(E2E 依存の縮退)
- フィクスチャの差分初期化
毎回全データをリセットせず、テストで必要な範囲だけ初期化。
同じ処理を何度実行しても状態が壊れないように設計し、再現性を保ったまま実行時間を大幅に短縮した。
成果
- 「壊していない確信」が得られ、大きなリファクタや依存アップグレードの意思決定が速くなった。
- 障害対応を通じて得た知見や仕様の理解を、ドキュメントではなくテストコードとして形式知化。
今後
差分テスト実行(テスト選択)の導入
すべてのテストを毎回実行せず、変更差分(パス・コミット履歴・依存グラフ)をもとに影響範囲だけ再実行する仕組みを導入。
- コード変更に対応するテストファイルを自動解析
- テストの依存関係グラフをキャッシュして選択実行
- 結果をメタデータとして蓄積し、影響範囲の精度を高める
これにより、CI時間を短縮しつつリグレッション検知精度を維持することを狙う。
CI 並列度の動的最適化(履歴ベース)
テストの実行履歴をもとに、CI の並列度を動的に最適化する。
- 各テストスイートの平均・P95実行時間を収集
- 実行履歴を解析し、次回ジョブで
--shard数やworkers数を自動調整 - 一定期間でリバランスを実施し、リソース使用率を可視化
これにより、CI負荷を固定値でなくデータ駆動で制御し、時間・リソース・信頼性のバランスを最適化する。
関連ブログ
参考
スローなテストの洗い出しと改善
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で特に遅いテストを抽出可能
またリソース消費やハンドルリークもjestのオプションとして提供されている。
-
--logHeapUsage
各テストファイル終了時のヒープ使用量を出力。
メモリリークやキャッシュ肥大を早期検知し、重いテストを特定。 -
--detectOpenHandles
実行終了後も解放されていないハンドル(未クローズのSocket・Timerなど)を検出。
非同期処理の不適切なawait漏れを発見でき、E2E・結合テストの安定化に寄与。
※常用はせず、デバッグ時に限定して実行。