TT Lab
はじめる
学ぶ 学習パス コース

CI/CDパイプライン

シャード数を増やしたら、落ちていたテストが通ってしまった

TT Labで続きを見る

目標

テストスイートを決定的に分割し、シャードごとの結果を機械が読める形式で残し、再びマージして1つの判定を作ります。シャード数が判定を変えないように守る検査と、記録された所要時間で最も重いシャードを軽くする振り分けまで、自分の手で作ります。

なぜ重要なのか

テストが長くなると、人は結果を待たなくなります。分けて実行することは、テストを削除せずに実時間を減らす最も直接的な手段ですが、分割は新しい失敗モードを一緒に持ち込みます。振り分けが実行ごとに変わると、「シャード2で失敗」という記録が次の実行で別のテストを指すようになり、再現が不可能になります。シャードごとの結果をいい加減にマージすると、シャードが1つまるごと死んだのに、全体が緑に見えます。そして、いつも同じ順序で実行されて隠れていた順序依存が、シャードが分かれた瞬間に表に出ます。そのため、分割を導入したパイプラインには、不変条件が1つ必要です。シャード数を変えても、全体のケース数と全体の判定は同じでなければならない。このラボは、その不変条件が破れる場面を自分で作り、原因を見つけて直したうえで、再び破れないように守るゲートまで作ります。

ステップ

  1. テスト対象のライブラリ/root/shard/tests/sampleapp.py(モジュールレベルの設定辞書SETTINGSを必ず置いてください)と、同じディレクトリにtest_*.pyファイルを4つ以上、ケースを20個以上作ってください。そのうち1つは/root/shard/tests/test_slowio.pyとし、time.sleepで0.1秒以上かかるケースを5つ置きます(あとで、シャードの間の時間の差を作ります)。このステップのすべてのテストは、通る必要があります。そして、/root/shard/list_tests.sh [시험디렉터리](プレースホルダーはテストディレクトリです)を作って、テスト名を모듈.클래스.메서드(プレースホルダーは、モジュール、クラス、メソッドです)の形でソートして1行に1つずつ出力させてください(既定値は/root/shard/tests)。最後に、全体を一度に実行した出力を/root/shard/baseline.txtに保存します(Ran N testsとOKが入っている必要があります)。
  2. /root/shard/shard.sh <조각번호> <조각수>(プレースホルダーは、シャード番号とシャード数です)を作ってください。標準入力で受け取ったテスト名の一覧から、そのシャードに属する名前だけをそのまま出力します。シャードは、名前のsha256の先頭8桁(16進数)をシャード数で割った余りで決めます。シャード番号は0から数えます。3つのことが守られなければなりません。1つ目は、シャードが互いに重ならず、すべて合わせると入力全体になること。2つ目は、同じ入力なら、2回実行しても1文字まで同じであること。3つ目は、一覧からテストを1つ除いても、残りの名前のシャードが変わらないこと。確認した結果を/root/shard/deterministic.txtに残してください。2回実行した出力をdiffした結果が空であるという行1つと、3シャードに分けた個数をshard0=<수> shard1=<수> shard2=<수>(プレースホルダーは件数です)の形で書いた行1つです。
  3. /root/shard/runner.py <시험디렉터리> <시험목록파일> <출력XML> <조각이름>(プレースホルダーは、テストディレクトリ、テスト一覧ファイル、出力XML、シャード名です)を作ってください。一覧のテストを1つのプロセスで、一覧の順序どおりに実行して、JUnit XMLを書きます。ルート要素はtestsuite(属性name・tests・failures・errors・time)で、テストごとにtestcase(属性classname・name・time)を置き、失敗はfailure、エラーはerrorの子要素として残します。そして、/root/shard/run_shard.sh <조각번호> <조각수> [리포트디렉터리](プレースホルダーは、シャード番号、シャード数、レポートディレクトリです)を作って、一覧を作り、分割し、実行して、<리포트디렉터리>/shard-<조각번호>.xmlを残すようにしてください(レポートディレクトリの既定値は/root/shard/reports)。bash /root/shard/run_shard.sh 0 3、1 3、2 3で、3つのシャードのレポートを作っておいてください。
  4. /root/shard/merge.sh <리포트디렉터리> <기대조각수>(プレースホルダーは、レポートディレクトリと期待するシャード数です)を作ってください。そのディレクトリの*.xmlをすべて読んで、1行だけを出力します: PASS cases=<수> failures=<수> errors=<수> shards=<수>(プレースホルダーは件数です)、または同じ形のFAIL ...。次の4つのうち1つでも当てはまればFAILで、終了コードは1です。レポートファイルの数が期待するシャード数と違っていること、読み取れないXMLがあること、ケースが1つもないこと、失敗やエラーが1つでもあることです。個数は、ヘッダーの属性値ではなく、実際のtestcase・failure・errorの要素を数えて求めてください。ステップ3で作った/root/shard/reportsでmerge.sh /root/shard/reports 3を実行して、その出力を/root/shard/verdict.txtに保存してください。
  5. /root/shard/total_of.sh <조각수> <출력디렉터리>(プレースホルダーは、シャード数と出力ディレクトリです)を作ってください。そのシャード数ですべてのシャードを実行して、レポートを出力ディレクトリに残し、最後にmerge.shの1行をそのまま出力します(終了コードもそのまま渡します)。出力ディレクトリに残るレポートは、ちょうどシャード数と同じ数でなければならず、以前の実行のレポートが混ざってはいけません。total_of.sh 2 /root/shard/out2とtotal_of.sh 3 /root/shard/out3を実行して、2行を/root/shard/invariant.txtに保存してください。shards=を除いた残り(cases=・failures=・errors=)が、互いに同じでなければなりません。
  6. 共有状態に頼るテストを、わざと入れます。/root/shard/tests/test_config.pyには、sampleapp.SETTINGSの値を変えてテストするものの、元に戻さないケースを2つ置き、/root/shard/tests/test_profile.pyには、既定の設定のままであることを期待するケースを2つ置きます。全体を1つのプロセスで実行すると、後ろのテストが失敗し、そのテストだけを単独で実行すると通ることを確認してください。続いて、total_of.shをシャード数2・3・4・5で実行して、シャード数によって全体の判定が分かれることを確認し、状態を汚染したテストのidを1行目に、影響を受けたテストのidを2行目に、/root/shard/order-dep.txtに書いてください。最後に、2つのテストがどちらもsampleapp.SETTINGSを使い続けながら、どんな順序で実行しても通るように直してください(テストを削除したり、共有設定を使わないことで回避したりしないでください)。
  7. /root/shard/timings.sh <리포트디렉터리>(プレースホルダーはレポートディレクトリです)を作って、レポートのtestcaseから<시험id> <초>(プレースホルダーは、テストidと秒です)を名前順に出力させてください。これで、今のテストすべての記録を/root/shard/timings.txtに作ります(1つのシャードですべてを実行したレポートを使えば済みます)。続いて/root/shard/balance.sh <시간기록파일> <조각수>(プレースホルダーは、時間記録ファイルとシャード数です)を作ってください。長いものから順に、そのときいちばん軽いシャードに入れる方式(LPT)で分けて、<조각번호> <시험id>(プレースホルダーは、シャード番号とテストidです)を1行ずつ出力します(すべてのテストが、ちょうど1回ずつ出てくる必要があります)。最後に、3シャードを基準に、ステップ2のハッシュによる振り分けと、このバランスのよい振り分けの、最も重いシャードの合計を測って、/root/shard/balance.txtに2行で書いてください: equal <초>とbalanced <초>(プレースホルダーは秒です)。
  8. /root/shard/order_fast_first.sh <시간기록파일> <시험목록파일>(プレースホルダーは、時間記録ファイルとテスト一覧ファイルです)を作ってください。一覧を、記録された所要時間の昇順(同じなら名前順)で出力し、記録のないテストは0秒とみなして先頭に置きます。続いて/root/shard/gate.sh <조각수> [시험디렉터리](プレースホルダーは、シャード数とテストディレクトリです)を作ってください(テストディレクトリの既定値は/root/shard/tests)。一覧を作り、シャードに分け、シャードごとに速いものから実行してからマージして、merge.shの1行と終了コードをそのまま出力します。レポートは自分の一時ディレクトリにだけ残し、/root/shardにXMLを残しません。bash /root/shard/gate.sh 3の出力を/root/shard/gate.txtに保存してください。

参考

テストスイートを作り、一度にすべて実行して基準を決める

テスト対象のライブラリ/root/shard/tests/sampleapp.py(モジュールレベルの設定辞書SETTINGSを必ず置いてください)と、同じディレクトリにtest_*.pyファイルを4つ以上、ケースを20個以上作ってください。そのうち1つは/root/shard/tests/test_slowio.pyとし、time.sleepで0.1秒以上かかるケースを5つ置きます(あとで、シャードの間の時間の差を作ります)。このステップのすべてのテストは、通る必要があります。そして、/root/shard/list_tests.sh [시험디렉터리](プレースホルダーはテストディレクトリです)を作って、テスト名を모듈.클래스.메서드(プレースホルダーは、モジュール、クラス、メソッドです)の形でソートして1行に1つずつ出力させてください(既定値は/root/shard/tests)。最後に、全体を一度に実行した出力を/root/shard/baseline.txtに保存します(Ran N testsとOKが入っている必要があります)。

unittestのdefaultTestLoader.discover(디렉터리, top_level_dir=디렉터리)がテストスイートを返し(プレースホルダーはディレクトリです)、各テストオブジェクトのid()が、まさに모듈.클래스.메서드という文字列です(プレースホルダーは、モジュール、クラス、メソッドです)。一覧は、あとのステップすべての入力なので、重複なしでソートして出力してください。全体の実行は、cd tests && python3 -m unittest $(목록)でできます(プレースホルダーは一覧です)。

同じ名前なら、いつも同じシャードへ行くようにする

/root/shard/shard.sh <조각번호> <조각수>(プレースホルダーは、シャード番号とシャード数です)を作ってください。標準入力で受け取ったテスト名の一覧から、そのシャードに属する名前だけをそのまま出力します。シャードは、名前のsha256の先頭8桁(16進数)をシャード数で割った余りで決めます。シャード番号は0から数えます。3つのことが守られなければなりません。1つ目は、シャードが互いに重ならず、すべて合わせると入力全体になること。2つ目は、同じ入力なら、2回実行しても1文字まで同じであること。3つ目は、一覧からテストを1つ除いても、残りの名前のシャードが変わらないこと。確認した結果を/root/shard/deterministic.txtに残してください。2回実行した出力をdiffした結果が空であるという行1つと、3シャードに分けた個数をshard0=<수> shard1=<수> shard2=<수>(プレースホルダーは件数です)の形で書いた行1つです。

printf '%s' "$name" | sha256sum | cut -c1-8で16進数8桁が得られ、bashの算術で$(( 16#$h % n ))として余りを求めます。行番号や配列のインデックスで分けると、3つ目が壊れます。前のほうで1つ抜けるだけで、後ろがすべてずれるからです。readは、IFS= read -rで受け取って、空白を保存してください。

シャードを実行して、機械が読める結果として残す

/root/shard/runner.py <시험디렉터리> <시험목록파일> <출력XML> <조각이름>(プレースホルダーは、テストディレクトリ、テスト一覧ファイル、出力XML、シャード名です)を作ってください。一覧のテストを1つのプロセスで、一覧の順序どおりに実行して、JUnit XMLを書きます。ルート要素はtestsuite(属性name・tests・failures・errors・time)で、テストごとにtestcase(属性classname・name・time)を置き、失敗はfailure、エラーはerrorの子要素として残します。そして、/root/shard/run_shard.sh <조각번호> <조각수> [리포트디렉터리](プレースホルダーは、シャード番号、シャード数、レポートディレクトリです)を作って、一覧を作り、分割し、実行して、<리포트디렉터리>/shard-<조각번호>.xmlを残すようにしてください(レポートディレクトリの既定値は/root/shard/reports)。bash /root/shard/run_shard.sh 0 3、1 3、2 3で、3つのシャードのレポートを作っておいてください。

unittest.TestResult()に、loadTestsFromName(id)で作ったスイートをrun()すると、ケース1つの結果を個別に受け取れ、その前後でtime.time()を測れば、ケースごとの所要時間が出ます。XMLはxml.etree.ElementTreeで作ります。テストディレクトリをsys.pathに入れないと、모듈.클래스.메서드という名前が解決されません(プレースホルダーは、モジュール、クラス、メソッドです)。1つのプロセスで続けて実行することが重要です。ステップ6で、その違いが表に出ます。

マージするまでは、判定がない

/root/shard/merge.sh <리포트디렉터리> <기대조각수>(プレースホルダーは、レポートディレクトリと期待するシャード数です)を作ってください。そのディレクトリの*.xmlをすべて読んで、1行だけを出力します: PASS cases=<수> failures=<수> errors=<수> shards=<수>(プレースホルダーは件数です)、または同じ形のFAIL ...。次の4つのうち1つでも当てはまればFAILで、終了コードは1です。レポートファイルの数が期待するシャード数と違っていること、読み取れないXMLがあること、ケースが1つもないこと、失敗やエラーが1つでもあることです。個数は、ヘッダーの属性値ではなく、実際のtestcase・failure・errorの要素を数えて求めてください。ステップ3で作った/root/shard/reportsでmerge.sh /root/shard/reports 3を実行して、その出力を/root/shard/verdict.txtに保存してください。

シャードが1つまるごと死ぬと、レポートファイルそのものがありません。そのため、「いくつ期待したか」を引数として受け取る必要があります。ヘッダーのfailures属性は、レポートを書いた側が書いた値なので、それだけを信じると、ヘッダーだけを0に書き換えたレポートが通ってしまいます。ET.parse(f).getroot().iter("testcase")で、要素を直接走査してください。

シャード数を変えても、全体の判定は同じでなければならない

/root/shard/total_of.sh <조각수> <출력디렉터리>(プレースホルダーは、シャード数と出力ディレクトリです)を作ってください。そのシャード数ですべてのシャードを実行して、レポートを出力ディレクトリに残し、最後にmerge.shの1行をそのまま出力します(終了コードもそのまま渡します)。出力ディレクトリに残るレポートは、ちょうどシャード数と同じ数でなければならず、以前の実行のレポートが混ざってはいけません。total_of.sh 2 /root/shard/out2とtotal_of.sh 3 /root/shard/out3を実行して、2行を/root/shard/invariant.txtに保存してください。shards=を除いた残り(cases=・failures=・errors=)が、互いに同じでなければなりません。

シャード数は、仕事を分ける方法にすぎないので、全体のケース数と判定に影響を与えてはいけません。2行が違っているなら、振り分けがテストを取りこぼしているか、二重に数えています。前回の実行のXMLが残っていると、個数がふくらむので、開始時に出力ディレクトリを空にしてください。

単独で実行すると通っていたテストが、一緒に実行すると壊れる

共有状態に頼るテストを、わざと入れます。/root/shard/tests/test_config.pyには、sampleapp.SETTINGSの値を変えてテストするものの、元に戻さないケースを2つ置き、/root/shard/tests/test_profile.pyには、既定の設定のままであることを期待するケースを2つ置きます。全体を1つのプロセスで実行すると、後ろのテストが失敗し、そのテストだけを単独で実行すると通ることを確認してください。続いて、total_of.shをシャード数2・3・4・5で実行して、シャード数によって全体の判定が分かれることを確認し、状態を汚染したテストのidを1行目に、影響を受けたテストのidを2行目に、/root/shard/order-dep.txtに書いてください。最後に、2つのテストがどちらもsampleapp.SETTINGSを使い続けながら、どんな順序で実行しても通るように直してください(テストを削除したり、共有設定を使わないことで回避したりしないでください)。

1つのプロセスで続けて実行すると、モジュールレベルの変数は、前のテストが変更したまま残ります。直す場所は、2か所のうち1つです。変更した側がtearDownで元に戻すか、期待する側がsetUpで自分が使う値を設定するか。test_render.pyが、すでに正しい方法を示しています。ファイル名のアルファベット順が実行順序であることも、確認してみてください。

記録された時間で分け直したら、最も重いシャードが軽くなった

/root/shard/timings.sh <리포트디렉터리>(プレースホルダーはレポートディレクトリです)を作って、レポートのtestcaseから<시험id> <초>(プレースホルダーは、テストidと秒です)を名前順に出力させてください。これで、今のテストすべての記録を/root/shard/timings.txtに作ります(1つのシャードですべてを実行したレポートを使えば済みます)。続いて/root/shard/balance.sh <시간기록파일> <조각수>(プレースホルダーは、時間記録ファイルとシャード数です)を作ってください。長いものから順に、そのときいちばん軽いシャードに入れる方式(LPT)で分けて、<조각번호> <시험id>(プレースホルダーは、シャード番号とテストidです)を1行ずつ出力します(すべてのテストが、ちょうど1回ずつ出てくる必要があります)。最後に、3シャードを基準に、ステップ2のハッシュによる振り分けと、このバランスのよい振り分けの、最も重いシャードの合計を測って、/root/shard/balance.txtに2行で書いてください: equal <초>とbalanced <초>(プレースホルダーは秒です)。

時間の記録は、次の実行の入力になるアーティファクトです。レポートにすでに入っているので、新しく測らず、取り出して使ってください。LPTは、一覧を所要時間の降順にソートしてから、それぞれを現在の合計が最も小さいシャードに入れる方法です。このPodにはbcがないので、合計はawkかpython3で求めてください。

分割して、実行して、マージする作業を、1つのコマンドにまとめる

/root/shard/order_fast_first.sh <시간기록파일> <시험목록파일>(プレースホルダーは、時間記録ファイルとテスト一覧ファイルです)を作ってください。一覧を、記録された所要時間の昇順(同じなら名前順)で出力し、記録のないテストは0秒とみなして先頭に置きます。続いて/root/shard/gate.sh <조각수> [시험디렉터리](プレースホルダーは、シャード数とテストディレクトリです)を作ってください(テストディレクトリの既定値は/root/shard/tests)。一覧を作り、シャードに分け、シャードごとに速いものから実行してからマージして、merge.shの1行と終了コードをそのまま出力します。レポートは自分の一時ディレクトリにだけ残し、/root/shardにXMLを残しません。bash /root/shard/gate.sh 3の出力を/root/shard/gate.txtに保存してください。

前のステップで作ったものをつなげる問題です。list_tests.sh・shard.sh・order_fast_first.sh・runner.py・merge.shです。一時ディレクトリはmktemp -dで作り、trap ... EXITで削除します。テストディレクトリを引数として受け取る理由は、テストをわざと壊したコピーで、ゲートが本当に赤信号を出すかを確認するためです。