Go言語にFlakyなテストへのサポートを追加する提案が面白かったので紹介します。
概要
Flakyなテストとは、コードに変更がないにもかかわらずテストが成功したり失敗したりと不安定な実行結果になるテストのことです。 テスト結果は本来なら全て成功ならリリース可能、1つでも失敗すればバグがあるのでリリース不可のようにリリースの可否を判断するための情報です。 そのため、不安定なテストは書かないようにすることが大前提です。 しかし、実際にはflakyであるとわかっていても修正が難しかったり、修正するための時間がないのでそのまま残すという判断をすることもあります。 Flakyなテストは削除するというのも手ではありますが不安定であってもテストが無いよりはマシとして残すこともあると思います。
この提案では、Flakyなテストを扱うための機能を追加するものです。
初めの提案内容は、flakyなテストにマーカーをつけることで、そのテストが失敗した時に指定回数だけテストを再試行するというものでした。
議論の結果、テスト関数およびサブテスト関数を再試行対象としてマークするための t.Retry()
関数と何回目の再試行かを返す t.Retries()
関数を追加するという形に変わりましたが、2023/11/15 にAccepted になったので今後導入予定です。
類似事例
提案および議論の中で既存のプロジェクトでFlakyなテストの再実行をサポートしているBazelとTailscaleのケースに言及しています。
Bazel の flaky
オプション
Google のBazelでは、一般的な定義 | Bazel にあるように flaky
を true
に設定することで提案と同様にテストを再試行する機能を用意していました。
Tailscale で利用されている go test
のラッパーコマンド
Tailscaleでは、go test
のようにテストを実行する独自のラッパーコマンドを利用していました。
当初の提案に最も近い先行例です。
テスト関数の冒頭で、flakytest.Mark(t, "issue へのリンクなどのコメント")
を呼ぶことでそのテスト関数がFlakyであることを示します。
Flakyであるとマークされたテスト関数が失敗していた場合、そのテスト関数が再実行されます。
- ラッパーコマンドの実装: tailscale/cmd/testwrapper/testwrapper.go at main · tailscale/tailscale · GitHub
- GitHub Action からラッパーコマンドを使ってテストを実行している箇所: tailscale/.github/workflows/test.yml at a633a3071158b7e55adf8087d1e68aac986a6bc8 · tailscale/tailscale · GitHub
- 再試行対象となるFlakyなテスト関数にマークをしている箇所: tailscale/tsnet/tsnet_test.go at a633a3071158b7e55adf8087d1e68aac986a6bc8 · tailscale/tailscale · GitHub
gotestyourself/gotestsum
gotestsum は、go test -json
でテストを実行し、その結果を整形、要約して出力するツールです。
gotestsumには flaky なテストに対してマーカーをつける機能はありませんが、--rerun-fails
オプションを設定することで失敗したテストを自動的に再実行して成功すればテスト全体も成功として扱えます。
提案の議論
テストコード変更の有無 1
提案では t.Flaky()
を呼び出したテスト関数だけをテスト失敗時に再試行するFlakyなテストとしてマークします。
開発者が意図的に flaky なテストのマーカーとなる t.Flaky()
を書くことの利点は、アプリケーションにとって想定外な状況でのテスト失敗と想定ずみの原因によるテスト失敗を区別できるようになることです。
僕も同意見です。自動的にテストが再試行されるような形だと導入は簡単ですが、flakyなテストであることがプロジェクトにある程度かかわっていないと認識しにくいためです。 また、明示的にflaky だとテストをマークするのであれば、コードレビュー段階でなぜflakyとしているのかを tailscale/tsnet/tsnet_test.go at a633a3071158b7e55adf8087d1e68aac986a6bc8 · tailscale/tailscale · GitHub のようにIssueへのリンクなどで示すことを求められることが予想できます。 これは、Flakyさの原因調査のヒントを残すために重要です。
これに対して gotestsum のように失敗したテストあれば自動的に再試行し、その結果成功したテストをflakyなテストとして出力した方がいいのではという意見がありました。flaky なテストを見つけることに開発者のリソースを割くべきではないためです。
テストを再試行させるための方法についての議論 2
2つの提案が議論されていました。
- A: Flakyなテスト関数の冒頭で
t.Flaky("reason")
を呼び、Flakyなテストであることを示す。テストが失敗した場合、-maxretry
フラグ (デフォルトは 2) 回まで再試行する。 - B: テストが失敗したときに
t.Fatal()
の代わりにt.Retry("reason")
を呼ぶ。t.Retry()
は、呼び出された時点でそのテストを終了するとともに-maxretry
回までテストを再試行するためにそのテストをキューに追加する。
2つの提案のメリット、デメリットは次の通りです。
- A:
- メリット
- Flakyなテストが何かの理由で安定するようになったことをテストログを追うだけで監視できる。
t.Flaky()
をテスト関数の冒頭に追加するだけでよいので利用が楽
- デメリット
- テスト関数全体のどこで失敗しても再実行されるのでテストのどこがFlakyかを示すのには使いにくい
- メリット
- B
- メリット
t.Retry()
でテストのうち開発者が意図した処理で起こった失敗だけを拾ってテストを再実行できる。そのため、開発者が失敗すると思っていなかった箇所での失敗のときはFlakyだから失敗したのではなく他の要因で純粋にテストが壊れていると判断できる。- テストを再実行するときの挙動を詳細に制御できる
- デメリット
- Flakyなテストの安定性を監視しにくい。ソースコードから
t.Retry()
の呼び出し箇所を探して、そのテストが最近のテストログで呼ばれていないことを確認することになるのでAより手間が増える。 - Flaky さの原因と思われるテストコードにある
t.Fatal()
を探してt.Retry
に置き換えるため導入に手間がかかる。quicktest.Assert()
のようなヘルパーメソッドを利用している場合にヘルパーメソッド内部でt.Fatal()
が呼ばれてしまっているためにt.Retry()
への書き換えができないのではという指摘がありました。しかし、issuecomment-1787669737 でMark()
ヘルパー関数を定義することでt.Flaky()
と同様にテスト関数全体をFlaky なテストとして扱えるようになることが示されました。
- Flakyなテストの安定性を監視しにくい。ソースコードから
- メリット
t.Retry
であれば、Flakyな箇所の特定ができるので不安定さの解消に寄与できることや Mark()
をヘルパー関数として定義することで t.Flaky()
相当のことが実装できることから t.Retry()
の追加することが選ばれました。
再試行回数の設定方法 3
テストの最大再試行数をグローバルに1つだけ設定できるようにするか関数ごとに異なる回数を指定できるようにするかの議論です。
- A:
-maxretry
フラグによりグローバルに適用される1つの最大再試行回数を指定する - B: テストメソッドごとに最大再試行回数を指定できるように何回目の実行かを返す
t.Retries() int
を追加する
Bの t.Retries()
を追加する案は、cmd/go: add per-test timeouts · Issue #48157 · golang/go と関連して次のような利点が述べられていました。
デッドロックが原因のFlakyなテストはタイムアウト時間を長くすることで成功しやすくなります。
このような処理は Exponential Backoff と呼ばれる手順が使えます。
Exponential Backoff とは、通信のリトライなどで用いられるリトライのたびに通信の時間間隔を増やしつつリトライを繰り返す仕組みです。この Exponential Backoff を実装するために t.Retries()
があると簡単に実装できます。
このリトライ処理を書く際に便利である利点があるため t.Retries() int
も採用されました。
提案のまとめ 4
最終的にAccepted となった提案は、(*T) t.Retry()
と (*T) t.Retries() int
の2つを追加する変更です。
細かな仕様の修正がされていますが、詳細は以下の通りです。
(*T) t.Retry()
t.Retry()
は、そのテストが不安定であったために失敗したことをマークします。テスト実行は t.Retry()
が呼び出された後も継続され、そのテストもしくはサブテストの t.Cleanup()
が完了したら、マークされたテストを再実行する。
テストが再試行された場合の出力は、次のようになります。
=== RETRY TestCase
test2json での出力は、次の通りです。
{"Action":"retry","Test":"TestCase"} {"Action":"retry","Test":"TestCase","Output":"=== RETRY TestCase\n"}
(*T) t.Retries() int
t.Retries() int
は、テストが何試行目かを返します。
下記のようなコードを書くことで再試行回数に応じたタイムアウト設定などの処理が書けます。
// maxRetry 回だけテストを再試行する if t.Failed() && t.Retries() < maxRetry { t.Retry() }
http client などでレスポンスを待つタイムアウト時間を伸ばしつつ再実行する場合には次のように書けます。
count := t.Retries() client.SetTimeout(count * time.Second)
感想
Flakyなテストが今までどう扱われてきたのかを提案を読む中で把握することができました。
t.Flaky()
か t.Retry()
かの議論では、不安定なテストを安定したテストに修正するために役立つかという視点で議論が行われている点が面白かったです。
また、リトライ毎に待機時間を延ばす手法があることは知っていましたが、それが Exponential Backoff と呼ばれていることを新しく知ることができました。
できるだけ正確に議論を追うようにはしていますが、間違っている箇所もあるかもしれません。 間違いを見つけた場合はそっと教えてください。
- cmd/go: add support for dealing with flaky tests · Issue #62244 · golang/go · GitHub のあたりの議論↩
- cmd/go: add support for dealing with flaky tests · Issue #62244 · golang/go · GitHub のあたりの議論です↩
- https://github.com/golang/go/issues/62244#issuecomment-1777894842 以降での議論です。↩
- cmd/go: add support for dealing with flaky tests · Issue #62244 · golang/go · GitHub にて最終的な仕様が纏められています。↩