Goのテスト安定性向上のためにFlakyなテストを再試行する機能を導入する提案

Go言語にFlakyなテストへのサポートを追加する提案が面白かったので紹介します。

概要

Flakyなテストとは、コードに変更がないにもかかわらずテストが成功したり失敗したりと不安定な実行結果になるテストのことです。 テスト結果は本来なら全て成功ならリリース可能、1つでも失敗すればバグがあるのでリリース不可のようにリリースの可否を判断するための情報です。 そのため、不安定なテストは書かないようにすることが大前提です。 しかし、実際にはflakyであるとわかっていても修正が難しかったり、修正するための時間がないのでそのまま残すという判断をすることもあります。 Flakyなテストは削除するというのも手ではありますが不安定であってもテストが無いよりはマシとして残すこともあると思います。

github.com

この提案では、Flakyなテストを扱うための機能を追加するものです。 初めの提案内容は、flakyなテストにマーカーをつけることで、そのテストが失敗した時に指定回数だけテストを再試行するというものでした。 議論の結果、テスト関数およびサブテスト関数を再試行対象としてマークするための t.Retry() 関数と何回目の再試行かを返す t.Retries() 関数を追加するという形に変わりましたが、2023/11/15 にAccepted になったので今後導入予定です。

類似事例

提案および議論の中で既存のプロジェクトでFlakyなテストの再実行をサポートしているBazelとTailscaleのケースに言及しています。

Bazel の flaky オプション

Google のBazelでは、一般的な定義  |  Bazel にあるように flakytrue に設定することで提案と同様にテストを再試行する機能を用意していました。

Tailscale で利用されている go test のラッパーコマンド

Tailscaleでは、go test のようにテストを実行する独自のラッパーコマンドを利用していました。 当初の提案に最も近い先行例です。 テスト関数の冒頭で、flakytest.Mark(t, "issue へのリンクなどのコメント") を呼ぶことでそのテスト関数がFlakyであることを示します。 Flakyであるとマークされたテスト関数が失敗していた場合、そのテスト関数が再実行されます。

gotestyourself/gotestsum

gotestsum は、go test -json でテストを実行し、その結果を整形、要約して出力するツールです。 gotestsumには flaky なテストに対してマーカーをつける機能はありませんが、--rerun-fails オプションを設定することで失敗したテストを自動的に再実行して成功すればテスト全体も成功として扱えます。

GitHub - gotestyourself/gotestsum: 'go test' runner with output optimized for humans, JUnit XML for CI integration, and a summary of the test results.

提案の議論

テストコード変更の有無 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-1787669737Mark()ヘルパー関数を定義することで t.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 と呼ばれていることを新しく知ることができました。

できるだけ正確に議論を追うようにはしていますが、間違っている箇所もあるかもしれません。 間違いを見つけた場合はそっと教えてください。