本記事では、並行実行するテストで環境変数を設定しているコードを指摘するツールparallelenv
の紹介をします。
並行実行するテストでは、環境変数を設定してはいけない
Go1.17より、テストごとに環境変数を設定するためのメソッドとしてtesting
パッケージにt.Setenv
が追加されました。Go1.17以前ではテストが終了しても環境変数が破棄できないos.Setenv
を使用するほかなかったため、かなり便利になりました。
// v1.17以前
err := os.Setenv("LANGUAGE", "go")
// v1.17以降
t.Setenv("LANGUAGE", "go")
ここで、テストで環境変数を使用する上で注意があります。
t.Parallel
でテストを並行実行するようにしていると、並列で動作した場合にテスト動作中の環境変数の寿命の扱いが破綻してしまい、参照する環境変数の値がめちゃくちゃになってしまいます。
上記のような想定外の動作を防ぐために、t.Setenv
の中では一つのテスト関数内でt.Setenv
とt.Parallel
を呼び出していた場合、実行時にpanicを起こしてクラッシュする仕組みになっています1。
func (t *T) Setenv(key, value string) {
if t.isParallel {
panic("testing: t.Setenv called after t.Parallel; cannot set environment variables in parallel tests")
}
parallelenv
今回作成したparallelenv
は、一つのテスト関数内でt.Setenv
とt.Parallel
を呼び出している箇所を指摘します。
package main
import (
"testing"
)
func TestMain(t *testing.T) {
t.Parallel()
t.Setenv("LANGUAGE", "Go")
...
}
上記のようなテストコードに、parallelenv
のチェックを入れると以下のような結果になります。
$ parallelenv main_test.go
./main_test.go:7:2: cannot set environment variables in parallel tests
./main_test.go:8:2: cannot set environment variables in parallel tests
また、以下のようにすると、カレントディレクトリ以下の*_test.go
に一致するすべてのファイルに対して静的解析を行います。
$ parallelenv ./...
go install
ですぐに使えるようになっているため、ぜひ使ってみてください。
go install github.com/sho-hata/parallelenv/cmd/parallelenv@latest
作ってみて
なぜ作ったのか
最初に述べましたが、仮にt.Setenv
とt.Parallel
を同時に呼び出していると、panicを吐いてクラッシュしてくれるようになっているため、実行時にコードの誤りに気づくことができます。
ただ、動作時になって初めて気づくより、静的に解析する段階で気づけると良いかなぁ、と思ったのと、前から静的解析ツールを作りたいと思っていたので、素振りがてら作ったのがモチベーションです。
「役に立つこと」を意識しすぎると静的解析の勉強すらしなかったので、作ってみて良かったと思います。
どうやって作っていったか
Goの静的解析については多くの日本語情報がネットに散らばっており、巨人の肩に乗ることができるため、実際に静的解析をするまでの距離が非常に短いです。先人たちに感謝しつつ、以下のような順序で作っていきました。
まず最初に、「プログラミングGo完全入門 静的解析とコード生成」を参考にして、静的解析がなんたるかを大雑把に把握しました。こちらの資料は静的解析をやる意味・解析の流れなどがわかりやすくまとまっており、とても参考になりました。
次に、「skeletonで始めるGoの静的解析」を読み、skeletonというx/tools/go/anaiysisを使った静的解析ツールの雛形を生成してくれるツールを知り、静的解析のテンプレートをサクッと作りました。
そして、他の方が作ったlinterを参考に、実装を行います。
- https://github.com/sivchari/tenv
- https://github.com/golang/tools/blob/master/go/analysis/passes/findcall/findcall.go
自分がやりたい解析を実現するために、見よう見まねで実装しました。
Goの抽象構文木を把握するための情報源として、日本語で書かれた以下の記事は大変参考になりました。詰まるたびに読み直し、何度も「なるほどー!」となりながら開発していました。
また、x/tools/go/anaiysis
を使った開発はかなり体験がよかったです。
func Test_f(t *testing.T) {
t.Skip()
t.Parallel() // want "cannot set environment variables in parallel tests"
t.Setenv("SAMPLE", "sample") // want "cannot set environment variables in parallel tests"
...
このように、テスト対象となるソースコードを準備し、コメントの形で期待するLinterの出力結果を記述するだけでテストができます。
- 最初は単純なテストケースから始め、それにパスする静的解析ツールの実装を書く
- 次は
if文の中にあるパターン
のテストケースを書き、実装する - その次は
サブテストがあるパターン
...
といったように一つづつテストケースを書き、それにパスする静的解析ツールの実装を書くことで、テスト駆動開発チックに進めることができました。
開発時、どういうところが詰まったか
一つのメインテスト関数で、t.Parallel
とt.Setenv
が同時に呼び出されている箇所をすべて報告すれば良いかというと、そうではありません。
例として、以下のようなサブテストが存在するテスト関数を考えます。
func Test_f(t *testing.T) {
t.Parallel() // 並行実行する
tests := map[string]struct {
...
}{
// test case
}
for name, tt := range tests {
tt := tt
t.Run(name, func(t *testing.T) {
t.Setenv("LANGUAGE", "go") // サブテスト実行ごとに破棄される!
if got := f(); got != tt.want {
t.Errorf("f() = %v, want %v", got, tt.want)
}
})
}
上記のような、メインテストがt.Parallel
で並行実行するようになっていて、サブテストでSetenv
を呼び出していた場合は問題がありません。サブテストが並列に動作することはなく、環境変数のスコープはサブテスト内にのみ閉じられているためです。
そのため、「テスト関数を解析して、t.Parallel
とt.Setenv
が同時に現れたらすべて報告する」といった単純な方法ではできませんでした。
type testLevel = int
type results map[testLevel][]*result
type result struct {
target target
pos token.Pos
}
最終的には上のように、メインテスト、サブテスト、サブサブテスト、、といったようにテストのスコープごとに解析結果を一時保持するデータ構造を作ることで対応しました。もっと良い方法があったかもしれませんが、一旦このような形で。
おわりに
日本語情報でのわかりやすい記事・参考になるlinterが多く、かなりスムーズかつ楽しくGoの静的解析ツールを作ることができました。
go/ast
パッケージもかなりわかりやすい形で抽象構文木を扱えるようになっており、実際何も知らないところからツールが完成するまで2日もかからなかったと思います。
今回作成した静的解析ツールは、GitHubで公開しています。
もし良さそうと思ったらスターをいただけると自分のモチベーションアップに繋がります。
また、バグやもっとこうした方が良いというアドバイス、既に似たようなLinterがありましたら教えていただますと嬉しいです。