フューチャー技術ブログ

Goのテストに入門してみよう!

2020/08/15更新: 「テストの失敗をレポートしたい」と「サブテストの一部のみ実施したい」の章を追加

はじめに

TIG の辻です。今回は春の入門祭りということで Go のテストに入門してみよう!という記事です。

書いた背景ですが Go の標準ライブラリのコードリーディング会で testing パッケージにチャレンジしてみましたが、難しすぎてわからん。そもそも Go のテストって何ができるんだっけ?という話になり、基本的な内容をなるべく具体例をまじえながらまとめました。

ざっとどんなことができるんだろう、という index になれば幸いです。

Tips

Go に組み込まれているテストの仕組みの中に、ベンチマークに関するテストと Example テストというサンプルコード用のテストも含まれているのですが、この 2 つは対象外にします。基礎的と思われる内容から順に並べてみました。

テストがしたい

Goのテストは go test コマンドを用いてテストを実施します。テストを実施する関数の命名は以下のような形式でなければなりません。

func TestXxx(*testing.T)

TestXxxTestxxx ではダメです。Test_xxx という関数名であれば問題ありません。

テストファイルは xxxx_test.go といった命名である必要があります。このファイルはビルド時には除かれます。簡単なテストを試してみます。

main_test.go
package main

import "testing"

func add(a, b int) int {
return a + b
}

func TestAdd(t *testing.T) {
type args struct {
a int
b int
}
tests := []struct {
name string
args args
want int
}{
{
name: "normal",
args: args{a: 1, b: 2},
want: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := add(tt.args.a, tt.args.b); got != tt.want {
t.Errorf("add() = %v, want %v", got, tt.want)
}
})
}
}

https://play.golang.org/p/XYrpmljtPrW

$ go test -v
=== RUN TestAdd
=== RUN TestAdd/normal
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/normal (0.00s)
PASS

上記のテストは TableDrivenTest とサブテストを組み合わせています。どちらも現場でよく使われます。サブテストを用いると各テストごとに結果がわかるようになります。 TableDrivenTest はさまざまな Input/Output パターンを網羅するのに便利です。上記のテストをシンプルに書き直すと以下のようになります。

func add(a, b int) int {
return a + b
}

func TestAdd(t *testing.T) {
if add(1, 2) != 3 {
t.Errorf("add() = %v, want %v", add(1, 2), 3)
}
}

https://play.golang.org/p/-TQObdTOqPA

$ go test -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
PASS

テストの失敗をレポートしたい

Go はテストのアサーションを提供していません。理由は公式のFAQで紹介されています。先程の TestAddc 関数のように、テストが失敗したことを開発者が自ら実装する必要があります。失敗したことを示すには T.Error (T.Errorf)T.Fatal (T.Fatalf) を用いることができます。

T.Fatal を用いると T.Fatal が実行された以降のテストは呼び出されずに終了します。テストが失敗したことを示すには T.Error を使い、テストの初期化など、処理が失敗するとその後のテストが無意味になる場合は T.Fatal を用いると良いでしょう。以下のように t.Fatalf を用いた場合は、その後のテストの処理 t.Log("after add() ...") が呼び出されていないことが分かります。deferT.Cleanup といった後処理は呼び出されます。

func TestAdd(t *testing.T) {
type args struct {
a int
b int
}
tests := []struct {
name string
args args
want int
}{
{name: "fail", args: args{a: 1, b: 2}, want: 30},
{name: "normal", args: args{a: 1, b: 2}, want: 3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// t.Fatalf でテストが失敗した場合でもクリーンアップ処理は呼び出される
t.Cleanup(func() {
t.Log("cleanup!")
})

// t.Fatalf でテストが失敗した場合でも defer の処理は呼び出される
defer t.Log("defer!")

if got := add(tt.args.a, tt.args.b); got != tt.want {
t.Fatalf("add() = %v, want %v", got, tt.want)
}
// t.Fatalf でテストが失敗した場合は以下は呼び出されない
t.Log("after add() ...")
})
}
}
$ go test -v
=== RUN TestAdd
=== RUN TestAdd/fail
diff_test.go:31: add() = 3, want 30
panic.go:617: defer!
diff_test.go:24: cleanup!
=== RUN TestAdd/normal
diff_test.go:34: after add() ...
diff_test.go:35: defer!
diff_test.go:24: cleanup!
--- FAIL: TestAdd (0.00s)
--- FAIL: TestAdd/fail (0.00s)
--- PASS: TestAdd/normal (0.00s)
FAIL
exit status 1
FAIL github.com/d-tsuji/go-sandbox 0.156s

https://play.golang.org/p/2NZxz45zGD7

なお t.Fatalf と似たような関数名でログ出力してアプリケーションを終了する log パッケージの Fatalf という関数があります。log.Fatalf のGo Docにもあるように log.Fatalfdefer といった後処理を呼び出さずに即座に os.Exit(1) でアプリケーションが終了します。t.Fatalflog.Fatalf を混乱しないように注意しましょう。

https://play.golang.org/p/ADm5xU7gsfm

テストをスキップしたい

時間がかかるテストなど、自動テストなどではテストをスキップしたい場合があるかもしれません。次のようなある条件の場合は処理にめちゃくちゃ時間がかかる例を考えてみます。

func add(a, b int) int {
time.Sleep(365 * 24 * time.Hour)
return a + b
}

このテストをスキップしたいとします。その場合は func (c *T) Skip(args ...interface{}) というメソッドを用いることでスキップできます。

テストコードに以下を追加します。

			b: 2,
},
want: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 以下を追加
// ---------------------------------
if testing.Short() {
t.Skip("skipping test in short mode.")
}
// ---------------------------------
if got := add(tt.args.a, tt.args.b); got != tt.want {
t.Errorf("add() = %v, want %v", got, tt.want)
}
})
}
$ go test -v -short
=== RUN Test_add
=== RUN Test_add/normal_1
--- PASS: Test_add (0.00s)
--- SKIP: Test_add/normal_1 (0.00s)
main_test.go:35: skipping test in short mode.
PASS
ok github.com/d-tsuji/go-sandbox 0.339s

テストがスキップされていることが分かります。さらっと testing.Short() という関数も用いましたが Short()testing パッケージに含まれている関数で、-short フラグがセットされていると true になります。そのためテストを実施するときに -short というフラグを付与したときだけテストがスキップされる、そうでないときはスキップされずテストが実施される、というように使い分けることができます。

標準パッケージでもテストのスキップが実装されているのを色々見ることができます。以下は io/ioutil/ioutil_test.go からの抜粋です。特定の条件を満たす場合にテストをスキップするように実装されています。

io/ioutil/ioutil_test.go
func TestReadOnlyWriteFile(t *testing.T) {
if os.Getuid() == 0 {
t.Skipf("Root can write to read-only files anyway, so skip the read-only test.")
}

// ...

テストを並列に実施したい

以下のような謎に sleep する実装があるとします。テストを並列に実施したいとします。

func add(a, b int) int {
time.Sleep(time.Duration(a+b) * time.Second)

return a + b
}

並列化せず、逐次サブテストを実施すると 3 + 5 + 7 = 15 から約 15 秒テスト実施に時間がかかります。

func Test_add(t *testing.T) {
type args struct {
a int
b int
}
tests := []struct {
name string
args args
want int
}{
{"normal_1", args{1, 2}, 3},
{"normal_2", args{2, 3}, 5},
{"normal_3", args{3, 4}, 7},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := add(tt.args.a, tt.args.b); got != tt.want {
t.Errorf("add() = %v, want %v", got, tt.want)
}
})
}
}
$ go test -v
=== RUN Test_add
=== RUN Test_add/normal_1
=== RUN Test_add/normal_2
=== RUN Test_add/normal_3
--- PASS: Test_add (15.01s)
--- PASS: Test_add/normal_1 (3.00s)
--- PASS: Test_add/normal_2 (5.00s)
--- PASS: Test_add/normal_3 (7.00s)
PASS
ok github.com/d-tsuji/go-sandbox 15.315s

並列にテストを実施するには func (t *T) Parallel() メソッドを用いることができます。上記のテストに tt := ttt.Parallel() を追記します。

for _, tt := range tests {
// 以下を追加
// ---------
tt := tt
// ---------
t.Run(tt.name, func(t *testing.T) {
// 以下を追加
// -----------
t.Parallel()
// -----------
if got := add(tt.args.a, tt.args.b); got != tt.want {
t.Errorf("add() = %v, want %v", got, tt.want)
}
})
}

上記の実装を用いてテストをします。するとテストが並列に実施されていることが分かります。結果としてテストにかかった時間は約 7 秒になりました。

$ go test -v
=== RUN Test_add
=== RUN Test_add/normal_1
=== PAUSE Test_add/normal_1
=== RUN Test_add/normal_2
=== PAUSE Test_add/normal_2
=== RUN Test_add/normal_3
=== PAUSE Test_add/normal_3
=== CONT Test_add/normal_1
=== CONT Test_add/normal_3
=== CONT Test_add/normal_2
--- PASS: Test_add (0.00s)
--- PASS: Test_add/normal_1 (3.00s)
--- PASS: Test_add/normal_2 (5.00s)
--- PASS: Test_add/normal_3 (7.00s)
PASS
ok github.com/d-tsuji/go-sandbox 7.317s

ループ時に割り当てているローカル変数 tt を捕捉することは重要です。Go ではループで用いられる変数は同じアドレスを使います。

func main() {
b := []byte("abcde")
for i, c := range b {
fmt.Printf("i: %#v, c: %#v ------- &i: %#v, &c: %#v\n", i, string(c), &i, &c)
}
}
// 変数のアドレスがすべて同じアドレスを参照している
i: 0, c: "a" ------- &i: (*int)(0x40e020), &c: (*uint8)(0x40e024)
i: 1, c: "b" ------- &i: (*int)(0x40e020), &c: (*uint8)(0x40e024)
i: 2, c: "c" ------- &i: (*int)(0x40e020), &c: (*uint8)(0x40e024)
i: 3, c: "d" ------- &i: (*int)(0x40e020), &c: (*uint8)(0x40e024)
i: 4, c: "e" ------- &i: (*int)(0x40e020), &c: (*uint8)(0x40e024)

https://play.golang.org/p/_tSALjq8yZ6

そのため、テストを並列に実行するときは tt := tt などとして変数をシャドウイングし、並列で実行しているテストに影響がないようにする必要があります。

ただし、テストケース自体がそもそも並列に実行できない場合、例えばデータベース上のテーブルへの UPDATE や INSERT が発生し、テストケースで競合する場合、テストを並列に実行することはできないため、注意が必要です。

テストの前処理や後処理を実施したい

テストをしていると、前処理や後処理をしたい場合があると思います。主な例の 1 つとしてデータベースの処理化があるでしょう。テストを実施する前処理としてあるデータを INSERT しておいて、テスト実施後に対象のテーブルのデータを削除する、といったものです。

そのような共通的な前処理や後処理を実施したい場合は

func TestMain(m *testing.M)

関数を用いることができます。

例を見てみます。

func f() {
fmt.Println("なんらかの処理")
}

以下のように TestMain を用いてテストを制御するとテストの前後(今回の場合は f() の前後)に処理を実行できます。m.Run() の実行結果を取得して os.Exit() するのが慣用的です。

func Test_f(t *testing.T) {
f()
}

func TestMain(m *testing.M) {
fmt.Println("前処理")
status := m.Run()
fmt.Println("後処理")

os.Exit(status)
}
$ go test
前処理
なんらかの処理
PASS
後処理
ok github.com/d-tsuji/go-sandbox 0.298s

参考までに google/trillian という OSS で TestMain がどのように使われているか確認してみます。以下はテスト用のデータベースを openTestDBOrDie() として作成し、テスト終了後に done(context.Background()) という処理を実施しています。

func TestMain(m *testing.M) {
flag.Parse()
if !testdb.PGAvailable() {
glog.Errorf("PG not available, skipping all PG storage tests")
return
}

var done func(context.Context)
db, done = openTestDBOrDie()

status := m.Run()
done(context.Background())
os.Exit(status)
}

https://github.com/google/trillian/blob/master/storage/postgres/storage_test.go#L168-L181

  • 後処理には T.Cleanup が便利

Go1.14 でテスト時に生成したリソースを便利に後処理できる関数が登場しました。T.Cleanup です。

func (c *T) Cleanup(f func())

リソースの後処理という意味では defer を用いてクリーンアップできるので、今までとあまり変わらないのでは?と思うかもしれません。T.Cleanup を便利に使えることが実感できるシーンの 1 つとして、テストに必要な前処理をテストとは別の関数で実施している場合があります。

簡単な例ですが、テストの前準備としてテスト用のファイルを生成する必要があったとして、テスト終了後に削除したい場合、以下のような実装が考えられます。TempFile 関数ではリソースをクローズする処理を呼び出し元に返却する必要があり、呼び出し元で後処理として teardown 関数を呼び出すことになります。

testutil/file.go
func TempFile(t *testing.T, content []byte) (name string, teardown func()) {
file, err := ioutil.TempFile("", "test")
if err != nil {
t.Error(err)
}

if err = ioutil.WriteFile(file.Name(), content, 0644); err != nil {
t.Error(err)
}

return file.Name(), func() {
syscall.Unlink(file.Name())
}
}

呼び出し元の処理です。

file, delete := testutil.TempFile(t, nil /* something */)
defer delete()

T.Cleanup を用いると前処理を実施する関数内でリソースの後処理が実施できるようになります。TempFile 関数の例であれば、以下のように t.Cleanup を用いることができます。関数から return したタイミングで呼び出される defer とは異なり、テストが完了したタイミングで Cleanup 処理が呼び出されます。

func TempFile(t *testing.T, content []byte) string {
file, err := ioutil.TempFile("", "test")
if err != nil {
t.Error(err)
}
t.Cleanup(func() { syscall.Unlink(file.Name()) })

if err = ioutil.WriteFile(file.Name(), content, 0644); err != nil {
t.Error(err)
}

return file.Name()
}

ファイルでなくても、テスト用のデータベースや HTTP サーバの teardown など、テストに必要なリソースの teardown 処理を同じ関数内に記述することができ、テストコードが簡潔になります。

カバレッジを取得したい

標準パッケージのカバレッジを取得してみます。今回は io パッケージ( ioutil も含む)のテストのカバレッジを取得することにします。カバレッジは go test コマンドの引数として -covermode=count を付与すれば取得できます。 -coverprofile=c.out で結果をファイルに保存します。

Go のコードは /Go/src にあるものとします。以下のコマンドを実行してカバレッジを取得します。

$ go test io/... -covermode=count -coverprofile=c.out
ok io 0.620s coverage: 95.2% of statements
ok io/ioutil 0.311s coverage: 68.9% of statements

以下のコマンドで上記の出力ファイルを簡単に HTML で可視化できます。

$ go tool cover -html=c.out -o coverage.html

このような感じでどのパスが通っていないか確認できます。

カバレッジ表示結果

あるディレクトリ配下のテストをすべて実施したい

たとえば標準パッケージの例だと、io パッケージはテスト対象に含めるが、その他のパッケージはテスト対象に含めない…といった要領です。これはテストのコマンドというよりはパッケージのコマンドになります。以下のように ... の文字列を用いてワイルドカードとしてテスト対象のファイルを選択できます。詳細は go help packages とすることで確認できます。

以下の場合は io パッケージに含まれるすべてのテストを実行します。

go test io/...

同様に io パッケージに含まれる ioutil パッケージのみテストしたい場合は以下のようになります。

$ go test -v io/ioutil/...
=== RUN TestReadFile
--- PASS: TestReadFile (0.00s)
=== RUN TestWriteFile
--- PASS: TestWriteFile (0.00s)
=== RUN TestReadDir
--- PASS: TestReadDir (0.00s)
=== RUN TestTempFile
--- PASS: TestTempFile (0.00s)
=== RUN TestTempFile_pattern
--- PASS: TestTempFile_pattern (0.00s)
=== RUN TestTempDir
--- PASS: TestTempDir (0.00s)
=== RUN TestTempDir_BadDir
--- PASS: TestTempDir_BadDir (0.00s)
=== RUN ExampleReadAll
--- PASS: ExampleReadAll (0.00s)
=== RUN ExampleReadFile
--- PASS: ExampleReadFile (0.00s)
PASS
ok io/ioutil 0.342s

一部のテストケースのみ実施したい

実行対象のテストを抽出するには -run フラグを用いることできます。以下のように正規表現を用いて、一致するテストのみを実行できます。

-run regexp

例えば io パッケージで Pipe に関するテストのみ実行したいとしましょう。Pipe に関するテストはテスト関数に Pipe の文字列が含まれるものとします。以下のように実行するとテストの関数名に Pipe が含まれるテストのみ実行されていることが分かります。

go test -v io/... -run Pipe
=== RUN TestPipe1
--- PASS: TestPipe1 (0.00s)
=== RUN TestPipe2
--- PASS: TestPipe2 (0.00s)
=== RUN TestPipe3
--- PASS: TestPipe3 (0.00s)
=== RUN TestPipeReadClose
--- PASS: TestPipeReadClose (0.01s)
=== RUN TestPipeReadClose2
--- PASS: TestPipeReadClose2 (0.00s)
=== RUN TestPipeWriteClose
--- PASS: TestPipeWriteClose (0.01s)
=== RUN TestPipeWriteClose2
--- PASS: TestPipeWriteClose2 (0.00s)
=== RUN TestPipeCloseError
--- PASS: TestPipeCloseError (0.00s)
=== RUN TestPipeConcurrent
=== RUN TestPipeConcurrent/Write
=== RUN TestPipeConcurrent/Read
--- PASS: TestPipeConcurrent (0.00s)
--- PASS: TestPipeConcurrent/Write (0.00s)
--- PASS: TestPipeConcurrent/Read (0.00s)
=== RUN ExamplePipe
--- PASS: ExamplePipe (0.00s)
PASS
ok io 0.345s
testing: warning: no tests to run
PASS
ok io/ioutil 0.575s [no tests to run]

サブテストの一部のみ実施したい

TableDrivenTest でテストを実装していると、ある 1 つのテストケースで複数のサブテストを実装します。サブテストの一部のみテストを実行したい場合はテストケースを絞り込むときと同様に -run フラグを用いることができます。

func TestAdd(t *testing.T) {
type args struct {
a int
b int
}
tests := []struct {
name string
args args
want int
}{
{name: "fail", args: args{a: 1, b: 2}, want: 30},
{name: "normal", args: args{a: 1, b: 2}, want: 3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := add(tt.args.a, tt.args.b); got != tt.want {
t.Errorf("add() = %v, want %v", got, tt.want)
}
})
}
}

TestAddnormal のサブテストのみ実行されていることが分かります。サブテストがたくさんある場合で、一部のみ実行したい場合に便利です。

$ go test -v -run Add/mal
=== RUN TestAdd
=== RUN TestAdd/normal
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/normal (0.00s)
PASS
ok github.com/d-tsuji/go-sandbox 0.181s

テストのキャッシュを削除したい

Go でテストをしていると以下のように cached の文字列を見ることがあると思います。テストのキャッシュを削除するにはどのようにすれば良いでしょうか。

$ go test io/... -run Pipe
ok io (cached)
ok io/ioutil (cached) [no tests to run]

テストのキャッシュは Go1.10 から組み込まれました。テストのキャッシュの詳細は go1.10#test が詳しいです。キャッシュ使わない場合はを明示的に -count=1 と指定すればよいです。-count=1 と明示的に指定するとテストはキャッシュされなくなります。

go test io/... -run Pipe -count=1
ok io 0.304s
ok io/ioutil 0.563s [no tests to run]

...

go test io/... -run Pipe -count=1
ok io 0.314s
ok io/ioutil 0.576s [no tests to run]

...

go test io/... -run Pipe -count=1
ok io 0.321s
ok io/ioutil 0.575s [no tests to run]

また go clean -cache を用いてもビルドキャッシュ全体を削除することができ、テストのキャッシュも削除できます。

テストコードの雛形を楽に作りたい

Tips ですが IDE などの機能を使うと少し便利になるかもしれません。例えば GoLand の機能を用いるとデフォルトで TableDrivenTest の雛形を生成してくれます。詳細は割愛しますが VS Code を用いて開発する場合でも同様に雛形を生成できます。

テストコードを生成するIDEの機能

構造体、マップやスライスの比較を実施したい

map のキーと値が一致しているかどうか確認するようなテストがしたいとしましょう。mapslicespec#Comparison_operators にもあるように比較演算子を用いて比較することができません。map に含まれるキーと値が同じものを含んでいるかどうかはループを回して確認しないといけないのでしょうか。

reflect パッケージに含まれる reflect.DeepEqual を用いると map のような比較演算子で比較できないオブジェクトの比較ができます。

net/http パッケージにある request_test.go の中でクエリパラメータなどに用いられる map (map[string][]string 型)は以下のように比較して同値かどうかテストしています。

net/http/request_test.go
wantForm := url.Values{
"language": []string{"Go"},
"name": []string{"gopher"},
"skill": []string{"go-ing"},
"field1": []string{"value1"},
"field2": []string{"initial-value2", "value2"},
}
if !reflect.DeepEqual(req.Form, wantForm) {
t.Fatalf("req.Form = %v, want %v", req.Form, wantForm)
}

https://github.com/golang/go/blob/master/src/net/http/request_test.go#L210-L219

Go は言語自体にテスティングフレームワークを提供してません。Where is my favorite helper function for testing? に理由が記載されていますが、端的に言うと開発者が適切にエラーハンドリングすることと適切なエラーメッセージの出力を実装することが重要であるためです。

とはいえ薄い便利なツールを使いたくなるシチュエーションはあるのではないでしょうか。reflect.DeepEqual を使った同値チェックは google/go-cmp を使うとより便利にテストができるので、私は reflect.DeepEqual の代わりとして google/go-cmp を用いることが多いです。

go-cmp のリポジトリにある構造体を比較するサンプルの例を見てみます。以下のような net.IP 型や time.Time 型をフィールドに持つ構造体の比較です。

example_test.go
type (
Gateway struct {
SSID string
IPAddress net.IP
NetMask net.IPMask
Clients []Client
}
Client struct {
Hostname string
IPAddress net.IP
LastSeen time.Time
}
)

func MakeGatewayInfo() (x, y Gateway) {
x = Gateway{
SSID: "CoffeeShopWiFi",
IPAddress: net.IPv4(192, 168, 0, 1),
NetMask: net.IPv4Mask(255, 255, 0, 0),
Clients: []Client{{
Hostname: "ristretto",
IPAddress: net.IPv4(192, 168, 0, 116),
}, {
Hostname: "aribica",
IPAddress: net.IPv4(192, 168, 0, 104),
LastSeen: time.Date(2009, time.November, 10, 23, 6, 32, 0, time.UTC),
}, {
Hostname: "macchiato",
IPAddress: net.IPv4(192, 168, 0, 153),
LastSeen: time.Date(2009, time.November, 10, 23, 39, 43, 0, time.UTC),
}, {
Hostname: "espresso",
IPAddress: net.IPv4(192, 168, 0, 121),
}, {
Hostname: "latte",
IPAddress: net.IPv4(192, 168, 0, 219),
LastSeen: time.Date(2009, time.November, 10, 23, 0, 23, 0, time.UTC),
}, {
Hostname: "americano",
IPAddress: net.IPv4(192, 168, 0, 188),
LastSeen: time.Date(2009, time.November, 10, 23, 3, 5, 0, time.UTC),
}},
}
y = Gateway{
SSID: "CoffeeShopWiFi",
IPAddress: net.IPv4(192, 168, 0, 2),
NetMask: net.IPv4Mask(255, 255, 0, 0),
Clients: []Client{{
Hostname: "ristretto",
IPAddress: net.IPv4(192, 168, 0, 116),
}, {
Hostname: "aribica",
IPAddress: net.IPv4(192, 168, 0, 104),
LastSeen: time.Date(2009, time.November, 10, 23, 6, 32, 0, time.UTC),
}, {
Hostname: "macchiato",
IPAddress: net.IPv4(192, 168, 0, 153),
LastSeen: time.Date(2009, time.November, 10, 23, 39, 43, 0, time.UTC),
}, {
Hostname: "espresso",
IPAddress: net.IPv4(192, 168, 0, 121),
}, {
Hostname: "latte",
IPAddress: net.IPv4(192, 168, 0, 221),
LastSeen: time.Date(2009, time.November, 10, 23, 0, 23, 0, time.UTC),
}},
}
return x, y
}

この構造体が同値かどうかテストしてみます。上記の実装の通り MakeGatewayInfo は異なる 2 つの変数 xy を返す関数です。これを go-cmpreflect.DeepEqual のそれぞれを用いて比較してみます。

example_test.go
func TestMakeGatewayInfoDeepEqual(t *testing.T) {
got, want := MakeGatewayInfo()
if !reflect.DeepEqual(got, want) {
t.Errorf("MakeGatewayInfo() got = %v, want %v", got, want)
}
}

func TestMakeGatewayInfoGoCmp(t *testing.T) {
got, want := MakeGatewayInfo()
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("MakeGatewayInfo() mismatch (-want +got):\n%s", diff)
}
}

実行すると以下のような結果を得ることができます。

$ go test example_test.go
--- FAIL: TestMakeGatewayInfoDeepEqual (0.00s)
example_test.go:15: MakeGatewayInfo() got = {CoffeeShopWiFi 192.168.0.1 ffff0000 [{ristretto 192.168.0.116 0001-01-01 00:00:00 +0000 UTC} {aribica 192.168.0.104 2009-11-10 23:06:32 +0000 UTC} {macchiato 192.168.0.153 2009-11-10 23:39:43 +0000 UTC} {espresso 192
.168.0.121 0001-01-01 00:00:00 +0000 UTC} {latte 192.168.0.219 2009-11-10 23:00:23 +0000 UTC} {americano 192.168.0.188 2009-11-10 23:03:05 +0000 UTC}]}, want {CoffeeShopWiFi 192.168.0.2 ffff0000 [{ristretto 192.168.0.116 0001-01-01 00:00:00 +0000 UTC} {aribica 192.
168.0.104 2009-11-10 23:06:32 +0000 UTC} {macchiato 192.168.0.153 2009-11-10 23:39:43 +0000 UTC} {espresso 192.168.0.121 0001-01-01 00:00:00 +0000 UTC} {latte 192.168.0.221 2009-11-10 23:00:23 +0000 UTC}]}
--- FAIL: TestMakeGatewayInfoGoCmp (0.00s)
example_test.go:22: MakeGatewayInfo() mismatch (-want +got):
{t.Gateway}.IPAddress:
-: s"192.168.0.2"
+: s"192.168.0.1"
{t.Gateway}.Clients[4].IPAddress:
-: s"192.168.0.221"
+: s"192.168.0.219"
{t.Gateway}.Clients[?->5]:
-: <non-existent>
+: t.Client{Hostname: "americano", IPAddress: s"192.168.0.188", LastSeen: s"2009-11-10 23:03:05 +0000 UTC"}
FAIL
FAIL command-line-arguments 0.304s
FAIL

go-cmp の結果は何が同値で、何が同値でなかったか、同値でなかったときは取得した値と想定する値は何か明示的にわかるのがよいです。他にもオプションで条件をカスタマイズできます。(やりすぎ注意)

社内でも stretchr/testify/assert を使う勢なども見かけます。Go のテスティングフレームに関する話は好みが分かれるところだと思うので、深くは触れません。

APIサーバにアクセスするテストをしたい

net/http/httptest を用いると簡単にテスト用のモックサーバをたてることができます。リクエストに対して Hello, client というレスポンスを返却する API を用いた、API クライアントの視点で単体テストを実施する例を考えてみます。

httptest.NewServer にハンドラを渡すことでハンドラの振る舞いをするローカルの HTTP サーバを提供してくれます。デフォルトだとこのテスト用の HTTP サーバは 127.0.0.1:0 で起動します。ポートが 0 にバインドされていますが、これは空いている任意ポートを割り当てます。なお、ブラウザからデバッグしたいなどの用途として httptest.serve フラグを渡すこともできます。-httptest.serve=127.0.0.1:18888 などとしてフラグを指定した場合、HTTP サーバはブロックされます。

x_test.go
func TestX(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, client")
}))
defer ts.Close()

res, err := http.Get(ts.URL)
if err != nil {
t.Error(err)
}

got, err := ioutil.ReadAll(res.Body)
res.Body.Close()

// API からのレスポンスを用いてなんならの処理をする

if res.StatusCode != 200 {
t.Errorf("GET %s: expected status code = %d; got %d", ts.URL, 200, res.StatusCode)
}
if string(got) != "Hello, client\n" {
t.Errorf("expected body %v; got %v", "Hello, client", string(got))
}
}

https://play.golang.org/p/i1Dz5alS4hQ

実際にはテストで用いる構造体のフィールドに URL を保持できるようにして httptest で起動させたモックサーバの URL に切り替えるなどことが多いのではないか、と思います。

func setup() (client *Client, mux *http.ServeMux, serverURL string, teardown func()) {
mux = http.NewServeMux()

apiHandler := http.NewServeMux()
apiHandler.Handle(baseURLPath+"/", http.StripPrefix(baseURLPath, mux))

// ...

server := httptest.NewServer(apiHandler)

client = NewClient(nil)
url, _ := url.Parse(server.URL + baseURLPath + "/")
client.BaseURL = url
client.UploadURL = url

return client, mux, server.URL, server.Close
}

https://github.com/google/go-github/blob/master/github/github_test.go#L31-L64

APIサーバのハンドラのテストをしたい

上記の「APIサーバにアクセスするテストをしたい」の項目では httptest.NewServer でモックサーバを立てて、API クライアントという視点でリクエストを発行してテストをしました。今度は API サーバを提供する視点から、ハンドラの単体テストを実施する場合を考えてみます。httptest.NewRequest を用いると、ハンドラの単体テストを簡潔に実施できます。以下のようなハンドラを考えてみます。

func helloHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("hello world!"))
}

このハンドラに対して GET リクエストしたときに hello world! というレスポンスが得られるかどうかテストします。httptest.NewRequest はテストのためのリクエストを生成するのに便利です。httptest.NewRequest で生成した *http.Request をハンドラに渡すことができます。また httptest.NewRecorder() で生成できる *httptest.ResponseRecorder を用いるとハンドラのレスポンスを記録できます。

これらを用いて、以下のようにハンドラから想定のレスポンスが得られるかどうかテストできます。

import (
"io/ioutil"
"net/http/httptest"
"testing"
)

func TestHelloHandler(t *testing.T) {
r := httptest.NewRequest("GET", "/dummy", nil)
w := httptest.NewRecorder()

helloHandler(w, r)

resp := w.Result()

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Errorf("cannot read test response: %v", err)
}

if resp.StatusCode != 200 {
t.Errorf("got = %d, want = 200", resp.StatusCode)
}

if string(body) != "hello world!" {
t.Errorf("got = %s, want = hello world!", body)
}
}
$ go test -v
=== RUN TestHelloHandler
--- PASS: TestHelloHandler (0.00s)
PASS
ok github.com/d-tsuji/go-sandbox/x 0.186s

httptest.NewRequest()httptest.NewRecorder() を用いることで、Echogo-swagger といった Web フレームワークを用いて構築しているハンドラも同様にテストできます。

ちなみにリクエストは http.NewRequestWithContext (http.NewRequest) でも生成できるのは?と思うかもしれません。何が異なるかというと、httptest.NewRequesthttp.Handler に適しているリクエストを返却します。一方で http.NewRequestWithContext (http.NewRequest) は Client.DoTransport.RoundTrip に適しているリクエストを返却します。そのため、単体テストの用途で直接ハンドラにリクエストを渡す場合は httptest.NewRequest を用いることになります。

データ競合をテストで検知したい

テストでレースコンディションを検知したい場合はどうすればよいでしょうか。レースコンディションが起きていないかどうか確認するのに -race フラグが役に立ちます。

公式の The Go Blog の例を引用します。

race_test.go
package main

import (
"fmt"
"math/rand"
"testing"
"time"
)

func TestFoo(te *testing.T) {
start := time.Now()
var t *time.Timer
t = time.AfterFunc(randomDuration(), func() {
fmt.Println(time.Now().Sub(start))
t.Reset(randomDuration())
})
time.Sleep(5 * time.Second)
}

func randomDuration() time.Duration {
return time.Duration(rand.Int63n(1e9))
}

上記の実装は異なるゴルーチンから変数 t の読み書きをしていて、レースコンディションを引き起こします。これは -race をつけることで確認できます。-racego test 以外にも以下のように使うことができます。

$ go test -race mypkg    // test the package
$ go run -race mysrc.go // compile and run the program
$ go build -race mycmd // build the command
$ go install -race mypkg // install the package
> go test -v -race race_test.go
=== RUN TestFoo
947.9969ms
==================
WARNING: DATA RACE
Read at 0x00c00009a030 by goroutine 9:
command-line-arguments.TestFoo.func1()
C:/Users/d-tsuji/go/src/github.com/d-tsuji/go-sandbox/race_test.go:15 +0x128

Previous write at 0x00c00009a030 by goroutine 8:
command-line-arguments.TestFoo()
C:/Users/d-tsuji/go/src/github.com/d-tsuji/go-sandbox/race_test.go:13 +0x194
testing.tRunner()
C:/Go/src/testing/testing.go:909 +0x1a0

Goroutine 9 (running) created at:
time.goFunc()
C:/Go/src/time/sleep.go:168 +0x58

Goroutine 8 (running) created at:
testing.(*T).Run()
C:/Go/src/testing/testing.go:960 +0x658
testing.runTests.func1()
C:/Go/src/testing/testing.go:1202 +0xad
testing.tRunner()
C:/Go/src/testing/testing.go:909 +0x1a0
testing.runTests()
C:/Go/src/testing/testing.go:1200 +0x528
testing.(*M).Run()
C:/Go/src/testing/testing.go:1117 +0x306
main.main()
_testmain.go:44 +0x22a
==================
1.0319969s
1.6985715s
1.9336667s
2.22163s
2.7717493s
3.4048429s
3.7370048s
3.9208427s
4.4012115s
--- FAIL: TestFoo (5.00s)
testing.go:853: race detected during execution of test
FAIL
FAIL command-line-arguments 5.122s
FAIL

上記のようにレースコンディションが発生している場合は、テストが FAIL になります。

-race をつけてテストすると多少実行に時間がかかりますが、想定しないレースコンディションが発生して、テストが FAIL になるとデバッグが大変です。テストの際にデフォルトで -race をつけておくのも一つの方法です。

テストデータを置いておきたい

テストの Input や Output になるファイルを testdata という名前のディレクトリに置いておくことができます。testdata ディレクトリに含まれるファイルはテストのときのみ用いられます。標準パッケージの中でたくさん用いられていますが、image パッケージの例を上げると https://github.com/golang/go/tree/master/src/image/testdata といったものです。

テストにヘルパー関数を使いたい

複数のテストで用いるような共通の関数をヘルパー関数として実装する場合があると思います。テスト用のヘルパー関数の特徴は以下です。

Go Friday 傑作選 より引用

テスト用のヘルパー関数は以下のように実装されます。

func mustUrlParse(t *testing.T, s string) *url.URL {
t.Helper()
u, err := url.Parse(s)
if err != nil {
t.Fatal(err)
}
return u
}

ヘルパー関数に t.Helper() を付与すると何が嬉しいのでしょうか。上記のヘルパー関数 mustUrlParse を用いたテストを実施してみて確認してみます。

func TestX(t *testing.T) {
type args struct {
str string
}
tests := []struct {
name string
args args
want int
}{
{"normal1", args{"http://example.com/"}, 0},
{"normal2", args{"%zzzzz" /* error occured */}, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url := mustUrlParse(t, tt.args.str)

// 何らかの処理をしてテストする
})
}
}
  • ヘルパー関数から t.Helper() を除いた場合
go test -v
=== RUN TestX
=== RUN TestX/normal1
=== RUN TestX/normal2
--- FAIL: TestX (0.00s)
--- PASS: TestX/normal1 (0.00s)
--- FAIL: TestX/normal2 (0.00s)
x_test.go:44: parse %zzzzz: invalid URL escape "%zz"
FAIL
exit status 1
FAIL github.com/d-tsuji/go-sandbox/x 0.299s
  • ヘルパー関数に t.Helper() がある場合
go test -v
=== RUN TestX
=== RUN TestX/normal1
=== RUN TestX/normal2
--- FAIL: TestX (0.00s)
--- PASS: TestX/normal1 (0.00s)
--- FAIL: TestX/normal2 (0.00s)
x_test.go:34: parse %zzzzz: invalid URL escape "%zz"
FAIL
exit status 1
FAIL github.com/d-tsuji/go-sandbox/x 0.296s

上記の 2 通りの結果を比較すると FAIL になった行が何行目を指しているのか、が変わっています。

// t.Helper() がない場合
x_test.go:44: parse %zzzzz: invalid URL escape "%zz"

// t.Helper() がある場合
x_test.go:34: parse %zzzzz: invalid URL escape "%zz"

t.Helper() を付けた場合は t.Run() 内の _ = mustUrlParse(t, tt.args.str) の行を指しています。付けなかった場合はヘルパー関数でエラーになった t.Fatal(err) の行を指しています。t.Helper() を付けた場合のほうが、テストケース内のどの行で失敗したか分かりやすくなり、エラーの原因を探りやすくなるのではないでしょうか。

ブラックボックステストをしたい

あるパッケージ mypkg があって以下のようなファイル構成になっているとします。ブラックボックステストなどを実施するときにパッケージ名を分けたい、などのニーズがあるかもしれません(そんなに多くは無いと思いますが)。Go ではディレクトリ名とパッケージ名は同じである必要があるため、異なるパッケージ名のファイルを作ることはできません。しかし例外的に mypkg_test という、mypkg_test を付与したパッケージ名は用いることができます。Test packages にもあるように Go では _test の接尾語をもつパッケージ名は別のパッケージとして扱われます。パッケージ名を mypkgmypkg_test とで分けることでテスト実施時に mypkg の非公開の関数を呼び出すことはできなくなります。似たような議論を Proper package naming for testing with the Go language で見ることができます。

具体的には以下のようなファイル構成になっていて x.gox_test.go で別のパッケージ名とできます。

mypkg
|--x.go
|--x_test.go
x.go
package mypkg

// ...
x_test.go
// mypkg ディレクトリにあるテストファイルでも _test を付けたパッケージ名を混在できる
package mypkg_test

// ...

用途(単体・インテグレーション)などによってテストを切り替えたい

テストは単体テストの他、インテグレーションテストなど用途によっていくつか存在します。用途によって実行するテストを分けるにはどのようにすれば良いでしょうか。ビルドタグ を用いる方法やディレクトリを分ける方法があります。

  • ビルドタグを用いる方法

以下のようにビルドタグ // +build integration を付与します。このようにすると go test -tags=integration とタグを付けたときのみテストが実行されるようになります。タグがない場合はテスト対象に含まれません。

// +build integration

package x

import (
"testing"
)

func TestX(t *testing.T) {

// connecting server and some processing and so on...

t.Log("run integration test")
}

OSS から具体例をあげると以下のようなものです。以下の例はテストのパッケージ名に _test をつけることでテストであることを明示的に分けています。テストで用いる関数は、テスト内でインポートしています。

// Copyright 2019 Google Inc. All Rights Reserved.
// This file is available under the Apache license.
// +build integration

package mtail_test

import (
"os"
"path"
"testing"
"time"

"github.com/google/mtail/internal/mtail"
"github.com/google/mtail/internal/testutil"
)

func TestNewProg(t *testing.T) {
tmpDir, rmTmpDir := testutil.TestTempDir(t)
defer rmTmpDir()

logDir := path.Join(tmpDir, "logs")
progDir := path.Join(tmpDir, "progs")
err := os.Mkdir(logDir, 0700)
if err != nil {
t.Fatal(err)
}
err = os.Mkdir(progDir, 0700)
if err != nil {
t.Fatal(err)
}

m, stopM := mtail.TestStartServer(t, 0, false, mtail.ProgramPath(progDir), mtail.LogPathPatterns(logDir+"/*"))
defer stopM()

startProgLoadsTotal := mtail.TestGetMetric(t, m.Addr(), "prog_loads_total").(map[string]interface{})

testutil.TestOpenFile(t, progDir+"/nocode.mtail")
time.Sleep(time.Second)

progLoadsTotal := mtail.TestGetMetric(t, m.Addr(), "prog_loads_total").(map[string]interface{})

mtail.ExpectMetricDelta(t, progLoadsTotal["nocode.mtail"], startProgLoadsTotal["nocode.mtail"], 1)

}

https://github.com/google/mtail/blob/master/internal/mtail/new_prog_integration_test.go#L1-L44

  • ディレクトリを分ける

シンプルながら、インテグレーションテストは integration などのディレクトリに対象のテストケースを含めることが多いのではないでしょうか。その integration パッケージと // +build integration のビルドタグを組み合わせる方法も考えられます。OSS だと以下のようなイメージです。

モックを使ってテストをしたい

外部サービスに依存する実装のテストをするときに、外部サービスをモックしたい場合があります。特に外部の API を実行した結果に依存する場合、API のレートリミットやテストの結果が外部 API の仕様変更などに引きづられ、テスト結果が不安定になります。どのようにすればモック化できるでしょうか。方法はいくつかありますが、基本的な考えはインターフェースを満たす実装を、本物の実装とモックで切り替えていくことになります。

データベースアクセスする処理をモックする簡単な疑似コードを見てみます。データベースからユーザの検索と作成をします。インターフェースのメソッドは以下のようにあるとします。

type DataStore interface {
FindUser(ctx context.Context, id int) (*User, error)
CreateUser(ctx context.Context, name string) error
}
type User struct {
id int
name string
}

通常の実装は以下のような感じを想定します。ポイントは DataStore のインターフェースをファクトリ関数である NewDataStore で返すところです。

type UserDataStore struct {
*sql.DB
}

// DataStore のインターフェースを返すのがポイント
func NewDataStore(db *sql.DB) DataStore {
return &UserDataStore{db}
}

func (u *UserDataStore) FindUser(ctx context.Context, id int) (*User, error) {
var user *User
row := u.QueryRowContext(context.Background(), `SELECT * FROM user WHERE userId = ?`, id)
if err := row.Scan(&user); err != nil {
return nil, err
}
return user, nil
}

func (u *UserDataStore) CreateUser(ctx context.Context, name string) error {
// ...
return nil
}

あるテストをする際に、このデータベースに依存する実装をモックする例は以下のようになります。インターフェースを満たす、モックオブジェクトに想定の結果が取得できるメソッドを実施します。

type MockUserDataStore struct{}

func NewMockDataStore() DataStore {
return &MockUserDataStore{}
}

func (m *MockUserDataStore) FindUser(ctx context.Context, id int) (*User, error) {
return &User{
id: 1,
name: "test user",
}, nil
}

func (m *MockUserDataStore) CreateUser(ctx context.Context, name string) error {
fmt.Println("create user. dummy")
return nil
}

このモックしたオブジェクトをテストで用いる場合は以下のようになります。

func TestXXX(t *testing.T) {
storage := NewMockDataStore()
// ...
user, err = storage.FindUser(context.TODO(), 1)
// ...
}

上記のようにして外部依存する処理をモックできます。モックの struct やメソッドの実装が手間なので gomock を使うこともあるかもしれません(gomock に関して詳細は説明を割愛します)。

インターフェースを用いたテストに関しては Golangにおけるinterfaceをつかったテスト技法 が詳しいです。またインターフェースの埋め込みを用いたモックに関しては Golangにおけるinterfaceをつかったテストで Mock を書く技法 が詳しいです。

余談ですが、テストがモックに依存しすぎるとなんのためのテストをしているか分からなくなったり、リファクタリングのときに辛かったりするので、モックを使ったテストは用法用量に注意です。

ゴルーチンリークを検出したい

ゴルーチンがリークするような実装になっていることをテスト時に確認したいかもしれません。公式のツールチェーンには含まれていませんが、goleak はゴルーチンリークの検出に役に立ちます。このツールを用いるとテスト時にゴルーチンがリークしている場合にテストが FAIL になります。

簡単な例で確認してみます。

leak.go
func X(send chan struct{}) {
go func() {
for {
select {
// チャネルになんらかの値が書き込まれない or チャネルがクローズされない限り
// ブロックされて、ゴルーチンリークになる
case <-send:
return
}
}
}()
}
leak_test.go
func TestDoLeak(t *testing.T) {
closedchan := make(chan struct{})
close(closedchan)

tests := []struct {
name string
ch chan struct{}
}{
{
name: "not leak",
// クローズされているチャネルを渡す
ch: closedchan,
},
{
name: "leak",
ch: make(chan struct{}),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer goleak.VerifyNone(t)
X(tt.ch)
})
}
}

結果は以下のようになります。想定通り leak のテストが FAIL になっていることが分かります。

> go test -v
=== RUN TestDoLeak
=== RUN TestDoLeak/not_leak
=== RUN TestDoLeak/leak
--- FAIL: TestDoLeak (0.46s)
--- PASS: TestDoLeak/not_leak (0.00s)
--- FAIL: TestDoLeak/leak (0.46s)
leaks.go:78: found unexpected goroutines:
[Goroutine 11 in state chan receive, with github.com/d-tsuji/go-sandbox/t.X.func1 on top of the stack:
goroutine 11 [chan receive]:
github.com/d-tsuji/go-sandbox/t.X.func1(0xc000010360)
C:/Users/d-tsuji/go/src/github.com/d-tsuji/go-sandbox/t/leak.go:13 +0x40
created by github.com/d-tsuji/go-sandbox/t.X
C:/Users/d-tsuji/go/src/github.com/d-tsuji/go-sandbox/t/leak.go:10 +0x46
]
FAIL
exit status 1
FAIL github.com/d-tsuji/go-sandbox/t 0.761s

https://play.golang.org/p/wWGWe8_S0WN

まとめ

テストに関する Go の基本的な機能のまとめてみました。なるべく使い方のイメージがわかるように具体的な例を多く載せました。参考にした素晴らしいドキュメントの URL は以下に記載しています。合わせてみてみてください。

参考資料