はじめに
最近初めてGoで単体テストを書きました。
テストの書き方や実行方法などは、多くの記事が存在しておりそれらを利用することで大体理解することができました。
(参考までに自分が調べた記事をいくつか載せておきます)
簡単なテストが書けるようになってから「テストには色々な書き方があるな」と思いました。
そこで、Google のスタイルガイドや初めての Go 言語、ネット上の記事を参考に Go で書くテストのベストプラクティスについて調べました。
Go のテスト初心者の自分にとって役立ったことを、アウトプットするために本記事を投稿します!
対象読者
- なんとなく Go のテストを書いている人
- Goのテストのベストプラクティスを知りたい人
TL;DR
HOW | To Do |
---|---|
テストを一気通貫で実行したい |
Error とFatal を使い分けるアサーションで比較しない |
セットアップにバグがあるかを確認したい | Error とFatal を使い分ける |
テストの出力結果で詳細な情報を知りたい |
入力をテスト結果に含める 適切な書式指定子を使う |
構造体を返す関数をテストしたい | cmpを使って比較する |
複数のテストケースを使ったテストしたい | テーブルテストを使う |
他のコードと依存関係があるコードをテストしたい | スタブを使う |
前提:テストをなぜ行うのか?
「そもそもなぜテストをするのか」、目的をもってテストを書いていますか?
テストの目的を知ることで、何のテストが必要か、どんなテストを書くべきかが迷わなくなると思います。
調べたり、実際に書いてみて自分が感じたことは、以下の 3 つです。
コードの質を向上させる(バグ発見、処理の高速化など)
開発の過程で機能を追加したらなぜか動かなくなったり、直したはずのバグが復活することありませんか?
そのときにログやコードを読んで、バグを手動で見つけるのはすごく困難です。
テストを書くことで、機能を変更しても動作確認やバグの発見が容易に行えます!(とても便利)
コードを書いた人の意図を明示する
他の人が書いたコードは読みにくいと感じる人が多いと思います。
この理由の一つに、「コードを書いた人の意図や頭の中の仕様を理解できない」というのがあると思っています。
テストを書くことで、コードを書いた人が想定している入力と出力を確認でき、処理の流れが追いやすくなります。
また、メンテナンスするときには成功パターンと失敗パターンが明確になっていることで、担当者が変わっても同じ処理を書くことがより簡単になります。
要件通りの機能が実現できているかの確認
ある単体テストを書いているときに、テストケースが通るためのコードを書いてしまい、バグを放置する事態を起こしてしまいました。
なぜこの事態が起きたかというと、テストを書く目的が機能が実現できているかの確認ではなく、テストを通すことが目的になったからです。
テストを通ることを目的にしたことで、テストのコードが動かない理由はテストコードであり、テスト対象のコードは絶対に合っていると思い込んでしまいました。
その経験から、テストを書くときは「要件通りの機能が実現できているかを確認すること」が目的であることを忘れないようにしましょう!
状況に応じてError
とFatal
を使い分ける
テストの失敗を表す場合は、Error
とFatal
の2つで実行結果を表示します。
Error
とFatal
は以下の違いがあります。
関数 | 処理内容 |
---|---|
Error | テキストをエラーログとして出力する。 テストに失敗しても、後続のテストは実行する。 |
Errorf | フォーマットでエラーログを出力する。 テストに失敗しても、後続のテストは実行する。 |
Fatal | テキストをエラーログとして出力する。 テストに失敗すると、後続のテストは実行しない。 |
Fatalf | フォーマットでエラーログを出力する。 テストに失敗すると、後続のテストは実行しない。 |
基本的に検証したい関数の実際の出力結果と、期待する出力を比較する場合はError
を使うべきです。
なぜなら、テストを一気通貫で最後まで実行することで、バグを修正する度にテストを再実行して次のバグを見つける手間を減らせるからです!
一方で、テストのセットアップで失敗したかを確認する場合にはFatal
を使うべきです。
セットアップなど、他のテストケースとも共通する機能にバグが存在する場合には、問題の切り分けのためにError
ではなくFatal
を使うことが推奨されています。
以下の関数でテストコードを比較します。
func calc(a, b int) (int, error) {
return a + b, nil
}
Bad
func TestCalc(t *testing.T) {
data := []struct {
name string
num1 int
num2 int
expected int
}{
{"addition - Success", 2, 2, 3},
{"addition - Success", 3, 3, 6},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
result, err = calc(d.num1, d.num2)
// 1ケース目で処理が終了
if resutl != d.expected {
t.Fatalf("func(%d, %d) = %d, want = %v", d.num1, d.num2, result, expected)
}
})
}
Good
func TestCalc(t *testing.T) {
data := []struct {
name string
num1 int
num2 int
expected int
}{
{"addition - Success", 2, 2, 3},
{"addition - Success", 3, 3, 6},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
result, err = calc(d.num1, d.num2)
if resutl != d.expected {
t.Errorf("func(%d, %d) = %d, want = %v", d.num1, d.num2, result, expected)
}
})
}
}
アサーションで実行結果と想定結果を比較しない
Googleのスタイルガイドによると、アサーションはシステムの正しさを確認する関数であり、期待された値ではなかった場合はテストを失敗させるものとみなされています。
JavaやPythonはアサーションを公式が提供していますが、Goは公式が提供するアサーションが存在していません。
この理由は、アサーションを使うことでエラーの出力が適当になることです。(公式が明言。)
アサーションは期待された値ではない場合にテストを失敗させることを実現するコードは下記通りです。
package assert
func IsNotNil(t *testing.T, name string, val interface{}) {
if val == nil {
t.Fatalf("data %s = nil, want not nil", name)
}
}
このコードからわかる通り、テストを失敗させるためにFatal
でエラー内容を出力させることが多いです。
したがって、アサーションはテストを早期に中止し、テストが正しく行われたかに関する情報を省略させます。
バグの解消に必要な情報が省略されることによって、バグの原因追求が遅くなる可能性があります。
有名なtestifyライブラリのアサーションでは、Error
を使って結果を出力しています。(このライブラリであれば使ってもいいと思っている。)
// エラー出力に関するコード
func Fail(t TestingT, failureMessage string, msgAndArgs ...interface{}) bool {
if h, ok := t.(tHelper); ok {
h.Helper()
}
content := []labeledContent{
{"Error Trace", strings.Join(CallerInfo(), "\n\t\t\t")},
{"Error", failureMessage},
}
// Add test name if the Go version supports it
if n, ok := t.(interface {
Name() string
}); ok {
content = append(content, labeledContent{"Test", n.Name()})
}
message := messageFromMsgAndArgs(msgAndArgs...)
if len(message) > 0 {
content = append(content, labeledContent{"Messages", message})
}
t.Errorf("\n%s", ""+labeledOutput(content...))
return false
}
// 実行結果と期待値を比較するコード
func NotNil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool {
if !isNil(object) {
return true
}
if h, ok := t.(tHelper); ok {
h.Helper()
}
return Fail(t, "Expected value not to be nil.", msgAndArgs...)
}
参照:
Bad
package assert
// アサーションを定義
func IsNotNil(t *testing.T, name string, val interface{}) {
if val == nil {
t.Fatalf("data %s = nil, want not nil", name)
}
}
func StringEq(t *testing.T, name, got, want string) {
if got != want {
t.Fatalf("data %s = %q, want %q", name, got, want)
}
}
// テストコード
var obj BlogPost
assert.IsNotNil(t, "obj", obj)
assert.StringEq(t, "obj.Type", obj.Type, "blogPost")
assert.IntEq(t, "obj.Comments", obj.Comments, 2)
assert.StringNotEq(t, "obj.Body", obj.Body, "")
Good
// テストコード
var got BlogPost
want := BlogPost{
Comments: 2,
Body: "Hello, world!",
}
// `cmp`と`fmt`を使う
if !cmp.Equal(got, want) {
t.Errorf("blog post = %v, want = %v", got, want)
}
入力をテスト結果に含める
入力をテスト出力結果に含めることで、関数の実行結果と期待される値のみの場合よりもエラーの原因特定が容易になります。
もし入力が不透明な場合には、テストケースにそれぞれ名前をつけ、エラーメッセージの一部として結果を表示することでエラーの原因追求が容易になる場合もあります。
Bad
fmt.Printf("gotResult %v, wantResult %v", got, want)
Good
fmt.Printf("YourFunc(%v) = %v, wantResult %v", num1, got, want)
関数が構造体を返す場合は、cmp を使って比較する
数値やbooleanは演算子==
を使って、比較することが可能です。
その一方で、比較演算子を使ってポインタ同士を比較する場合は、ポインタが示す値ではなく同じ変数を指しているかが比較されます。
そのため、構造体を比較する場合、構造体のフィールドにスライスやマップ型が存在すると比較できません。(そもそもスライス型を含んでいるとコンパイルエラーになる)
よって、関数の実行結果が構造体の場合は、cmp
パッケージを使った比較を行うべきです!
cmp
パッケージの使用例として、値が等しいか確認する方法はcmp.Equal
、オブジェクト間の差分を確認する方法にはcmp.Diff
を使うなどがあげられます。
以下の関数でテストコードを比較します。
p1 := Person{
Name: "太郎",
Age: 20,
}
p2 := Person{
Name: "太郎",
Age: 20,
}
Bad
// 等しいと判断される
// 構造体のフィールドがStirngとInt型のため正しい結果が取得できている
if p1 != p2 {
// 比較結果を表示
}
Good
if !cmp.Equal(p1, p2) {
// 比較結果を表示
}
テスト結果の出力には、適切な書式指定子を使う
テスト結果を出力するときは、書式指定子を使うことで関数の実行結果と期待される値を出力することが多いです。
この時に、使用する書式指定子によって出力結果から得られる情報量が変化します。
文字列データを表示 -> %q
s := `hello
world`
// %vを使う
fmt.Printf("%v\n", s)
// 結果表示
// hello
// world
// %sを使う
fmt.Printf("%s\n", s)
// 結果表示
// hello
// world
// %qを使う:改行、タブが含まれることがわかる
fmt.Printf("%q\n", s)
// 結果表示
// "hello\n\tworld"
小さい構造体を表示 -> %+v
type Person struct {
id int
Name string
}
p := &Person{
id: 1,
Name: "taro",
}
// %vを使う
fmt.Printf("%v\n", p)
// 結果表示
// &{1 tutuz}
// %+vを使う:構造体がより詳細に表示される
fmt.Printf("%+v\n", p)
// 結果表示
// &{id:1 Name:tutuz}
常にエラー内容を比較することはやめる
単体テストにおいて文字列比較やcmp.Equal
を使って、特定の入力に対して特定のエラーが出力されることを確認するとき、エラーメッセージが書き換わるとテストに大きな変更を加える必要がでてきます。
そのため、関数が返すエラーを確認する場合は、エラー内容の文字列を比較するのではなく、エラーがないか(!=nil)で比較すべきです。
しかし、エラーメッセージ特定の条件を満たしているかを確認したい場合もあると思います。
その場合は文字列を比較しても問題はないです!
以下はメソッドが正しく実行されるかどうかをテストしたい関数です。(エラー内容は重要ではない)
この関数のテストコードを比較します。
func PrintError() int, error {
// 今後、エラー内容が書き換わる可能性がある
return 1, errors.New("Error")
}
Bad
gotNum, gotErr := PrintError()
wantErr := "Error"
if err != wantErr {
t.Errorf("得られたエラー: %d, 予想されたエラー: %d", gotErr , wantErr)
}
Good
gotNum, gotErr := PrintError()
if err != nil {
t.Errorf("得られたエラー %d", gotErr)
}
複数のテストケースがある時はテーブルテストを使う
関数やメソッドが正しい挙動をしているか検証するためには、複数のテストケースが必要な場合が多いです。
複数のテストケースを一つのテスト関数で実行する場合は、テーブルテストを利用することで繰り返しを避けることができ、読みやすさが向上します。
また、テストケースが1つだけの場合でもテーブルテストを使うことで、拡張性の高いテストコードを作成できます。
テーブルテストを使うパターンでは、無名構造体にテスト名、検証する関数の引数、戻り値を持たせることで、各スライスごとにテストケースが見やすいコードを書くことが可能です。
テーブルテストの無名構造体の最初の引数をテスト名(テスト内容を説明する文字列)にすることで、どのテストが行われるかがわかるようになるため、バグの解消にも役立ちます。
テスト名は、以下を意識して名前を決定すべきです。
- 読みやすい
- 説明文ではなく、関数の識別子の役割を持たせる
- スペースはアンダースコア(_)に置き換える
- スラッシュは使わない(テストフィルターにとって特別な意味を持つため)
例えば、以下の関数のテストコードをテーブルテストを使うパターンと、使わないパターンで書きます。
func DoMath(num1, num2 int, op string) (int, error) {
switch op {
case "+":
return num1 + num2, nil case "-":
return num1 - num2, nil default:
return 0, fmt.Errorf("未知の演算子 %s", op)
}
}
Bad
func TestDoMath(t *testing.T) {
result, err := DoMath(2, 2, "+")
if result != 4 {
t.Errorf("DoMath(2, 2) = %d, 期待する結果: 4", result)
}
if err != nil {
t.Errorf("得られたエラー: nil, 予想されたエラー: %d", err)
}
result2, err2 := DoMath(2, 2, "-")
if result2 != 0 {
t.Errorf("DoMath(2, 2) = %d, 期待する結果: 0", result2)
}
if err2 != nil {
t.Errorf("得られたエラー: nil, 予想されたエラー: %d", err2)
}
}
Good
func TestDoMathTable(t *testing.T) {
data := []struct {
// テストを説明するテスト名
name string
num1 int
num2 int
op string
// 期待する値
expected int
// 期待するエラー内容
errMsg string
}{
{"addition - Success", 2, 2, "+", 4, ""},
{"subtraction - Success", 2, 2, "-", 0, ""},
}
// Runメソッドで、定義したテストケースを順に起動させる
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
// テストしたい関数を実行
result, err := DoMath(d.num1, d.num2, d.op)
if result != d.expected {
t.Errorf("DoMath(%d, %d) = %d, 期待する結果: %d", num1, num2, result, d.expected)
}
var errMsg string
if err != nil {
errMsg = err.Error()
}
if errMsg != d.errMsg {
t.Errorf("得られたエラー: %d, 予想されたエラー: %d", errMsg, d.errMsg, )
}
})
}
}
依存関係がある処理のテストは、スタブを使う
他のコードと依存関係があるコードのテストを作成したい場合は、スタブやモックを使ってテストコードを作成することが多いです。
モックとスタブの違いは下記のとおりです。(Qiitaの記事がわかりやすいので参考にしてください。)
- モック:テスト対象から出力を受ける
- スタブ:テスト対象に都合のいいデータを出力する
Googleのベストプラクティスには、スタブが紹介されていたので今回はスタブについてまとめます。
なお、Goでモックを使う場合は、gomockなどのライブラリを使うとことで実装できます。
スタブを使って以下の関数から2つのテストコードを作成します。
- 特定の入力に対して同じ出力を返す
- 同じメソッドを異なるテストから入出力を変えて呼び出す ← 拡張性が高いこちらをより推奨
type User struct{}
type Pet struct {
Name string
}
// スタブ対象
type Entities interface {
GetUser(id string) (User, error)
GetPets(userID string) ([]Pet, error)
}
type Logic struct {
Entities Entities
}
// テスト対象
func (l Logic) GetPetNames(userId string) ([]string, error) {
// スタブを使って、出力結果を操作したい
pets, err := l.Entities.GetPets(userId)
if err != nil {
return nil, err
}
out := make([]string, 0, len(pets))
for _, p := range pets {
out = append(out, p.Name)
}
return out, nil
}
特定の入力に対して同じ出力を返す
// 構造体に Entities を埋め込むことで、GetPetNamesStub は Entities のスタブとして利用できる
type GetPetNamesStub struct {
Entities
}
// ある入力に対して特定の値を返す、GetPetNamesStub構造体(スタブ)のメソッドとして定義
func (ps GetPetNamesStub) GetPets(userID string) ([]Pet, error) {
// userIDに応じて、特定の値を返す
switch userID {
case "1":
return []Pet{{Name: "Bubbles"}}, nil
case "2":
return []Pet{{Name: "Stampy"}, {Name: "Snowball II"}}, nil
default:
return nil, fmt.Errorf("不正なID: %s", userID)
}
}
func TestLogicGetPetNames(t *testing.T) {
// スタブが返す値を定義しない
data := []struct {
name string
userID string
petNames []string
}{
{"正常終了, 1匹のペットを入力", "1", []string{"Bubbles"}},
{"正常終了, 2匹のペットを入力", "2", []string{"Stampy", "Snowball II"}},
{"異常終了, 不正なuserID", "3", nil},
}
// スタブのインスタンスを作成
l := Logic{GetPetNamesStub{}}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
// スタブのメソッドであるGetPetsが呼び出され、userIDに応じたpetNamesを返す
petNames, err := l.GetPetNames(d.userID)
if err != nil {
t.Error(err)
}
if diff := cmp.Diff(d.petNames, petNames); diff != "" {
t.Error(diff)
}
})
}
}
同じメソッドを異なるテストから入出力を変えて呼び出す
// テストケースごとに異なる関数をセットすることができるスタブとして利用
type EntitiesStub struct {
// テスト対象コードの Entities で定義されているメソッドに合わせて、構造体上に関数フィールドを定義
getUser func(id string) (User, error)
getPets func(userID string) ([]Pet, error)
}
// メソッドを定義して、Entities インターフェースに適応させる
func (es EntitiesStub) GetUser(id string) (User, error) {
return es.getUser(id)
}
func (es EntitiesStub) GetPets(userID string) ([]Pet, error) {
return es.getPets(userID)
}
func TestLogicGetPetNames(t *testing.T) {
data := []struct {
name string
// 上記で定義したメソッドに適応させる
getPets func(userID string) ([]Pet, error)
userID string
petNames []string
errMsg string
}{
{
"正常終了, 1匹のペットを入力",
// 任意の入力に対して、返り値を指定する
func(userID string) ([]Pet, error) {
return []Pet{{Name: "Bubbles"}}, nil
},
"1",
[]string{"Bubbles"},
"",
},
{
"正常終了, 2匹のペットを入力",
func(userID string) ([]Pet, error) {
return []Pet{{Name: "Stampy"}, {Name: "Snowball II"}}, nil
},
"2",
[]string{"Stampy", "Snowball II"},
""
},
{
"異常終了, 不正なuserID",
func(userID string) ([]Pet, error) {
return nil, errors.New("不正なID: 3")
},
"3",
nil,
"不正なID: 3"
},
}
// インスタンスを作成、テストケースごとにスタブを切り替えるため初期化
l := Logic{}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
// テストケースごとにスタブを作成する
// インスタンスに上記で定義した EntitiesStub をセットする。このときに定義した d.getPets を呼び出せる
l.Entities = {
getPets: d.getPets
}
// テスト対象の関数を実行
petNames, err := l.GetPetNames(d.userID)
if diff := cmp.Diff(petNames, d.petNames); diff != "" {
t.Error(diff)
}
var errMsg string
if err != nil {
errMsg = err.Error()
}
if errMsg != d.errMsg {
t.Errorf("予想されたエラーメッセージ `%s`, 得られたエラーメッセージ `%s`", d.errMsg, errMsg)
}
})
}
}
おまけ:testing
パッケージを使う
おまけですが、Go の標準ライブラリではtesting
パッケージが提供されています。
testing
パッケージには、良いテストを書くための機能があるため、状況に応じて使うことが推奨されています。
中でも、便利そうなものをまとめてみたので、興味ある方は使ってみてください。
ベンチマークテスト
プログラムのパフォーマンスを計測し、コードを最適化したいときに利用する。
// テスト対象
func ItoaByFmt(n int) {
var s []string
for i := 0; i < n; i++ {
s = append(s, fmt.Sprint(i))
}
}
// ベンチマークでテスト
// BenchmarkXxXというテスト関数を用意する
// 引数は、testing.B型
func BenchmarkItoaByFmt(b *testing.B) {
ItoaByFmt(b.N)
}
# -bench を指定して、ベンチマークを実行
$ go test -bench .
goos: linux
goarch: amd64
pkg: example
cpu: ** 使用しているPCのCPUが表示される **
BenchmarkItoaByFmt-8 9352432 155.6 ns/op
PASS
ok example 1.594s
カバレッジの計算
テストされていないコードを特定したい場合に利用する。
# -cover を指定して、カバレッジを取得
$ go test -cover .
ok example 0.002s coverage: 0.0% of statements [no tests to run]
特定のテストをスキップ
時間のかかるテストを除いて、テストを実行したい場合に利用する
func TestCase1(t *testing.T) {
got := 1
want := 1
if got != want {
t.Errorf("got %d, want %d", want, got)
}
}
func TestCase2(t *testing.T) {
// -short が指定されているかを判定
if testing.Short() {
// テストケースをスキップする
t.SkipNow()
}
got := 1
want := 1
if got != want {
t.Errorf("got %d, want %d", want, got)
}
}
# -short を指定して、特定のテストをスキップする
$ go test -v -short
=== RUN TestCase1
--- PASS: TestCase1 (0.00s)
=== RUN TestCase2
--- SKIP: TestCase2 (0.00s)
PASS
ok example 0.002s
データ競合を特定
並列処理に起因するデータ競合を特定して場合に利用する。
// テスト対象
func getCounter() int {
var counter int
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func() {
for i := 0; i < 1000; i++ {
counter++
}
wg.Done()
}()
}
wg.Wait()
return counter
}
// テストコード
func TestGetCounter(t *testing.T) {
counter := getCounter()
if counter != 5000 {
t.Error("想定外のカウンタ値:", counter)
}
}
# -race を指定して、データ競合箇所を特定
$ go test -race
==================
WARNING: DATA RACE
Read at 0x00c00001a218 by goroutine 11:
test_examples/race.getCounter.func1()
/mnt/c/Users/rytsh/Documents/Study/lgo-main/example/ch13/race/race.go:12 +0x46
Previous write at 0x00c00001a218 by goroutine 9:
test_examples/race.getCounter.func1()
/mnt/c/Users/rytsh/Documents/Study/lgo-main/example/ch13/race/race.go:12 +0x58
Goroutine 11 (running) created at:
test_examples/race.getCounter()
/mnt/c/Users/rytsh/Documents/Study/lgo-main/example/ch13/race/race.go:10 +0x8d
test_examples/race.TestGetCounter()
/mnt/c/Users/rytsh/Documents/Study/lgo-main/example/ch13/race/race_test.go:6 +0x2b
testing.tRunner()
/usr/local/go/src/testing/testing.go:1446 +0x216
testing.(*T).Run.func1()
/usr/local/go/src/testing/testing.go:1493 +0x47
・・・
テストの並行実行
テストを並行実行することで、テスト実行時間を短縮したい場合に利用する。
異なるパッケージのテストを並行化したい場合
# -p を指定して、異なるパッケージのテストを並行化
$ go test ./.. -p=1
$ go test ./.. -p=2
同一パッケージ内で異なるテストを並行化
func TestParallel(t *testing.T) {
t.Run("case1", func(t *testing.T) {
// t.Parallel()を呼び出したケースを並列化
t.Parallel()
})
t.Run("case2", func(t *testing.T) {
// t.Parallel()を呼び出したケースを並列化
t.Parallel()
})
t.Run("case3", func(t *testing.T) {
// t.Parallel() 呼び出しをしない
})
}
# -parallel を指定して、同一パッケージ内で異なるテストを並行化
$ go test ./.. -parallel=1
$ go test ./.. -parallel=2
まとめ
この記事では Go のテスト初心者に向けて、汎用的なテストのベストプラクティスをまとめました。
Google のスタイルガイドにも書いていましたが、スタイルガイドを守ることは絶対的ではありません。
人や組織によってもコードの慣習は様々なため状況に応じて使い分けてください!
参考
Google のスタイルガイド
初めての Go 言語
【初心者向け】テストコードの方針を考える(何をテストすべきか?どんなテストを書くべきか?)