本記事はGo Advent Calendar 2022 & カンム Advent Calendar 2022の記事になります。
何を作ったか
以下のデモのように、コマンドを1回実行するだけで対象リポジトリ内のテストを最大限に並行実行に対応させるツールを作りました。
なぜ作ったか
Goのtesting
パッケージを使ったテストコードの実行は、go test
コマンドの-p
オプションで明示的に並列実行の制限をしない限り、複数のパッケージが並行にテストを実行するようになっています。
開発者が意識しなくともテストの実行が最適化されるようになっており、このような仕組みはJavaScriptのテストフレームワークJestでも取り入れられていますね。
これだけでも大幅に最適化されていますが、個々のパッケージ内のテスト群を最大限に並行実行させるためには、各テストでtesting.T.Parallel()
メソッド(以下Parallel()
)を呼び出す必要があります。
ユニットテストの数が少ないと、Parallel()
を各テストに手作業で埋め込むこともできますが、ある程度テスト数が多くなっていると骨が折れます。また、テスト関数ごとの環境変数を設定するSetenv()
を呼び出していると、同じテスト関数内ではParallel()
が呼び出せないなどのルールがあり、スクリプトで対処しようにも一筋縄ではいきません。
そこで、各テストコードに自動でParallel()
を埋め込み、コマンドを1回実行するだけで対象リポジトリ内のテストを最大限に並行実行に対応させるツールを作りました。
tparagenのリポジトリ
使い方
Goをインストールしていれば以下のコマンドで、そうでなければリリースページからバイナリをダウンロードしてください。
go install github.com/sho-hata/tparagen/cmd/tparagen@latest
ダウンロードしたら対象のディレクトリに移動して、tparagen
コマンドを実行します。これで、対象のディレクトリ配下のテストが並行実行に対応します。
$ tparagen
どうやって動いているのか
仕組みはシンプルです。
- 対象のソースコードに対し静的解析を走らせて、並行実行に対応していないテスト関数のAST(構文解析木)上の位置を取得
- 1で取得した位置に
testing.T.Parallel()
の式構文を埋め込む
というパワープレイでやっています。
並行実行に対応していないテスト関数を見つける処理を書くために、tparallelというlinterのソースコードを参考にしました。これは並行実行に対応していないテスト関数を見つける静的解析ツールです。そのため、今回作るツールの条件と一致しており、AST上を走査する処理をほぼ流用できました。
実装済みの機能
-
-i
、-ignore
オプションで特定のディレクトリを除外 -
Setenv()
のサポート - Table Driven Testでのテスト並行実行時、一部のテストケースしか評価されない問題のサポート
-
nolint:paralleltest, tparallel
コメントのサポート
以下はサポートしていません。
- テスト関数内で呼び出しているヘルパー関数内で
testing.T.Setenv
しているパターン - テスト内環境変数を
os.Setenv
で環境変数を設定しているパターン - その他、
testing.M
でDBの初期化をしている等、並行実行した場合に壊れてしまう前後処理が挟まっているパターン
実装に少し手こずったところ(ちょっと細かい話)
コア部分自体は比較的簡単に書き終えたのですが、testingパッケージのSetenv()
メソッドのサポートに少し手こずりました。
サポート内容は、「テスト関数内でSetenv()
を呼び出していた場合、そのテスト関数は並行実行の対象から外さなければならない」というものです。
testingパッケージには、テスト中に一時的に環境変数を設定できるSetenv()
メソッドがあります。これはgo1.17から導入されたメソッドで、テストケース内で安全に環境変数のセット・リストア処理を行います。ただしこれは並列実行するテストでは利用できず、Parallel()
・Setenv()
両方を使っているテストを実行すると、Panicが発生してテストが失敗するようになっています。
このSetenv()
をサポートするにあたり、トップレベルテスト・サブテスト両方存在するケースの対応が少々面倒でした。
- トップレベルテスト:
func TestXXX(t *testing.T)
のシグニチャを持つテスト関数のこと - サブテスト:トップレベルのテスト関数内で、
t.Run()
で記述されている部分のこと
「Parallel()
・Setenv()
両方を使っているとダメ」というルールは、トップレベル、サブレベル、サブサブレベル...それぞれのスコープでチェックする必要があります。
つまり、トップレベルのテストがParallel()
を呼び出していても、サブテストがParallel()
を呼び出していなければサブテスト内でSetenv()
は使用できます。
具体的な成功例と失敗例です。
// 🟢 OKなパターン
func TestSample1(t *testing.T) {
t.Parallel()
t.Run("sub", func(t *testing.T) {
t.Setenv()
})
// ❌ NGなパターン
func TestSample2(t *testing.T) {
t.Run("sub", func(t *testing.T) {
t.Parallel()
t.Setenv()
})
そのため、「Setenv()
を使っている関数を見つけた場合、Parallel()
を埋め込む対象から外す」という単純なルールで解析すると上記の例に対応できません。
結果的に、「トップレベルのテスト、サブテスト、サブサブテスト... のテストスコープごとにSetenv()
を呼び出していないかチェック。呼び出していたらParallel()
を埋め込む対象から外す」という処理にしました。
実際の処理はこちら。
他、実装に関して
ファイル・ディレクトリを探索する機能として、Go標準ライブラリのfilepath.WalkDir()関数がありますが、tparagenではマルチコア環境下での高速処理が期待できるsaracen/walkerのWalk()
関数を使用して実装しました。
走査するディレクトリ・ファイルの数が多いほど実行時間は線形に伸びていくため、少しでも早くできればと思い。
ものすごく雑にM1 Max MacBook Pro(10コアCPU・64GB Mem)で何回か10回あたりの平均実行速度を調べたところ、filePath.Walk()
と比べて2倍近く短縮されました。
シグネチャも変わらないため、※例外(ファイルまたはディレクトリが見つかったときに行う処理がゴルーチンセーフになっていない、など)を除いてwalkerを使うと、手軽にマルチコア環境下での高速化が期待できて良いのではないでしょうか。
また、Goのテストでありがちな間違い(tt := ttの初期化し忘れ)を防ぐため、サブテスト内のテスト関数の並行化と同時にループ変数の再定義も行なうようになっています。こちらもParallel()
埋め込みと同様に、ASTに直接ループ変数を再定義する式を埋めこんでいます。どうやっているかの実装はこちら。
気になったら、ぜひ一度触ってみてください
DBと接続するテストが書かれていて、前後処理にDB作成や破棄がされているなど、そのままでは並行実行できないプロジェクトには導入にひと工夫必要ですが、そうでなければ割と便利に使えるかもしれません。
tparallel
,paralleltest
といったlinterは指摘までしかしないため、「ciに落ちて、指摘された部分を修正して再度commitして..」という作業が面倒な方には役に立つと思います。
使ってみて何かおかしなところや修正点があったらissue、PRお待ちしております。
また、よかったらstarいただけると、モチベーションにつながります。