ソフトウェア開発においては、最初は失敗していた数多くのテストが機能の実装が進むにつれ、失敗数を減らしていきます。本ブログでは、失敗をできるだけ早く開発者にフィードバックし、実装や修正を高速化し、品質を高めるための CircleCI の機能 Fail Fast をご紹介していきます。

これまでのテスト時間短縮アプローチ

ソフトウェア開発の際、ビルドを自動化する、テストを自動化するといった取り組み(継続的インテグレーション: CI)には、すでに多くの組織やプロジェクトで取り組みが進んでいます。

テストを自動化することで、人手で(手動で)テストするのと異なり、テストの数が増えても、テストで疲れたり、その結果テスト漏れが発生するということはありません。ただし、新たなコードが追加されたり、既存のコードが修正され、それに合わせてテストが追加された結果、テストの完了に要する時間が長くなる、という新たな問題に向き合うことになります。

テスト時間が長くなることの問題とは何でしょう? 以前は成功していたテストが直近のコードの修正や変更により通らなくなったのであれば、その原因の多くは、直近の修正や変更の中にあると考えるのが自然です。

修正や変更の直後であれば、作業内容も記憶の中に新しいままです。また、実際には自分の修正、変更したコードが直接の原因ではなく、その呼び出し元や呼び出し先など、何らかの依存関係のあるコードとの間での統合(インテグレーション)に問題があるとしても、見るべき範囲を小さくすることができます。

テスト高速化には2つのアプローチ、

  1. テストを実行する環境のスペックをより高くする
  2. テストを並列で実行する

があり、とりわけテストが自動化されているのであれば、並列実行により実行時間の大幅な短縮が期待できます。具体的な手法や効果については、ブログ「テスト分割と並列実行 - 実行時間を短縮する」で説明していますので、ぜひご覧ください。

これまでのアプローチでは解決しきれない問題

さて、前述のブログでは、テストの自動分割と並列実行の効果として、実行時間の異なる10個のテストを1並列、3並列、および5並列で実行した実例を図示してご紹介しました。

テストの図

さて、この図を見て、テストの自動分割や並列実行に取り組んだところ、テスト時間は確かにある程度短縮されるものの、実は、無駄なテスト完了待ち時間が発生していることに気が付きます。

テストがfailした時の図

すべてのテストに成功するのであれば、1並列で実行するより、3並列で実行したほうが3分の1程度の時間でテストが完了することが期待されます。一方、3並列で実行した場合、3つのうちの1つのテスト実行環境でテストが失敗しても、のこりの2つのテスト実行環境は(別実行環境でテストに失敗していることを知らないで実行しつづけるのであれば)、上の図のように1並列での実行時よりもテスト失敗通知を受けるまでの時間がかかる、ということがありえます。

テスト実行時におけるより速いテスト打ち切り(Fail Fast)

テスト実行環境は単一(1並列)であれ、複数並列であれ、どこかでテストが失敗したのであれば、自環境でのテストを打ち切り、また他環境でも以降のテストを継続しない、そのような振る舞いを提供するのが、Fail Fast です。

実際には、テストの実行ごとに他環境に対して「打ち切り」の割り込みをかけるのではなく、一連のテストをバッチ分割した上で、テスト実行環境にプラグインを(自動で)インストールし、分割されたバッチ1,バッチ2…の順にテストを実行し、実行が完了する度にプラグインがプラグインマネージャーにステータスを通知する(合わせて次のバッチを実行してよいか確認する)といった仕組みを取っています。

Fail Fast フロー図

話を分かりやすくするために、テストファイル no01_test.py ~ no20_test.py の20個のテストを実行する(うち、no11_test.py は失敗する)ような例を考えてみましょう。

並列度1,バッチ数3で実行する場合、

  • バッチ1では no01, no04, no07, no10, no13, no16, no 19 を実行し、ステータスを通知し、バッチ2の実行に移る

  • バッチ2では no02, no05, no08, no11(ここで失敗), no14, no17, no20 まで途中に失敗をはさんだままやり切り、失敗した旨のステータスを通知する。テスト実行に失敗した環境(自分自身)があるので、バッチ3は実行しないで、処理を打ち切る

といったようにテストを実行し、実行を打ち切ります(下スライドの青色の番号が成功したテスト、赤色の番号が失敗したテスト、黒色の番号は未実行のテスト)。

テストをバッチ分割して実行

1並列ではなく3並列の場合も(説明が複雑になってしまうのですが)考え方は同様です。

テストをバッチ分割して並列実行

ここに挙げたような4並列、バッチ数3でFail Fastなテストを実行する際のコンフィグは次の通りです。

jobs:
  build-and-test:
    parallelism: 4
    docker:
      - image: cimg/python:3.11.3
    resource_class: small  
    steps:
      - checkout
      - python/install-packages:
          pkg-manager: pip
      - run:
          name: Run tests (in parallel)
          command: |
            set -e
            mkdir -p test-results
            pytest --collect-only -q | grep -e "\.py" | \
            circleci tests run \
            --command="xargs pytest --verbose
--junitxml=test-results/junit.xml --" \
            --fail-fast --batch-count=3 --verbose
--test-results-path="test-results"
      - store_test_results:
          path: test-results
      - store_artifacts:
          path: test-results
          destination: test-results

注意が必要なのは、並列であることと、バッチごとに実行することは(概念としては類似していますが)別のレベルの話であるということです。上のスライドでは並列であることを実行環境であるコンテナ1~コンテナ4で示しています。各コンテナでバッチ1,バッチ2…が終了する度にプラグインマネージャーとやりとりを行っています。全部の実行環境で同じ番号のバッチが終了するまで待ち合わせをするわけではないため、上の例ではコンテナ2ではバッチ3までテストが完了しているということもあり得ます。

さいごに

Fail Fast機能のドキュメントのユースケースにも書かれている通り、結果が不安定なテスト(Flaky Test)の実行や、時間のかかるテストなどを実行する際には、Fail Fast機能で失敗に早く気づき、早く対応することが非常に有効です。フィーチャーブランチでの開発の際などテスト失敗頻度が高いことが想定される場合に適用するといった使い分けも有効でしょう。

今回は Pythonスクリプトを例としてとりあげましたが、JavaScript/TypeScript といった lightweight な言語だけでなく、Java や C# のようなコンパイルが必要な言語で書かれたアプリケーションやサービスであってもメリットが期待できます。

大規模テストに、早く賢く失敗に対応する上でご活用ください。

関連ガイドと記事