はじめに
Goのユニットテストはとても簡単に書くことができ、指定された書式に沿ってコードを書けばテストができます。
func TestSample_Sample(t *testing.T) {
if got := Sample(); got != "ok" {
t.Errorf("Sample() = %v, want %v", got, "ok")
}
}
筆者は業務・プライベートでテストを書いているうちに、
- 「Goのテストって簡単に書けるけど、裏側ではどのように処理されているんだろう?」
- 「いつもテスト関数に渡している
*testing.T
ってなんだろう?」
ということが気になりました。
実際の処理は内部に隠されており、調べても「Goのテストの書き方」の情報は見つかるのですが、内部構造の解説をしている情報にはたどり着くことができませんでした。
じゃあ実際にtestingパッケージをコードリーディングしてみよう、と思い立って、調べたことをまとめた内容が本記事になります。
コードは公開されているため、誰でも読むことはできます。ただ、自分なりにわかりやすくまとめ、公開することで他の方に役に立てばいいなという動機で執筆しました。
本記事では説明をしやすくするため、意図的に省いた処理があります(並行実行時の挙動など)。全てを網羅したい方は、実際のコードを読んでいただけると幸いです。
想定読者
- Goの構造体型に関して基本的な理解がある方
- Goのテストを書いているが、testingパッケージのコードを読んだことがない方
環境
本記事のコードリーディングは、Go1.17のtestingパッケージのソースコードを対象としています。
解説する内容は別バージョンのソースコードの中身とは異なる場合がありますのでご注意ください。
用語の定義
- メインテスト...トップレベルに置かれたテスト関数
- サブテスト...メインテストに含まれるテスト関数
// メインテスト
func Test_Sample(t *testing.T) {
tests := []struct {
name string
want int
}{
{
name: "trueの場合、1を返す",
want: 1,
},
{
name: "falseの場合、2を返す",
want: 2,
},
}
for _, tt := range tests {
// サブテスト
t.Run(tt.name, func(t *testing.T) {
if got := Sample(); got != tt.want {
t.Errorf("Sample() = %v, want %v", got, tt.want)
}
})
}
}
go test
実行時の裏側
テスト実行を支える型たち
ここでは、テスト実行時に活躍する主要なtesting
パッケージの型を紹介します。
主に関わるのは以下に紹介する2つの型です。
testing.T
型
testing
パッケージにはtesting.T
型が存在します。testing.T
型はテスト時の設定を保持・管理する構造体で、テスト関数に必ず引数として渡している型です。
このtesting.T
型を構成する各フィールドについて、さらっと確認します。
type T struct {
common
isParallel bool // t.Parallel()を呼び出しているとtrue
isEnvSet bool // t.SetEnv()を呼び出しているとtrue
context *testContext
}
出典: https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/testing/testing.go;l=674
isParallel
・isEnvSet
というフィールドがあります。
Goのテストは、テストを並行実行できる機能(t.Parallel
)や、テストケース内だけで使用できる環境変数を設定できる機能(t.Setenv
)があります。上記フィールドには、これらの機能のOn/Offの状態が記録されます。
context
フィールドは、testing.*testContext
型になっています。本記事では詳しく述べませんが、この型は並行実行時の状態を管理する構造体になります。
最後に、testing.common
型が埋め込まれています。この構造体については次項で詳しく説明します。
testing.common
型
**testing.common
型は、テストの成功/失敗状態の保持や、t.Errorf()
メソッドなどの自分達が良く使うユーティリティメソッドを持っています。**前述したt.Parallel()
やt.SetEnv()
も、testing.T
型に埋め込まれたtesting.common
型のメソッドになります。
また、この型はベンチマークで用いるbenchmark.B
と共通のメソッドセットを持つtesting.TB
インタフェースを満たしています。
type TB interface {
Error(args ...interface{})
Errorf(format string, args ...interface{})
Fail()
Fatal(args ...interface{})
Fatalf(format string, args ...interface{})
...
}
この構造体は多くのフィールドを持っているため、ここでは著者が主要と判断したフィールドをざっと紹介します。他フィールドの詳細は、testing.go#390を参照してください。
テストの状態に関するフィールド
type common struct {
...
name string // テスト名
ran bool
failed bool // テストが失敗したかどうか
finished bool // テストが完了したかどうか
signal chan bool // テストが終了したかどうかの通知
...
}
親テスト・サブテストに関するフィールド
type common struct {
...
parent *common // 親テストの情報
sub []*T // 並行実行されるサブテスト情報が入ったキュー
...
}
テストを構成する型について把握したところで、次章からは実際の処理の流れを追っていきます。
テスト実行処理の詳細
この章では、go test
コマンドを実行したときに、テストコード実行から終了するまでの流れについて扱います。
まず、処理の流れを大まかにイメージできるよう、図に表してみました。
ざっくりと流れを把握したところで、コードリーディングを進めていきましょう。
まず、テスト実行時のtesting
パッケージでの処理は、大きく以下のように分けられます。
-
(t *T).Run()
の呼び出し 〜 テストケース実行までの処理 -
テストケース実行 〜 テスト終了までの処理
ここからは、上記のそれぞれの処理についてGoのコードを見ていきます。
1. テスト準備〜テストケース実行まで
まずは、 テスト準備〜テストケース実行までの処理を追ってみましょう。
メインテスト・サブテスト共に、テストの実行時には**T.Run()
**というメソッドを呼び出します。
メインテストはtesting
パッケージのruntests()
メソッド経由で、サブテストは自分達がソースコード内で直接t.Run()
を呼び出して実行する形になります。
ここで行われる処理は、以下の2つです。
行われる処理
- 必要な情報をセットする
- テスト実行ゴルーチンを生成する
それぞれについてみていきます。
必要な情報のセット
テストケース実行の前に、テストに必要な情報をT.common
・T.context
フィールドにセットする処理が行われます。
func (t *T) Run(name string, f func(t *T)) bool {
...
t = &T{
common: common{
barrier: make(chan bool), // 並行実行時に使われるフィールド初期化
signal: make(chan bool, 1),
name: testName,
parent: &t.common,
level: t.level + 1,
creator: pc[:n], // サブテストの実行時点でのスタックトレースが入るフィールドの初期化
chatty: t.chatty,
},
context: t.context, // 並行実行時の状態保持に使われるフィールド初期化
}
...
ここで、主要なフィールドをいくつか説明していきます。
-
name
-
name
フィールドはわかりやすく、名の通りテスト名が入ります。このフィールドは、テスト終了後のログ出力時に参照されます。 - メインテスト実行時には
テスト関数名
が、サブテスト実行時には[親テスト関数名+ "/" + 引数に渡した文字列]
が入ります。- 【最初の例の場合】メインテスト:
"Test_Sample"
・サブテスト:"Test_Sample/trueの場合、1を返す"
- 【最初の例の場合】メインテスト:
-
-
signal
-
signal
フィールドにはテストが終了したかどうかの通知のチャネル型の初期化が行われます。
-
-
level
-
level
フィールドには、実行するテストの深さが入ります。上記例だと、メインテスト実行時には1
、サブテスト実行時には2
が入ります。
-
-
parent
-
parent
フィールドには、親テストのt.common
のポインタが渡されます。 - つまり、メインテスト実行時には初期状態のため
nil
になりますが、サブテスト実行時にはメインテストの情報が入ったt.common
のポインタがセットされます。
-
テスト実行ゴルーチンの生成
実際にテストを走らせる処理は、tRunner()
という関数で行われます。Goのテストは並行実行に対応しており、ここでゴルーチンtRunner
を生成しています。
...
go tRunner(t, f)
if !<-t.signal {
...
runtime.Goexit()
}
return !t.failed
}
2. テストケース実行〜テスト終了まで
ここからは、テストケース実行〜テスト終了までを追っていきます。
ここでの主役は、ゴルーチンtRunner
です。
func tRunner(t *T, fn func(t *T)) {
t.runner = callerName(0)
...
defer func() { // ②
...
}()
defer func() { // ③
...
}()
t.start = time.Now()
t.raceErrors = -race.Errors()
fn(t) // ①
t.mu.Lock()
t.finished = true
t.mu.Unlock()
}
このtRunner
には、2つのdefer
文と、テスト実行処理が書かれています。
defer
文は関数がreturnされるまで処理を遅らせるので、まずは①の部分から順に追っていきましょう。
(1) テストケース実行
func tRunner(t *T, fn func(t *T)) {
...
fn(t) // テストケース実行
// テスト終了フラグ立てる
t.mu.Lock()
t.finished = true
t.mu.Unlock()
}
fn(t)
で、t.Runner()
に引数として渡した関数が実行されます。
この関数はテストコード記述時にメインテスト関数もしくはサブテストのt.Run()
の第二引数で渡した、*testing.T
を引数としてテストケースを実行する無名関数です。
つまり、自分達が書いたテストケースごとのテスト処理は、この時点で行われます。
t.Run(tt.name,
// ↓これ!
func(t *testing.T) {
if got := Sample(); got != tt.want {
t.Errorf("Sample() = %v, want %v", got, tt.want)
}
})
【寄り道】失敗処理についてもっと詳しく
このようなt.Errorxx``系のメソッドは、
common.failedフィールドを
falseにすることで、テストに失敗した状態を保持しています。 また、このテストが何かのサブテストだった場合は、親の
common.failedも
false`にセットしています。
func (c *common) Errorf(format string, args ...interface{}) {
c.log(fmt.Sprintf(format, args...))
c.Fail() // ここで失敗フラグを立てている!
}
最後に、テストケースを実行した後は成功・失敗関係なくテストが終了したことを示すt.finished
フィールドをtrue
にしています。
その際、他のゴルーチンの影響を受けないようRWMutex.Lock
で排他制御が行われています。
(2) テスト結果ログ出力・panic処理・サブテスト並行実行
ここでは、tRunner()
1つ目のdefer
文で実行される処理を追っていきます。
この部分はかなり多くの処理が記述されているので、大まかに以下のように分類しました。
- panic処理
- テスト結果のログ出力処理
- サブテスト並行実行処理
2.1.panic処理
テストケース実行時にpanicが発生した場合は、プログラムがクラッシュして不整合は起こらないように、tRunner
内で安全にpanic処理が行われます。
err := recover()
err
変数にpanicの詳細が書き込まれ、この値を用いてpanicした理由をログ出力します。そして、親も含めたcommon.failed
フィールドが全てfalseになり、テストに失敗している状態になります。
ここで、テストコード内にt.Cleanup()
で後処理が記述されていた場合、設定された後処理を全て実行します。
最終的にはdopanic()
が呼び出され、その関数内でpanicさせます。
2.2.ログ出力処理
続いてはログ出力処理です。テスト実行時、以下のようなログ出力をよく見ます。
--- PASS: Test_Sample (0.00s)
--- PASS: Test_Sample/trueの場合1、を返す (0.00s)
--- PASS: Test_Sample/falseの場合、2を返す (0.00s)
PASS
ok sample/ 0.666s
これらのログ出力はt.report()
メソッド内において行われています。ソースコードを読むと、t.common
のname
フィールドの値が用いられているのがわかります。
func (t *T) report() {
if t.parent == nil {
return
}
dstr := fmtDuration(t.duration)
format := "--- %s: %s (%s)\n"
if t.Failed() {
t.flushToParent(t.name, format, "FAIL", t.name, dstr)
} else if t.chatty != nil {
if t.Skipped() {
t.flushToParent(t.name, format, "SKIP", t.name, dstr)
} else {
t.flushToParent(t.name, format, "PASS", t.name, dstr)
}
}
}
2.3.サブテスト並行実行処理
テストコード内でt.Parallel()
を呼び出していた場合は、テストケースが並行実行されます。
t.Parallel
を呼び出しているテスト関数がどのような順序で実行されるのかは、他の方が書かれた以下の記事で詳しく解説されています。
ここでは、testing.testContext
型にある内部的な並行実行を行うためのカウントを操作し、一斉に並行実行させます。
このとき、サブテストにt.Cleanup()
が呼ばれていた場合は、後述する後処理も行われます。
(3) テストケース実行後の後処理
Goのテストでは、テストコード内でt.Cleanup()
を呼び出し、引数に関数を渡すことによって、テストケース実行終了時に任意の後処理を実行させることができます。
t.Runner()
では最後にcommon.runCleanup()
を呼び出して、このテストケース実行の後処理が行われ、これで全ての処理が終了となります。
defer func() {
if len(t.sub) == 0 {
t.runCleanup(normalPanic)
}
}()
おわりに
ここまで、go test
コマンドを実行してから、実際にテストが実行されるまでの処理の流れを追ってきました。
筆者はGoで書き始めて半年ほどですが、Goのコードはシンプルで読みやすく、スムーズに標準パッケージを読み進めることができました。
本記事を読むことで、普段何気なく使っているtesting
パッケージが裏側でどのような処理をしているか、その一片でも把握できるお手伝いができたのであれば幸いです。
間違い等、内容に不備がありましたらご指摘ください。