回帰テスト(リグレッションテスト)とは、コードを変更した後に既存のテストを再実行し、それまで正常に動作していた機能が壊れていないことを確認する手法です。回帰テストが答えるのは、「この変更によって、以前は動いていたものが壊れなかったか?」というただ1つの問いです。CI/CD パイプラインでは、回帰テストがコミットのたびに自動的に実行されるため、コードが本番環境に届く前に、チームはすぐにフィードバックを得られます。
このガイドでは、回帰テストとは何か、再テストやその他のテストの種類とどう違うのか、そして CI/CD パイプラインで回帰テストを自動化する方法を説明します。
回帰テストはなぜ重要なのか?
回帰テストが重要なのは、コードの変更には副作用があり、その副作用を継続的インテグレーションの段階で検出できれば、かかるコストが数時間ではなく数分で済むからです。
ある開発者が、チェックアウトページに割引コードの入力欄を追加したとします。機能は正常に動作し、ビルドもリリースされました。ところが2日後、サポートへの問い合わせが殺到します。既存の顧客がまったく購入を完了できなくなっていたのです。問題は割引コードのロジックにあったわけではありません。新しいコードによって価格計算モジュールの null 値の扱いが変わり、割引コードのない注文すべてで未処理の例外が発生するようになっていたのです。
これが回帰(リグレッション)です。このバグは新機能のテストでは表面化しませんでした。バグが潜んでいたのは、新しいコードと古いコードの相互作用の部分だったからです。コードベースは独立したシステムではなく、あるモジュールへの変更が、別のモジュールの不具合として表面化することがあります。
このバグを見つけたのは顧客でした。その結果、インシデント対応の時間と、プレッシャーの中でのホットフィックスのデプロイというコストが発生します。CI で検出されたバグなら、開発者の数分の時間と1行の修正で済みます。同じバグが本番環境で見つかれば、インシデントチケット、緊急対策会議、ホットフィックスブランチ、再デプロイが必要になり、その間に失われた顧客の信頼という代償も払うことになります。
Consortium for Information & Software Quality(CISQ)は2022年のレポートで、ソフトウェアの品質の低さが米国経済にもたらすコストは少なくとも2.41兆ドルに上ると推計しています。その大部分は、劇的な障害によるものではありません。発見が遅れたバグと、その修正の過程で残された技術的負債が積み重なった結果です。
回帰テストを省略しても、チームの作業が速くなるわけではありません。デバッグの場所が、数分で済む CI パイプラインから、インシデント対応の調整に何時間もかかる本番環境に移るだけです。しかも、5分前ではなく先週誰かが書いたコードに改めて向き合うという認知的な負担も加わります。問題をできるだけ早い段階で検出するというこのシフトレフトのテスト手法は、DevOps の中核となるプラクティスです。
回帰テストと再テストの違いは?
再テストでは、特定のバグ修正がうまくいったかどうかを確認します。回帰テストでは、その修正やその他の変更によって、別の何かが壊れていないかを確認します。
再テストの範囲は限定的です。たとえば、テスターが「チェックアウトページで海外からの注文の税額が誤って計算される」というバグを報告したとします。開発者がそれを修正します。再テストとは、バグレポートに記載されたシナリオをそのまま実行し、修正が機能していることを確認することです。テストに合格すれば、その不具合はクローズされます。
回帰テストの範囲はより広くなります。税額の修正がリリースされた後、回帰テストスイートを実行して、その修正が他の場所に新たな問題を引き起こしていないかを確認します。税額計算の変更によって、送料の計算方法や、確認ページでの注文合計の表示が影響を受けているかもしれません。回帰テストは、そうした副作用を検出します。
この2つは順番に行います。チームはまず修正を再テストし、次に回帰テストを実行して、巻き添えの被害がないかを確認します。CI パイプラインでは、特定の修正を対象としたテストと、より広範な回帰テストスイートの両方が、同じ実行の中で行われます。
| 項目 | 再テスト | 回帰テスト |
|---|---|---|
| 目的 | 特定のバグ修正を検証する | 変更によって既存の機能が壊れていないことを検証する |
| 範囲 | 限定的:不具合とその修正 | 広範:コードベース全体の、以前は動作していた機能 |
| きっかけ | 報告されたバグが修正されたとき | あらゆるコード変更:機能追加、修正、リファクタリング、依存関係の更新 |
| 自動化できるか | 場合による(不具合の内容次第) | できる。自動化すべき |
| 一般的なタイミング | 特定の修正後、不具合をクローズする前 | 変更のたび。理想的にはコミットのたび |
回帰テストの種類
回帰テストを実行すると決めたら、次の問題はテストスイートのどこまでを実行するかです。CSS の1行の修正に、データベーススキーマの移行と同じテストカバレッジは必要ありません。
適切な方法は、何が変更されたか、その変更にどれだけのリスクがあるか、コードをリリースするまでにどれだけ時間があるかによって変わります。ほとんどのチームは、以下の種類を組み合わせ、継続的インテグレーションと継続的デリバリーのパイプライン全体に段階的に配置しています。テストピラミッドに当てはめると、これらの種類はおおむね異なる層と範囲に対応します。
是正的回帰テスト
製品のコード自体は変更されていません。環境の変更、依存関係のアップグレード、インフラの移行の後に、既存のテストケースが引き続き合格することを確認します。テストを修正する必要がないため、是正的回帰テストはすばやく準備して実行できます。ただし、既存のテストですでにカバーされているコードパスしか対象にならないという制約があります。
漸進的回帰テスト
既存のテストでは変更後の動作を十分にカバーできなくなったため、チームが新しいテストケースを作成したり、既存のテストケースを更新したりします。開発者が新機能をリリースしたり、大規模なリファクタリングを行ったりする場合には、これが通常の状況です。網羅的ですが、回帰テストの実行に意味を持たせるには、事前にテストを作成する手間がかかります。
選択的回帰テスト
スイート全体を実行するのではなく、影響分析に基づいて一部のテストを選びます。この変更はどのモジュールに影響したか、そしてそれらのモジュールをカバーしているのはどのテストか、を分析します。選択的回帰テストは、時間の制約がある実行や、コードベースのうち独立していて境界がはっきりした部分にだけ影響するプルリクエストに適した、現実的な選択肢です。リスクは、影響分析で依存関係を見落とすと、テストの実行でもバグを見逃してしまうことです。
完全回帰テスト
スイート内のすべてのテストケースを実行します。大規模なリリースの前、データベーススキーマの更新や認証の書き換えといった基盤部分の変更の後、多くのモジュールにまたがる大規模なリファクタリングの後に適しています。最も高い信頼性が得られますが、最も時間がかかります。完全回帰テストをコミットのたびに実行するのが現実的なのは、並列実行によってスイートが十分に高速な場合だけです。
ユニット回帰テスト
1つのモジュールを対象としたユニットテストを、そのモジュールの変更後に実行します。ユニットテストは低コストで高速なため、通常は CI パイプラインの最初のゲートになります。トレードオフは範囲です。ユニット回帰テストでは、モジュール同士が連携したときにのみ発生するバグは検出できません。
ビジュアル回帰テスト
ビルド間でスクリーンショットを比較し、機能テストのアサーションではまったく検出できないレイアウトのずれ、スタイルの変化、レンダリングの違いを検出します。比較はツールで自動化できますが、検出された差分は人間がレビューします。ビジュアル回帰テストは、1つのコンポーネントの CSS の変更が別の場所のレイアウトを崩す可能性がある、フロントエンドの比重が大きいアプリケーションで特に役立ちます。
回帰テスト戦略の選び方
ほとんどの CI/CD パイプラインは、これらのいくつかを組み合わせています。ユニット回帰テストは、最初のゲートとしてコミットのたびに実行します。選択的回帰テストは、変更の影響を受ける可能性が最も高いモジュールを対象に、プルリクエストで実行します。完全回帰テストは、main へのマージ前やリリース前に実行します。漸進的回帰テストは、開発者が同じプルリクエストで新機能とあわせて新しいテストを作成する際に、自然に行われます。以下の自動化のセクションでは、これらをパイプラインのステージに組み込む方法を紹介します。
| 種類 | テスト対象 | 使用するタイミング | トレードオフ |
|---|---|---|---|
| 是正的 | 変更されていない製品コードに対する既存のテストケース | 環境や依存関係の変更(製品コードの変更なし) | 高速だが、既存のテストパスしかカバーできない |
| 漸進的 | 新規または更新したテストケースによる、変更後の動作 | 新機能、大規模なリファクタリング | 網羅的だが、新しいテストの作成が必要 |
| 選択的 | 影響分析に基づいて対象を絞ったモジュール | 時間の制約がある実行、独立したモジュールの変更 | 高速だが、影響分析の正確さに依存する |
| 完全 | 回帰テストスイート全体をエンドツーエンドで | 大規模なリリース、基盤部分の変更 | 信頼性は最も高いが、最も遅い |
| ユニット回帰 | 変更後の単一モジュールのユニットテスト | 最初の CI ゲート、コミットのたび | 低コストで高速だが、ユニットレベルの動作に限られる |
| ビジュアル回帰 | レイアウトやスタイルの変化を検出するスクリーンショットの差分 | フロントエンドの変更、CSS のリファクタリング | 機能テストで見逃す問題を検出できるが、人間によるレビューが必要 |
回帰テストはいつ実行すべきか?
既存の動作に影響する可能性のある変更を加えたら、そのたびに回帰テストを実行しましょう。
これには、新機能、バグ修正、リファクタリングしたコード、依存関係の更新(気づかないうちに更新される推移的依存関係を含む)、設定の変更、インフラの移行が含まれます。本番環境へのデプロイ前に、リリース前の回帰テストを実行するのも一般的なプラクティスです。
CI/CD パイプラインにおける実際の答えはもっとシンプルで、「コミットのたび」です。回帰テストスイートが自動化されていて十分に高速なら、夜間や週次の実行にまとめる理由はありません。
コミットのたびに実行すれば、回帰を持ち込んだ開発者がまだそのコードを見ている間に、失敗の結果が返ってきます。24時間待つと、その開発者はすでに別の作業に移っており、半分忘れかけている変更をデバッグするために、頭を切り替えて戻ってこなければなりません。
時間をかけて考えるべきなのは、回帰テストをいつ実行するかではありません。変更のたびにスイートを実行しても苦にならず、現実的に回せるほど高速にするにはどうすればよいか、です。それはパイプライン設計の問題です。
CI/CD パイプラインで回帰テストを自動化する方法
ほとんどのチームには、すでに回帰テストがあります。課題は通常、それらのテストの実行方法にあります。テストはテスト用のディレクトリに置かれたまま、リリース前に誰かがローカルで実行し、スイート全体の実行に時間がかかりすぎるため、ときどきいくつかがスキップされる、といった状況です。
CI/CD で回帰テストを自動化するとは、そうしたテストをパイプラインに移し、誰かがトリガーするのを覚えていなくても、変更のたびに一貫して実行されるようにすることです。そのためには、次の3つが必要です。パイプラインが一部を選んで実行できるように整理されたテストスイート、高速なテストが低速なテストのゲートになるようにステージ分けされたパイプライン、そしてスイートが大きくなっても高速さを保つための並列化の戦略です。
ステップ1:パイプラインで実行できるようにテストスイートを整理する
パイプラインで回帰テストを選択的に実行するには、まずテストにラベルを付けておく必要があります。ほとんどのテストフレームワークは、ランナーがカテゴリでフィルタリングできるように、何らかの形でタグ付けやマーキングをサポートしています。
pytest では、カスタムマーカーでこれを行います。
# conftest.py
import pytest
def pytest_configure(config):
config.addinivalue_line("markers", "unit: unit-level regression tests")
config.addinivalue_line("markers", "integration: integration-level tests")
config.addinivalue_line("markers", "e2e: end-to-end tests")
config.addinivalue_line("markers", "checkout: checkout flow coverage")
config.addinivalue_line("markers", "pricing: pricing calculation coverage")
# test_pricing.py
import pytest
@pytest.mark.unit
@pytest.mark.pricing
def test_discount_code_applies_percentage():
price = calculate_total(subtotal=100.00, discount_code="SAVE20")
assert price == 80.00
@pytest.mark.unit
@pytest.mark.pricing
def test_null_discount_code_returns_full_price():
price = calculate_total(subtotal=100.00, discount_code=None)
assert price == 100.00
1つのテストに複数のマーカーを付けられます。上の価格計算のテストには unit と pricing の両方が付いているため、パイプラインは速度(すべてのユニットテストを実行)でも、ドメイン(すべての価格計算テストを実行)でも、これらを対象にできます。JUnit 5 では同じ目的で @Tag を使います。Jest では --testPathPatterns や、testMatch と testRegex によるカスタム設定でフィルタリングします。
目指すのは、速度(ユニット、インテグレーション、E2E)とドメイン(チェックアウト、価格計算、認証)という2つの軸で整理されたテストスイートです。この構成によって、パイプラインは各ステージで適切なテストを柔軟に選んで実行できるようになります。
ステップ2:パイプラインをステージに分ける
すべてのテストを、すべてのトリガーで実行する必要はありません。コミットのたびに800件のテストを実行するパイプラインは、遅すぎるか、コストがかかりすぎるかのどちらかになります。代わりに、パイプラインを一連のゲートとして構成し、高速なテストを先に、低速なテストを後に実行しましょう。高速なゲートで失敗すればパイプラインはそこで停止するため、どうせ失敗する20分の E2E テストスイートを誰かが待つことはありません。
一般的なパターンは次のとおりです。
- ゲート1: ユニットテストと lint。コミットのたびに実行します。3分以内に完了するのが目安です。
- ゲート2: 結合テストと選択的回帰テスト。プルリクエストで実行します。10分以内に完了するのが目安です。
- ゲート3: 回帰テストスイート全体。main へのマージ時に実行します。並列化して、実時間を短く抑えます。
このゲート構成を設定する、実際に動作する CircleCI の設定は次のとおりです。
# .circleci/config.yml
version: 2.1
jobs:
unit-tests:
docker:
- image: cimg/python:3.12
steps:
- checkout
- run: pip install -r requirements.txt
- run: pytest -m unit --tb=short -q
integration-tests:
docker:
- image: cimg/python:3.12
steps:
- checkout
- run: pip install -r requirements.txt
- run: pytest -m integration --tb=short -q
full-regression:
docker:
- image: cimg/python:3.12
steps:
- checkout
- run: pip install -r requirements.txt
- run: pytest --tb=short -q
workflows:
regression-pipeline:
jobs:
- unit-tests
- integration-tests:
requires:
- unit-tests
- full-regression:
requires:
- integration-tests
filters:
branches:
only: main
ゲートを作るのは requires キーワードです。結合テストは、ユニットテストに合格するまで開始されません。完全回帰テストは、main ブランチでのみ、結合テストに合格した後にだけ実行されます。フィーチャーブランチのプルリクエストでは、パイプラインは最初の2つのゲートを実行して停止します。
ステップ3:スイートが大きくなっても高速さを保つ
回帰テストスイートは時間とともに大きくなります。それは想定内のことです。危険なのは、スイートが遅くなりすぎて、開発者が実行をスキップしたり、失敗を聞き流したりするようになることです。30分かかる回帰テストの実行は、30分のフィードバックの空白を生み、ほとんどの開発者は何もせずにそれを待ってはいません。
解決策は並列実行です。テストスイートを複数のコンテナに分割し、テストを順番にではなく同時に実行します。30分かかるスイートを6つのコンテナに分散させれば、約5分で完了します。
CircleCI は、parallelism キーと circleci tests split コマンドによって、これを標準でサポートしています。
full-regression:
docker:
- image: cimg/python:3.12
parallelism: 6
steps:
- checkout
- run: pip install -r requirements.txt
- run: |
TEST_FILES=$(circleci tests glob "tests/**/*.py" | circleci tests split --split-by=timings)
pytest $TEST_FILES --tb=short -q
--split-by=timings フラグは、過去の実行時間に基づいてテストを分配するため、1つのコンテナに低速なテストが集中することなく、各コンテナがほぼ同時に完了します。CircleCI は、このタイミングデータを過去の実行から自動的に収集します。
スイートが高速に実行されるほど、チームはより頻繁にそれを実行するようになります。そして、実行の頻度が高まるほど、回帰はより早く検出されます。
回帰テストの実例
あるチームが、チェックアウトフローを備えた Web アプリケーションを保守しているとします。バックエンドは Python で、pytest でテストしています。エンドツーエンドテストは Cypress で実行しています。CircleCI 上の CI パイプラインには、前のセクションで紹介したゲート構成を使っています。
ある開発者が価格計算のロジックをリファクタリングし、複数のファイルに重複していた割引コードの処理を1つの apply_discount() 関数にまとめました。変更はきれいに見えます。新しいユニットテストも合格しています。開発者はフィーチャーブランチにプッシュします。
パイプラインがその変更を取り込みます。ユニットテストは約2分で合格し、結合テストは4分で合格します。続いて選択的回帰テストのステージで、@checkout と @pricing のタグが付いた Cypress テストがステージング環境に対して実行され、1つのテストが失敗します。新しい関数が、期限切れの割引コードを古いロジックと同じようには処理していなかったのです。検証エラーを発生させる代わりに、何も知らせないまま割引額0を適用し、顧客に定価を請求していました。
開発者は、プッシュしてから8分以内に、プルリクエスト上でこの失敗を確認します。修正は4行です。再度プッシュするとすべて成功し、main にマージします。完全回帰テストスイートが並列で実行されてすべて合格し、デプロイが行われます。
これがループの全体像です。このパイプラインがなければ簡単に本番環境に到達していたかもしれないバグに対して、プッシュからフィードバックまでわずか8分です。手動の QA プロセスであれば、このバグが表面化するのは数日後で、その間に他の開発者が壊れた価格計算ロジックの上にさらにコードを積み上げているかもしれません。そうなると、修正箇所の切り分けは難しくなり、影響範囲は広がり、4行のパッチの代わりに誰かがインシデントのポストモーテムを書くことになります。
回帰テストは完全に自動化できるのか?
回帰テストのほとんどは自動化できます。ユニット、インテグレーション、エンドツーエンドの回帰テストは、期待される結果がわかっている反復可能なチェックであり、人間よりも機械のほうが速く、一貫して実行できます。人間が毎回同じ方法で実行するテストは、すべて自動化の候補になります。
自動化しにくいのは、判断を必要とする部分です。誰も予想していなかったエッジケースを探る探索的テスト。ボタンはまだ機能しているものの、モバイルではレイアウトのずれによって埋もれてしまっているといった、ユーザビリティの回帰。すべての機能テストのアサーションに合格しているのに、ユーザーには何かおかしく見える、主観的な見た目の品質。ビジュアル回帰ツールはビルド間でスクリーンショットを比較することでそのギャップの一部を埋めますが、それでも差分を検出したら人間のレビューに回します。
AI を活用したテストツールは、そのギャップをさらに縮め始めています。UI が変更されたときにセレクターを自動的に更新する自己修復型のテストロケーターや、過去の失敗データを使って、ある変更に対して回帰を検出する可能性が最も高いテストを優先する予測的テスト選択を提供するプラットフォームも登場しています。こうした機能は成熟しつつありますが、現時点では、よく構成されたテストスイートに代わるものではなく、その上に重ねるレイヤーとして使うのが最も効果的です。
目指すのは100%の自動化ではありません。反復可能な作業を自動化し、人間のテスターが人間にしか評価できないことに時間を使えるようにすることです。ただし、CI/CD のない自動化は、誰かのノート PC 上のスクリプトにすぎません。自動化した回帰テストを CI/CD パイプライン内で実行することで、同じ環境、同じ順序、同じトリガーで、変更のたびに実行される信頼できるものになります。
回帰テストのベストプラクティス
回帰テストの問題のほとんどは、技術的なものではありません。習慣の問題です。スイートはあるのに誰も一貫して実行していない、あるいは実行はしているものの時間がかかりすぎて誰も結果を気にしない、といった状況です。次の6つのプラクティスは、回帰テストスイートが時間とともに価値を失ってしまうパターンに対処するものです。
- スケジュールではなく、コミットのたびに実行する。 夜間の回帰テストの実行では、フィードバックのループが24時間になります。結果が返ってくる頃には、回帰を持ち込んだ開発者はすでに別の作業に移っており、半分忘れかけているコードに頭を切り替えて戻らなければなりません。コミットのたびに実行すれば、修正が簡単なうちにフィードバックを得られます。
- カバレッジの割合ではなく、リスクで優先順位を付ける。 回帰テストのカバレッジ100%はスライド資料の上では聞こえがよいものの、現実的な目標ではありません。回帰テストスイートは、売上に直結する重要な経路、頻繁に変更されるモジュール、過去にバグが多かった領域に集中させましょう。回帰が最も起こりやすく、最もコストが高いのはそうした場所です。コードカバレッジはテストされていない領域を見つけるための診断には役立ちますが、それ自体を目標にするのは適切ではありません。
- スイートを高速に保つ。 回帰テストスイートに15分以上かかると、開発者はそれを避ける方法を見つけ出します。フィードバックループを短く保つための主な手段は、並列実行と選択的なテストの実行の2つです。
- 不安定なテストはバグとして扱う。 コードを何も変更していないのに合格したり失敗したりするテストは、ちょっとした迷惑では済みません。スイート全体への信頼を損ないます。開発者が「あのテストは不安定なだけだから」と失敗を無視するようになると、本物の回帰がすり抜け始めます。不安定なテストは修正するか、削除しましょう。
- テストをコードと一緒にバージョン管理する。 テストは、それがカバーするコードと同じリポジトリに置くべきです。同じプルリクエストで変更し、同じレビュープロセスを経て、どのコミットでも本番コードと同期した状態に保ちます。別のリポジトリや別のツールで管理されたテストは、実際のコードとずれていきがちです。
- 合否だけでなく、実行時間も測定する。 合格まで40分かかる回帰テストスイートは、すべてのテストが成功していても問題です。スイートの実行時間の推移を追跡し、実行ごとの合格率の安定性を監視し、開発者エクスペリエンスにとって最も重要な指標として、フィードバックまでの時間に注目しましょう。
回帰テストのツール
回帰テストに専用のツールは必要ありません。チームがすでに使っているテストフレームワークと CI/CD プラットフォームで実行できます。重要なカテゴリは次のとおりです。
- テストフレームワーク: pytest、Jest、JUnit 5、Cypress、Playwright、Selenium。Web アプリケーションでは、Cypress と Playwright が従来の Selenium ベースの構成にほぼ取って代わっていますが、エンタープライズ環境でのクロスブラウザーのマトリックステストでは、Selenium が今も広く使われています。
- CI/CD プラットフォーム: CircleCI、GitHub Actions、GitLab CI、Jenkins。回帰テストにおける差別化要因は並列実行のサポートで、これによって、大きくなっていくスイートが高速さを保てるかどうかが決まります。
- ビジュアル回帰: Percy、Chromatic、BackstopJS。ビルド間でスクリーンショットを比較し、人間がレビューするための差分を検出します。
- テスト管理: TestRail、Zephyr。スイートの規模が大きくなり、テストケースと結果を長期的に追跡する必要が出てくるほど、重要性が増します。
重要なのはツールよりもプラクティスです。最新のテストフレームワークと、並列実行をサポートする CI/CD プラットフォームを組み合わせれば、どれを使っても、コミットのたびに回帰テストスイートを実行できます。難しいのは、回帰テストを一貫して作成し、高速に保ち、失敗をノイズではなくブロッカーとして扱う規律を身につけることです。
まとめ
回帰テストがあるからこそ、「素早く動く」ことが「何かを壊す」ことにつながらずに済みます。コミットのたびに回帰テストを実行するチームは、変更によって何かが壊れたかどうかを数分以内に把握できます。このフィードバックループこそが、自信を持ってリリースすることと、うまくいくよう祈りながらリリースすることを分けるものです。スイートが完璧である必要はありません。高速で、信頼でき、変更のたびに実行されていることが必要です。それ以外のことは、すべてそこからついてきます。