Slide 1

Slide 1 text

Nobuki Fujii @ Mirrativ, Inc. 
 Goのtestingパッケージにコミットした話 並列テストの解説を交えて Go Conference 2023


Slide 2

Slide 2 text

自己紹介 ● 氏名
 ○ 藤井 脩紀 (ふじい のぶき)
 ● 所属
 ○ 株式会社ミラティブ
 ■ バックエンドエンジニア
 ■ 2021年10月入社(≒ Go歴約1年半)
 ● GitHub
 ○ @noi
 2

Slide 3

Slide 3 text

概要 ● どんな話をするのか?
 ○ Goのtestingパッケージへのコミット内容を説明
 ○ 関連して並列テストの挙動についても解説
 
 3

Slide 4

Slide 4 text

コミットに至った経緯 ● ある日、業務中にテストを回していると見覚えのないテストが失敗
 ○ 失敗したテストには の表示が(CircleCIの機能)
 ○ そして問題を社内で共有したところ...
 ■ 同僚のエンジニアからtestingパッケージのT.ParallelとT.Setenvの相性 が悪いことを教えてもらえた
 
 4

Slide 5

Slide 5 text

コミットに至った経緯 ● そうなんだと思い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

Slide 6

Slide 6 text

コミットに至った経緯 ● テストを書いて確認してみると
 ○ 実際にpanicした
 ○ →しかし、問題となったテストではpanicしていない...
 
 // panic! func Test(t *testing.T) { t.Parallel() t.Setenv("key", "val") } 6

Slide 7

Slide 7 text

コミットに至った経緯 ● 原因を調査していくと
 ○ T.Runでテストを階層化するとpanicしないことが判明
 
 // no panic… func Test(t *testing.T) { t.Parallel() t.Run("Sub", func(t *testing.T) { t.Setenv("key", "val") }) } 7

Slide 8

Slide 8 text

コミットに至った経緯 ● 原因を特定したので調査内容を共有したところ
 ○ 以下のようにチャンスだと言ってもらい
 「確かに!」となったのがコミットに挑戦しようと思った経緯
 
 8

Slide 9

Slide 9 text

どんなコミット? ● ということで
 ○ T.Parallelの後でもT.Runの中でならT.Setenvできるという考慮漏れが存在し たのでその修正を行ったというのがコントリビュート内容
 ○ →そもそもなぜT.ParallelとT.Setenvは併用できないのか?
 
 // no panic... func Test(t *testing.T) { t.Parallel() t.Run("Sub", func(t *testing.T) { t.Setenv("key", "val") }) } 9

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

T.Parallel ● 説明の前に
 ○ 以下のような用語を用います
 
 コマンド 説明 トップレベルテスト パッケージ直下にTestXXXのような名前で定義されるテスト サブテスト T.Runにより実行されるテスト 並列テスト T.Parallelを呼び出しているテスト 非並列テスト T.Parallelを呼び出していないテスト 13

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

コミット内容 ● 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

Slide 20

Slide 20 text

余談 ● go testコマンドの並列化オプションについて
 ○ -pと-parallelの2種類ある
 ○ -p
 ■ パッケージ単位の並列数
 ● 最大いくつのパッケージを並列に実行するか
 ○ -parallel
 ■ テスト単位の並列数
 ● 同一パッケージ内で最大いくつのテストを並列に実行するか
 ● T.Parallelに関係するのはこちら
 ○ どちらもデフォルトは$GOMAXPROCS
 ■ 通常は利用可能なCPU数
 ○ つまり最大で-p × -parallelの数のテストが並列に実行されうる
 20

Slide 21

Slide 21 text

Goへのコントリビュート方法

Slide 22

Slide 22 text

コントリビュート手順 ● Contribution Guideがあるのでそれに従う
 ○ https://go.dev/doc/contribute
 ● ここでは大枠を説明
 
 22

Slide 23

Slide 23 text

コントリビュート手順 ● 手順(1/3)
 ○ 任意のGoogleアカウントでGerritに登録
 ■ GerritはコードレビューツールでGitHubのようなもの
 ■ 登録したアカウントのGmailアドレスをGitに紐づける
 
 $ git config --global user.email [email protected] 23

Slide 24

Slide 24 text

コントリビュート手順 ● 手順(2/3)
 ○ GitHubでIssueを立てて修正の必要性や方向性などを議論
 https://github.com/golang/go/issues/55128 24

Slide 25

Slide 25 text

コントリビュート手順 ● 手順(3/3)
 ○ 実際にコミットを作成してレビューを受けつつ承認されるまで修正
 ■ ガイドに従いセットアップすると使えるようになる
 git codereviewコマンドを利用してcommit & push
 
 コマンド 説明 git codereview change 変更を常に単一のコミットにまとめつつ必要な情報をコミットメッセージに追加してくれる( git commitのような役割) git codereview mail 変更をGerritに送信してくれる( git pushのような役割) 25

Slide 26

Slide 26 text

コードレビュー ● git codereview mailコマンドの初回実行時にGerrit上にChange Listが作成される のでそこでレビューを受ける
 ○ Change ListはGitHubにおけるPull Requestのようなもの
 https://go-review.googlesource.com/c/go/+/431101 26

Slide 27

Slide 27 text

コードレビュー ● コードレビューへの対応が完了するとマージされる
 https://github.com/golang/go/commit/d6ca24477afa85a3ab559935faa4fed917911e4f 27

Slide 28

Slide 28 text

Goのリリースサイクル ● 一定の開発期間とコードフリーズ期間で構成されている
 ○ コミットは2022/09にマージされて2023/02にリリースされた
 ○ 今はリリースサイクルが変更されている模様(左が旧、右が新)
 https://github.com/golang/go/wiki/Go-Release-Cycle Merge Release Go 1.20 28

Slide 29

Slide 29 text

感想とまとめ ● ドキュメント系以外でのOSSへのコントリビュートは初経験
 ○ 有名なOSSであるGoに貢献できてとても嬉しい
 ● メンテナーの方々のレスポンスは非常に早かった
 ○ スムーズに進められた
 ○ 英語が苦手でも大丈夫
 ● コミットから読み取れること
 ○ テストを並列実行したければ環境変数を無闇に利用してはならない
 ○ 対策案
 ■ 環境変数の取得は最上層のレイヤのみ行い引数として受け渡す
 ■ コマンドラインの実行時引数などでも指定できるようにする
 ● ちなみにミラティブはlinterを作成してT.Parallelを必須化している
 29

Slide 30

Slide 30 text

テックブログ ● 本セッションの内容はテックブログにもまとめてあります
 ○ 「Mirrativ Tech Blog」で検索
 ■ 「ミラティブ テックブログ」でも可
 https://tech.mirrativ.stream/entry/2022/12/22/171137 30

Slide 31

Slide 31 text

We are hiring! ● ミラティブではエンジニアを積極採用中!
 ○ 主にバックエンド開発にGoを利用しています
 https://www.mirrativ.co.jp/recruit/ 31

Slide 32

Slide 32 text

ご清聴ありがとうございました