Goのテーブル駆動テストをわかりやすく書きたい
Goでテーブル駆動テストを書いていると、書いているときは「すげー読みやすくテスト書けてるぞ!」と思っていても、落ち着いてから見てみると「なんだこれ...訳がわからん...」となることがあると思います。(自分はよくあります。)
この記事は、このようなことを解決するのに役立つtipsについてまとめています。主にテストケースについて焦点を当てています。
テストしやすいコード設計に興味がある方は
や を参考にしてください。はじめに
この記事はパーソナライズGopher道場で学んだことを元に書いています。
そして、この記事で紹介するテーブル駆動テストの書き方は主観に基づいており、
あくまでテストの1つの書き方にすぎないです。
なので、「この書き方をしないとダメ!」というものではないので、みなさんの考え方やプロダクトに合わせて、柔軟にこの記事で紹介するtipsを取り入れていただけると幸いです。
結論
さきに結論から述べると、
「テーブル駆動テストはテーブルとテストの部分でデータとロジックを分離する」
ということが、わかりやすくテーブル駆動テストを書く上で重要です。
そして、それを実現するために考えるべきことはテーブルでのテストケースの並び方です。
具体的には
- どのくらいの数のデータを用いるのか
- 入力と出力のみに注視する
この2つを意識すればわかりやすくテーブル駆動テストを書くことができます。
後の章ではこれらについて順を追って説明していきます。
テーブル駆動テスト
テーブル駆動テストって何?
テストケースにおいて入力と出力(期待される値)が1行になって複数の行で構成されるものをテーブルと呼びます。
テストケースのテーブルが与えられたときに、すべてを繰り返し処理し、ケースごとにテストを実行するものをテーブル駆動テストと呼びます。
大事なことは、
「テーブル駆動テストはツールやパッケージではなく、よりクリーンなテストを作成するための方法と展望に過ぎない」
ということです。
メリット
テーブル駆動テストのメリットは、
- 新しいテストケースを追加することが簡単
- 入力と出力に何を期待しているかが簡単に理解できる
- シンプルなまま、想定しているケースを網羅できる
- 問題点の再現が簡単
があります。
どうしたら書けるか?
では、どうしたらわかりやすいテーブルを書くことができるのでしょうか。
架空の施設の入場料金を計算するコードをもとに説明していきます。
基本的にはコードを追っていただければ分かるように書いたつもりです。
この施設の入場料金を実装してみると、
package calc
import (
"errors"
"time"
)
const defaultFee = 1000
func Fee(admissionTime time.Time) (int, error) {
fee := float64(defaultFee)
hour := admissionTime.Hour()
switch {
case hour >= 2 && hour < 5:
return 0, errors.New("現在は入場できない時間帯です")
case hour < 8:
fee *= 0.9
case hour >= 22:
fee *= 1.2
}
return int(fee), nil
}
このようになりました。
そして、この関数の挙動を確かめるためにテストも書いてみます。
package calc_test
import (
"hoge/calc"
"testing"
"time"
)
func TestFee(t *testing.T) {
cases := []struct {
name string
in time.Time
want int
expectErr bool
}{
{
name: "daytime_10:00",
in: time.Date(2022, time.February, 1, 8, 0, 0, 0, time.UTC),
want: 1000,
expectErr: false,
},
{
name: "midnight_22:00",
in: time.Date(2022, time.February, 1, 22, 0, 0, 0, time.UTC),
want: 1200,
expectErr: false,
},
{
name: "early_morning_5:00",
in: time.Date(2022, time.February, 1, 5, 0, 0, 0, time.UTC),
want: 900,
expectErr: false,
},
{
name: "err_2:00",
in: time.Date(2022, time.February, 1, 2, 0, 0, 0, time.UTC),
want: 0,
expectErr: true,
},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := calc.Fee(tt.in)
if tt.expectErr {
if err == nil {
t.Error("want err")
}
} else {
if err != nil {
t.Error("not want err")
}
}
if got != tt.want {
t.Errorf("want = %d, but got = %d", tt.want, got)
}
})
}
}
このようになりました。これでもテスト自体は通るので特に実装上の問題はないです。
しかし、現在の状況だと、テストケースが縦に長すぎます。
今はテストケースが4つしかないのであまり困らないですが、これがテストケースが増えてくると、「このテストケースは他と何が違うんだろう?」とふと考えた際に上にスクロールして戻らないといけなくなります。
なので、ここから改善していきます!
1. mapにしてみよう
まず初めに気になるのが、テーブルの中にテストケース名が含まれていることです。
さきほど、「テーブル駆動テストはテーブルとテストの部分でデータとロジックを分離する」と述べたのにも関わらず、テーブル内にロジックが混ざりこんでいます。
これを分離させていきます。その方法として1番初めに考えられるのが、構造体ではなくmapでテーブルを表現するということです。
具体的には以下のコードのようにします。(便宜上テストケース部分のみ表示します。)
cases := map[string]struct {
in time.Time
want int
expectErr bool
}{
"daytime_10:00": {
in: time.Date(2022, time.February, 1, 8, 0, 0, 0, time.UTC),
want: 1000,
expectErr: false,
},
"midnight_22:00": {
in: time.Date(2022, time.February, 1, 22, 0, 0, 0, time.UTC),
want: 1200,
expectErr: false,
},
"early_morning_5:00": {
in: time.Date(2022, time.February, 1, 5, 0, 0, 0, time.UTC),
want: 900,
expectErr: false,
},
"err_2:00": {
in: time.Date(2022, time.February, 1, 8, 0, 0, 0, time.UTC),
want: 0,
expectErr: true,
},
}
for testName, tt := range test {
//テストを書く
}
mapを用いることで、テストケース名とテストテーブルを分離することができました。
若干ではありますが、縦方向に少し圧縮することもできました。
2. テストケースを1列にしよう
これではまだまだ、いちいち上下にスクロールしないとテストケース同士を比較できません。
次はそこを見やすくしていきます。
そのためにテストケースを1列にします。
cases := map[string]struct {
in time.Time
want int
expectErr bool
}{
"daytime_10:00": {in: time.Date(2022, time.February, 1, 8, 0, 0, 0, time.UTC), want: 1000, expectErr: false},
"midnight_22:00": {in: time.Date(2022, time.February, 1, 22, 0, 0, 0, time.UTC), want: 1200, expectErr: false},
"early_morning_5:00": {in: time.Date(2022, time.February, 1, 5, 0, 0, 0, time.UTC), want: 900, expectErr: false},
"err_2:00": {in: time.Date(2022, time.February, 1, 8, 0, 0, 0, time.UTC), want: 0, expectErr: true},
}
一気に縦方向にスッキリさせることができました。
3. フィールド名をなくそう
縦方向にはスッキリしたものの、今度は左右にスクロールする必要が生まれてしまいました。
Effective goによると、Goの1行の文字列には制限がないようです。
しかし、いくらなんでも長すぎるので、横方向にもスッキリさせていきます。
具体的にはテーブル内の構造体フィールド名を省略させていきます。
フィールド名は初めに定義しているのに加えて、3個目くらいからなくても分かるようになるので必要ありません。
cases := map[string]struct {
in time.Time
want int
expectErr bool
}{
"daytime_10:00": {time.Date(2022, time.February, 1, 8, 0, 0, 0, time.UTC), 1000, false},
"midnight_22:00": {time.Date(2022, time.February, 1, 22, 0, 0, 0, time.UTC), 1200, false},
"early_morning_5:00": {time.Date(2022, time.February, 1, 5, 0, 0, 0, time.UTC), 900, false},
"err_2:00": {time.Date(2022, time.February, 1, 8, 0, 0, 0, time.UTC), 0, true},
}
すこし横方向にもすっきりしました。
しかし、まだ問題があります。引き続き改善していきます。
4. ヘルパー関数を使おう
ここで取り上げるのは、time.Date(2022, time.February, 1, 8, 0, 0, 0, time.UTC)
の部分についてです。
これが省略できれば一気に短くできます。
解決のためにヘルパー関数を用います。ヘルパー関数については
この記事を参考にしてください。ここでは細かいことについては言及しません。func ToDate(t *testing.T, date string) time.Time {
t.Helper()
d, err := time.Parse("2006-01-02 15:04:05", date)
if err != nil {
t.Fatalf("toDate: %v", err)
}
return d
}
そして、このようなヘルパー関数を作成します。これをテストテーブルにも導入すると、
cases := map[string]struct {
in time.Time
want int
expectErr bool
}{
"daytime_10:00": {ToDate(t, "2022-02-01 10:00:00"), 1000, false},
"midnight_22:00": {ToDate(t, "2022-02-01 22:00:00"), 1200, false},
"early_morning_5:00": {ToDate(t, "2022-02-01 05:00:00"), 900, false},
"err_2:00": {ToDate(t, "2022-02-02 02:00:00"), 0, true},
}
横方向にもすっきりしました。
この時点で最初と比べると、とてもわかりやすくなっていることが実感していただけると思います。
でもまだここで終わりではありません!まだ続きます!
5. ヘルパー関数の位置を調整しよう
先ほど追加したヘルパー関数ですが、1つ問題があります。
それは、ヘルパー関数もロジックの一部であるという点です。
ヘルパー関数はテストを助けるためのものであり、テーブルをわかりやすくするものではありません。
なので、外に出してやる必要があります。
func TestFee(t *testing.T) {
cases := map[string]struct {
in string
want int
expectErr bool
}{
"daytime_10:00": {"2022-02-01 10:00:00", 1000, false},
"midnight_22:00": {"2022-02-01 22:00:00", 1200, false},
"early_morning_5:00": {"2022-02-01 05:00:00", 900, false},
"err_2:00": {"2022-02-01 02:00:00", 0, true},
}
for name, tt := range cases {
tt := tt
t.Run(name, func(t *testing.T) {
t.Parallel()
got, err := calc.Fee(ToDate(t, tt.in))
...省略...
このようにすることで、ロジックに直接関わるものがテーブルからなくなり、入力と出力に注視することが可能になりました。
6. テーブルに必要なものを考えよう
ここまで読んでくださったみなさんなら、まだわかりやすくできるところがあることに気づけるのではないでしょうか。
それは、"2022-02-01 10:00:00"
の部分です。
この料金計算処理において必要なのは、「今何時なのか」 ということです。日付や年数による料金の違いはありません。
そのため、誰かの誕生日だろうが、地球滅亡の日だろうとこの処理には関係ないのです。
これは入力と出力に注視できる状態までわかりやすくしたからこそ気づけることです。
ヘルパー関数とテストを直していきます。
func ToDate(t *testing.T, date string) time.Time {
t.Helper()
d, err := time.Parse("2006-01-02 15:04:05", fmt.Sprintf("2022-02-01 %s:00", date))
if err != nil {
t.Fatalf("toDate: %v", err)
}
return d
}
cases := map[string]struct {
in string
want int
expectErr bool
}{
"daytime_10:00": {"10:00", 1000, false},
"midnight_22:00": {"22:00", 1200, false},
"early_morning_5:00": {"05:00", 900, false},
"err_2:00": {"02:00", 0, true},
}
これによって、かなりテーブルがわかりやすくなりました。
でもあともう少しだけ変更したい箇所があります。
7. わかりやすい名前で包もう
次に気になってくるのは、expectErr
のfalse
とtrue
についてです。
今はテストケースが4つしかないのでいいですが、これが増えてくると途中で
「このfalseって何だっけ?どっちだとerrを期待しているんだっけ?」
となる可能性があります。
なので、これをわかりやすい名前で包んであげることにします。
const wantErr, noErr = true, false
cases := map[string]struct {
in string
want int
expectErr bool
}{
"daytime_10:00": {"10:00", 1000, noErr},
"midnight_22:00": {"22:00", 1200, noErr},
"early_morning_5:00": {"05:00", 900, noErr},
"err_2:00": {"02:00", 0, wantErr},
}
このようにすることで、どっちだとerrを期待しているかがわかりやすくなりました。
この状態でもすごいわかりやすいのですが、最後にもう1つだけ修正します!
最後のtipsは一応オプションという形になっています。
8. 体で名を表そう(オプション)
最後に気になるのが、"daytime_10:00"
などのテストケース名です。
テーブルでデータを入出力に注視して並べることに集中すると、テーブル自体はとてもシンプルになっていきます。
そうなると"daytime_10:00"
だけでは何がどうなっているのかよくわからないです。
これが複雑な関数になるとなおさらです。
一番いいのは、テストケース名を第一言語で書くことです。
これは同じ第一言語を話す人同士で開発しているならとてもわかりやすいです。
今回は開発に関わっているのが全員第一言語が日本語であると仮定して進めていきます。
日本語でテストケース名を書くことには1つ問題があります。
それはformatをかけた際に、左右が合わなくなることです。これは日本語がマルチバイトだからです。
cases := map[string]struct {
in string
want int
expectErr bool
}{
"10:00は基本料金": {"10:00", 1000, noErr},
"22:00は深夜料金": {"22:00", 1200, noErr},
"5:00は早朝割": {"05:00", 900, noErr},
"2:00は時間外なのでerr": {"02:00", 0, wantErr},
}
ではどうすればいいでしょうか?ヘルパー関数を使えばいいのです!
func caseNameJp(t *testing.T, date string, want int, expectErr bool) string {
t.Helper()
if expectErr {
return fmt.Sprintf("%sは時間外なのでerrと%dを返す", date, want)
} else {
d := ToDate(t, date)
switch {
case d.Hour() < 8:
return fmt.Sprintf("%sは早朝割があるので%dを返す", date, want)
case d.Hour() >= 22:
return fmt.Sprintf("%sは深夜料金なので%dを返す", date, want)
default:
return fmt.Sprintf("%sは基本料金なので%d", date, want)
}
}
}
このように実装してみました。
今回は簡単な機能だったためほとんど元の実装と同じになりましたが、複雑な実装でも対応の仕方はあると思います。
const wantErr, noErr = true, false
cases := []struct {
in string
want int
expectErr bool
}{
{"10:00", 1000, noErr},
{"22:00", 1200, noErr},
{"05:00", 900, noErr},
{"02:00", 0, wantErr},
}
for _, tt := range cases {
tt := tt
t.Run(caseNameJp(t, tt.in, tt.want, tt.expectErr), func(t *testing.T) {
...省略...
最終的にはこのようになりました。
テーブルとテストでデータとロジックを分離させて、入出力だけにこだわれるようになりました。
これでテストを実行してみると、
go test -v ./...
---- 結果 ----
--- PASS: TestFee (0.00s)
--- PASS: TestFee/10:00は通常料金なので1000 (0.00s)
--- PASS: TestFee/22:00は深夜料金なので1200を返す (0.00s)
--- PASS: TestFee/02:00は時間外なのでerrと0を返す (0.00s)
--- PASS: TestFee/05:00は早朝割があるので900を返す (0.00s)
PASS
ok hoge/calc 0.002s
このような結果になります。
9. ヘッダーコメントをつけよう(おまけ)
8.まででテーブルとテストをデータとロジックに切り分けることはできました。
最後に仕上げとしてテストテーブルにヘッダーコメントをつけましょう。
これをつけることで、はじめて見た人が「???」とならずにすむことが増えるはずです。
ヘッダーコメントの役割はテーブルからはわからないけど、テーブルをみる上で必要なことをテーブルに影響を与えず示すことです。
今回の例には特にコメントを追加すべき点はないので、割愛します。
これについては自分もまだうまく書けている自信がないので、みなさんの中で探ってみてください。
まとめ
ここまで読んでくださり、本当にありがとうございます!
最後にもう一度テーブル駆動テストにおける大事なことをまとめると、
- データとロジックを分離させること
- テーブルのデータの並べ方をまず考えること
- どのくらいの数のテストデータを用いるのか
- 入力と出力に注視すること
となります。是非一部でもいいので、テストを書く際に意識してただけたら幸いです。
誤字・脱字等ありましたらコメントで教えていただけるとありがたいです。
参考文献
- Advanced Testing with Go - Speaker Deck
https://speakerdeck.com/mitchellh/advanced-testing-with-go - Effective Go - The Go Programming Language
https://go.dev/doc/effective_go - How to Write Go Code - The Go Programming Language
https://go.dev/doc/code#Testing - Peter Bourgon・Go best practice, six years in
https://peter.bourgon.org/go-best-practices-2016/ - テストしやすいGoコードのデザイン
https://talks.godoc.org/github.com/tcnksm/talks/2016/12/golang-tokyo/golang-tokyo.slide#1
Discussion