LoginSignup
10
3

More than 1 year has passed since last update.

go testの裏側では何をしているのか?Goのtestingパッケージの中身を覗いてみよう!

Last updated at Posted at 2021-12-19

:qiitan:はじめに

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型を構成する各フィールドについて、さらっと確認します。

testing.go
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

isParallelisEnvSetというフィールドがあります。
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インタフェースを満たしています。

testing.go_TB
type TB interface {
	Error(args ...interface{})
	Errorf(format string, args ...interface{})
	Fail()
	Fatal(args ...interface{})
	Fatalf(format string, args ...interface{})
	...
}

この構造体は多くのフィールドを持っているため、ここでは著者が主要と判断したフィールドをざっと紹介します。他フィールドの詳細は、testing.go#390を参照してください。

テストの状態に関するフィールド

testing.go
type common struct {
  ...
  name string // テスト名
  ran bool
  failed bool // テストが失敗したかどうか
  finished bool // テストが完了したかどうか
  signal chan bool // テストが終了したかどうかの通知
  ...
}

親テスト・サブテストに関するフィールド

testing.go
type common struct {
  ...
  parent *common // 親テストの情報
  sub []*T // 並行実行されるサブテスト情報が入ったキュー
  ...
}

テストを構成する型について把握したところで、次章からは実際の処理の流れを追っていきます。

テスト実行処理の詳細

この章では、go testコマンドを実行したときに、テストコード実行から終了するまでの流れについて扱います。

まず、処理の流れを大まかにイメージできるよう、図に表してみました。
Screenshot 2021-12-19 14.10.54.png

ざっくりと流れを把握したところで、コードリーディングを進めていきましょう。

まず、テスト実行時のtestingパッケージでの処理は、大きく以下のように分けられます。

  • (t *T).Run()の呼び出し 〜 テストケース実行までの処理

  • テストケース実行 〜 テスト終了までの処理

ここからは、上記のそれぞれの処理についてGoのコードを見ていきます。

1. テスト準備〜テストケース実行まで

まずは、 テスト準備〜テストケース実行までの処理を追ってみましょう。

メインテスト・サブテスト共に、テストの実行時には**T.Run()**というメソッドを呼び出します。
メインテストはtestingパッケージのruntests()メソッド経由で、サブテストは自分達がソースコード内で直接t.Run()を呼び出して実行する形になります。

ここで行われる処理は、以下の2つです。

行われる処理

  • 必要な情報をセットする
  • テスト実行ゴルーチンを生成する

それぞれについてみていきます。

必要な情報のセット

テストケース実行の前に、テストに必要な情報をT.commonT.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を生成しています。

testing.go
  ...
	go tRunner(t, f)
	if !<-t.signal {
    ...
		runtime.Goexit()
	}
	return !t.failed
}

2. テストケース実行〜テスト終了まで

ここからは、テストケース実行〜テスト終了までを追っていきます。
ここでの主役は、ゴルーチンtRunnerです。

testing.go
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) テストケース実行

testging.go_tRunner()内
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.Errorf`などを使ってテストを失敗させます。

このようなt.Errorxx``系のメソッドは、common.failedフィールドをfalseにすることで、テストに失敗した状態を保持しています。 また、このテストが何かのサブテストだった場合は、親のcommon.failedfalse`にセットしています。

testing.go
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処理が行われます。

testing.go_tRunner内
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.commonnameフィールドの値が用いられているのがわかります。

testing.go
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()を呼び出して、このテストケース実行の後処理が行われ、これで全ての処理が終了となります。

testing.go
	defer func() {
		if len(t.sub) == 0 {
			t.runCleanup(normalPanic)
		}
	}()

おわりに

ここまで、go testコマンドを実行してから、実際にテストが実行されるまでの処理の流れを追ってきました。
筆者はGoで書き始めて半年ほどですが、Goのコードはシンプルで読みやすく、スムーズに標準パッケージを読み進めることができました。

本記事を読むことで、普段何気なく使っているtestingパッケージが裏側でどのような処理をしているか、その一片でも把握できるお手伝いができたのであれば幸いです。

間違い等、内容に不備がありましたらご指摘ください。

10
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
3