分ければ速くなるが結果が変わることがある
一言でいうと
テストを複数のシャードに分けて同時に実行すると、フィードバックが短くなります。その代わり、分割が決定的でなかったり、シャードの間に隠れた順序依存があったりすると、全体の判定が変わります。分割は、速度を買う代わりに、新しい失敗モードを持ち込む取引です。
なぜ必要なのか
フィードバックが遅くなると、人はパイプラインを見なくなります。結果を待つ代わりに別の作業に行き、戻ってきたときにはすでに別のコミットが積まれていて、何が壊したのかがぼやけます。マーティン・ファウラーは、エクストリームプログラミングの指針を借りて、10分ビルドをほとんどのプロジェクトにとって妥当な目標として示し、ビルド時間から削った1分は、すべての開発者がコミットのたびに節約する1分だと書いています。テストが40分かかると、人は1日に1回しか統合しなくなり、そうなるとCIがもたらす利益そのものが消えます。
分割は、その40分を減らす最も直接的な手段です。サーバーを速くしたりテストを削除したりするのとは違い、テストはそのままにして、実時間だけを減らします。
どう分けるのか
2つの方式があり、どちらも使う場面があります。
ファイル単位で均等に。テストファイルの一覧をソートして、シャード数で割ります。実装が単純で、追加の情報が必要ありません。その代わり、ファイルごとにかかる時間がまちまちなので、最も遅いシャードが全体の時間を決めます。ファイルが1つで10分かかるなら、シャードをいくら増やしても10分を下回りません。
記録された所要時間でバランスよく。前回の実行で各テストがどれだけかかったかを保存しておき、その値を基準に、合計が近くなるように分けます。シャードがほぼ同時に終わるので、むだが少なくなります。その代わり、所要時間の記録そのものが入力になるので、そのファイルをどこに置いてどう更新するかを決める必要があります。記録のない新しいテストには既定値を与え、次の実行で埋めます。
# 균등 분할: 느린 파일 하나가 전체를 붙잡는다
조각0 [==== ] 2분
조각1 [================== ] 9분 <- 전체 9분
조각2 [===== ] 3분
# 시간 기반 분할: 합이 비슷해진다
조각0 [======== ] 5분
조각1 [======== ] 5분 <- 전체 5분
조각2 [======= ] 4분
決定的でなければならない分割
ここが核心です。同じコミットなら、いつ実行しても同じ振り分けが出なければなりません。振り分けが実行ごとに変わると、3つのものが崩れます。
1つ目は、失敗を再現できないことです。「シャード2で失敗」という記録が、次の実行では別のテストの集まりを指します。2つ目は、リトライが意味を失うことです。シャード1つだけを再実行しようとしても、そのシャードが同じテストを含むという保証がありません。3つ目は、順序依存の問題が不規則に現れて、「ときどき失敗するテスト」と誤解されることです。
そのため、振り分けは、実行時刻や乱数ではなく、コミットから決まる値だけで計算します。一覧をソートし、所要時間の記録も、リポジトリにコミットされたファイルから読みます。ハッシュでシャードを決めるときも、テスト名のような安定した文字列を使います。
分割したときに表に出る順序依存
分割が最もよく暴き出すのは、単独で実行すると通り、一緒に実行すると失敗するテストと、その逆です。原因はたいてい共有状態です。前のテストが作っておいたデータベースの行、グローバル変数、一時ファイル、登録しておいたグローバルな設定に、後ろのテストが頼っているのです。
このようなテストは、もともと壊れていたのに、たまたまいつも同じ順序で実行されていたので隠れていました。分割は、その順序を揺さぶって問題を表に出しただけです。そのため、分割を導入した直後の失敗は、分割のバグではなく、たいていすでにあった欠陥だと考えて始めるのが正しいです。確認する方法も簡単です。失敗したテストを単独で実行してみます。単独で通るなら、依存している状態があるということです。
マージしてはじめて出る判定
シャードごとの結果は、それぞれ部分的な情報です。シャードがすべて終わり、結果をマージしたあとでようやく判定が出ます。そのため、パイプラインには、シャードを待ってから結果ファイルを集めて数えるステップが必ず必要です。各シャードが自分のレポートをアーティファクトとして上げ、マージするステップがそれをダウンロードして合算します。GitLabのドキュメントがアーティファクトを「ステップの間で中間結果を渡すために使う」と書いているのが、まさにこの用途です。
集計のステップが確認すべきものは、合格数だけではありません。シャードが1つも欠けていないか、全体のテスト数が期待した値かを一緒に見ます。シャードが1つまるごと死んだのに、残りがすべて緑のとき、集計をいい加減にするパイプラインは緑と判定します。テストが0個実行されて成功したシャードも同じです。
そして、判定の不変条件が1つあります。シャード数を3から5に変えても、全体の判定は同じでなければなりません。これが、分割が正しいかを検査する最もよい方法です。同じコミットを、シャード数だけ変えて2回実行し、全体のテスト数と失敗の一覧を照合します。
現場での姿
- 速いテストを前のシャードに集めておくと、失敗が数秒でわかります。ユニットテスト全体が統合テスト1つより早く終わることが多いので、配置の順序を変えるだけで、体感のフィードバックが大きく減ります。
- シャード数を増やしたのに、全体の時間が変わりません。たいてい、最も遅いファイル1つが壁になっているか、各シャードが毎回依存関係を新しく取得するので、準備時間が実行時間を上回っています。
- 「シャード4だけがいつも失敗する」という言葉が出たら、振り分けが決定的かどうかをまず確認します。シャード番号が毎回別のテストを指しているなら、その文そのものが成り立ちません。
参考
- 継続的インテグレーション(高速なビルド): https://martinfowler.com/articles/continuousIntegration.html
- GitHub Actionsのマトリックス: https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs
- GitLabのジョブのアーティファクト: https://docs.gitlab.com/ci/jobs/job_artifacts/
次のラボですること
テストランナーと分割ツールを、シェルとpython3で自作します。まず、ファイル単位の均等な分割で分けて、シャードごとの時間がどれだけ開くかを測り、所要時間の記録を読んで、合計が近くなるように分け直します。同じコミットで2回分割して、振り分けが1文字まで同じかを照合し、わざと乱数を混ぜた分割ツールと比べて、何が崩れるのかを見ます。続いて、共有状態に頼るテストを入れて順序依存を再現し、シャードごとのレポートを集計して、シャードが1つ欠けたときに集計のステップが見つけられるかまで確認します。最後は、シャード数を変えても全体の判定が同じかを検査するステップです。