Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Go Conference 2023|Goのtestingパッケージにコミットした話

Go Conference 2023|Goのtestingパッケージにコミットした話

Mirrativ

June 06, 2023
Tweet

More Decks by Mirrativ

Other Decks in Programming

Transcript

  1. 自己紹介 • 氏名
 ◦ 藤井 脩紀 (ふじい のぶき)
 • 所属


    ◦ 株式会社ミラティブ
 ▪ バックエンドエンジニア
 ▪ 2021年10月入社(≒ Go歴約1年半)
 • GitHub
 ◦ @noi
 2
  2. コミットに至った経緯 • そうなんだと思いT.Setenvの実装を確認してみると
 ◦ t.isParallel == trueだとpanicするようになっていた
 ▪ t.isParallelはt.Parallel()を実行するとtrueになる
 


    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") } // 以下略 } 5
  3. T.Setenv • T.Setenvは何をしているか
 ◦ 内部的にはos.Setenvを呼び出している
 ◦ ただしテスト関数終了時にクリーンアップする
 ▪ 元々の値があれば戻し、なければ消す
 


    // *commonのレシーバーになっていますがT.Setenvは内部的にこちらを呼び出しています // また、コードは必要な部分だけを抜き出して整形しているので実際のものとは多少異なります func (c *common) Setenv(key, value string) { prevValue, ok := os.LookupEnv(key) if err := os.Setenv(key, value); err != nil { c.Fatalf("cannot set environment variable: %v", err) } if ok { c.Cleanup(func() { os.Setenv(key, prevValue) }) } else { c.Cleanup(func() { os.Unsetenv(key) }) } } 10
  4. T.Setenv • os.Setenvを呼び出しているこということは
 ◦ T.Setenvでセットされる環境変数はプロセス単位で共有される
 ◦ go testコマンドはパッケージ単位でプロセスが別れる
 ◦ →パッケージ単位で環境変数が共有される


    
 // x/a_test.go package x func TestA(t *testing.T) { fmt.Printf("%d\n", os.Getpid()) } // x/b_test.go package x func TestB(t *testing.T) { fmt.Printf("%d\n", os.Getpid()) } // y/c_test.go package y func TestC(t *testing.T) { fmt.Printf("%d\n", os.Getpid()) } ❯ go test ./... -v === RUN TestA 48234 --- PASS: TestA (0.00s) === RUN TestB 48234 --- PASS: TestB (0.00s) PASS ok m/x 0.532s === RUN TestC 48233 --- PASS: TestC (0.00s) PASS ok m/y 0.358s 11
  5. T.Setenv • つまり、T.ParallelとT.Setenvを併用するテストが複数存在するとそれらのテストが 互いに干渉しFlakyなテストになりうる
 ◦ →よって、併用を禁止する必要がある
 
 func TestA(t *testing.T)

    { t.Parallel() t.Setenv("key", "a") // 以下の処理では環境変数keyの値がbになりうる } func TestB(t *testing.T) { t.Parallel() t.Setenv("key", "b") // 以下の処理では環境変数keyの値がaになりうる } 12
  6. T.Parallel • 説明の前に
 ◦ 以下のような用語を用います
 
 コマンド 説明 トップレベルテスト パッケージ直下にTestXXXのような名前で定義されるテスト

    サブテスト T.Runにより実行されるテスト 並列テスト T.Parallelを呼び出しているテスト 非並列テスト T.Parallelを呼び出していないテスト 13
  7. T.Parallel • 説明の前に
 ◦ また、テストの階層構造の説明に親、子、兄弟などの表現を用います
 ▪ トップレベルテストは兄弟
 ▪ サブテストは子であり呼び出し元のテストが親
 ▪

    同じ親を持つサブテストも兄弟
 
 func TestA(t *testing.T) { t.Run("Sub1", func(t *testing.T) {}) t.Run("Sub2", func(t *testing.T) {}) } func TestB(t *testing.T) {} 14
  8. T.Parallel • T.Parallelの挙動
 ◦ T.Parallelが呼び出されたタイミングで中断し、兄弟の非並列テストが全て完 了するまで待機してから並列に再開するという振る舞い
 func TestA(t *testing.T) {}

    func TestB(t *testing.T) { t.Parallel() } func TestC(t *testing.T) {} func TestD(t *testing.T) { t.Parallel() } ※図はT.Parallelがテストの先頭で呼び出される前提 
 ※非並列テストの実行順序が保証されるかは未確認 
 15
  9. T.Parallel • 図の通りに動くのか確認してみる
 $ go test main_test.go -v === RUN

    TestA --- PASS: TestA (0.00s) === RUN TestB === PAUSE TestB === RUN TestC --- PASS: TestC (0.00s) === RUN TestD === PAUSE TestD === CONT TestB --- PASS: TestB (0.00s) === CONT TestD --- PASS: TestD (0.00s) PASS ok command-line-arguments 0.088s RUN: 開始 PAUSE: 中断 CONT: 再開 PASS: 終了(成功) 16
  10. T.Parallel • T.Runを利用して階層化した場合
 func TestA(t *testing.T) {} func TestB(t *testing.T)

    { t.Parallel() } func TestC(t *testing.T) { t.Run("Sub1", func(t *testing.T) { t.Parallel() }) t.Run("Sub2", func(t *testing.T) {}) t.Run("Sub3", func(t *testing.T) { t.Parallel() }) } func TestD(t *testing.T) { t.Parallel() t.Run("Sub4", func(t *testing.T) { t.Parallel() }) t.Run("Sub5", func(t *testing.T) {}) t.Run("Sub6", func(t *testing.T) { t.Parallel() }) } 17
  11. T.Parallel • 自身が非並列テストであっても親や先祖が並列テストなら他のテストと並列実行 されうる
 ◦ →T.Parallelを呼ぶテストの子孫でもT.Setenvを禁止すべき
 func TestA(t *testing.T) {

    t.Parallel() t.Run("Sub1", func(t *testing.T) { t.Setenv("key", "a") // 以下の処理では環境変数keyの値がbになりうる }) } func TestB(t *testing.T) { t.Parallel() t.Run("Sub2", func(t *testing.T) { t.Setenv("key", "b") // 以下の処理では環境変数keyの値がaになりうる }) } 18
  12. コミット内容 • T.Setenvの中で先祖を遡って走査してT.Parallelしているテストがあればpanicする ように変更
 func (t *T) Setenv(key, value string)

    { isParallel := false for c := &t.common; c != nil; c = c.parent { if c.isParallel { isParallel = true break } } if isParallel { panic("testing: t.Setenv called after t.Parallel; (略") } // 以下略 } func (t *T) Setenv(key, value string) { if t.isParallel { panic("testing: t.Setenv called after t.Parallel; (略") } // 以下略 } Before After 19
  13. 余談 • go testコマンドの並列化オプションについて
 ◦ -pと-parallelの2種類ある
 ◦ -p
 ▪ パッケージ単位の並列数


    • 最大いくつのパッケージを並列に実行するか
 ◦ -parallel
 ▪ テスト単位の並列数
 • 同一パッケージ内で最大いくつのテストを並列に実行するか
 • T.Parallelに関係するのはこちら
 ◦ どちらもデフォルトは$GOMAXPROCS
 ▪ 通常は利用可能なCPU数
 ◦ つまり最大で-p × -parallelの数のテストが並列に実行されうる
 20
  14. コントリビュート手順 • 手順(3/3)
 ◦ 実際にコミットを作成してレビューを受けつつ承認されるまで修正
 ▪ ガイドに従いセットアップすると使えるようになる
 git codereviewコマンドを利用してcommit &

    push
 
 コマンド 説明 git codereview change 変更を常に単一のコミットにまとめつつ必要な情報をコミットメッセージに追加してくれる( git commitのような役割) git codereview mail 変更をGerritに送信してくれる( git pushのような役割) 25
  15. 感想とまとめ • ドキュメント系以外でのOSSへのコントリビュートは初経験
 ◦ 有名なOSSであるGoに貢献できてとても嬉しい
 • メンテナーの方々のレスポンスは非常に早かった
 ◦ スムーズに進められた
 ◦

    英語が苦手でも大丈夫
 • コミットから読み取れること
 ◦ テストを並列実行したければ環境変数を無闇に利用してはならない
 ◦ 対策案
 ▪ 環境変数の取得は最上層のレイヤのみ行い引数として受け渡す
 ▪ コマンドラインの実行時引数などでも指定できるようにする
 • ちなみにミラティブはlinterを作成してT.Parallelを必須化している
 29