😎

Gopher塾でGoのテストに入門した【パート2 テーブル駆動テスト編】

2022/11/21に公開

おことわり

この記事は Gopher塾でGoのテストに入門した【パート1 入門編】 の続きの記事です.記事としては続いていますが,内容はこの記事の中で完結するように書いているのでこのまま読み進めていただいて大丈夫です.

はじめに

みなさんは「テーブル駆動テスト」という言葉を聞いたことはありますか??自分もGoに触るまでは聞いたことはありませんでした.というのもGo言語でよく使われているテストテクニックなので,他の言語に慣れ親しんでいるとあまり聞き馴染みはありませんね.

この記事を読み終わる頃にはきっとみなさんも自分で書いた既存のテストコードをテーブル駆動にリファクタリングしたくなると思います!!

前置きが長くなってしまったので,早速本題に入ります.

そもそもテーブル駆動テストってなに??

テーブル駆動テストとは,テストを テストデータ(ケース)とロジックに分け,テストデータ(スライス/マップ)のループによってテストを行います.Goの公式リポジトリ内のwiki TableDrivenTests (golang/go/wikiより引用)にも,

Writing good tests is not trivial, but in many situations a lot of ground can be covered with table-driven tests: Each table entry is a complete test case with inputs and expected results, and sometimes with additional information such as a test name to make the test output easily readable. If you ever find yourself using copy and paste when writing a test, think about whether refactoring into a table-driven test or pulling the copied code out into a helper function might be a better option.

と書いてあります.これだけ見ても理解しづらいですね・・・

誤解を恐れずに一言で表すと 「テストを書くのにコピペしてるならテーブル駆動にしようね」 (意訳)ということです.真面目に説明すると,上述した通り ロジックとデータを分離することで テストを行うテクニックです.

ちょっと具体的な例を考えてみましょう.簡単な四則演算を行う関数を作ったとしましょう.

func Calc(op: string, a b int) int {
    // がんばって実装する
    ...
    return retval
}

さてこの関数をテストするとき,みなさんならどうやってテストしますか??1つは,各テストケースごとに関数を実行して,エラー文を書いて・・・と実装する方法です.シンプルですね.Go言語で表現してみると

func TestCalc(t *testing.T){
    // ケース1
    want := 3
    got := Calc("add", 1, 2)
    if want != got {
        // 頑張って実装する
        ...
    }

    // ケース2
    want := 2
    got := Calc("sub", 3, 1)

    // 以下同じようにテストケースを実装する ...
}

こんな感じになるのではないでしょうか??当然テストは実行できますが,同じような記述が繰り返されていて中々しゃばいですね.

このテストがもしも次のように実装されていたらイケイケではないでしょうか??

func TestCalc2(t *testing.T){
    // テストケースをまとめて書いておく
    cases := map[string]Case{
        "add_case1":  {"add", 1, 2, 3},
        ...
        "sub_case1": {"sub", 3, 1, 2},
        ...
        "mul_case1":  {"mul", 2, 3, 6},
        ...
    }

    // 各テストケースについてテストを実行する
    for name, tc := range cases {
        // テストを実行する
        if want != got {
            ...
        }
    }
}

繰り返しの記述がなくなり,スッキリしましたね.これがテーブル駆動テストそのものです.名前こそあまり聞かないものの,この考え方自体は,他の言語でも使うこともありますね.テーブル駆動テストの文化,他の言語にも輸出されないかな・・・

Goでテーブル駆動テストを実装する

さてここまでの話でテーブル駆動テストの概要についてはざっくりと理解できたと思います.ではせっかくなので,実際にGoでテーブル駆動テストを実装してみたいと思います!!

テーブル駆動テストを理解するための簡単な例として,果物の名前と個数から合計金額を計算してくれる関数 calc_fruits を考えます.この関数はこんな風に実装できます.

calc_fruits.go
package calcfruits

func CalcFruitsSum(fruits_prices map[string]int, fruits_name string, fruits_count int) int {
    // 要素が存在するかどうかを確認,存在しない場合は-1を返す
    if count, ok := fruits_prices[fruits_name]; ok {
        return count * fruits_count
    } else {
        return -1
    }
}

ではこの関数に対するテストはどのようになるでしょうか.前回の記事 で紹介した「Goのテストの基礎の基礎」の内容をもとに実装してみると,例えば次のように実装できます.

calc_fruits_test.go
package calcfruits_test

import (
    calcfruits "handson/gopher_juku1/calc_fruits" // ディレクトリ構成に応じて適宜変更してください
    "testing"
)

func TestCalcFruitsSum(t *testing.T) {
    // フルーツの値段を格納するmap
    var fruits_prices = map[string]int{}
    fruits_prices["apple"] = 100
    fruits_prices["orange"] = 80
    fruits_prices["banana"] = 120

    want := 300
    got := calcfruits.CalcFruitsSum(fruits_prices, "apple", 3)
    if want != got {
        t.Errorf("want %d, got %d", want, got)
    }

    want = 160
    got = calcfruits.CalcFruitsSum(fruits_prices, "orange", 2)
    if want != got {
        t.Errorf("want %d, got %d", want, got)
    }

    want = -1
    got = calcfruits.CalcFruitsSum(fruits_prices, "grape", 2)
    if want != got {
        t.Errorf("want %d, got %d", want, got)
    }
}

実際に go test を実行すれば確かにテストはPASSしますが・・・先ほど書いた通り,同じような内容の繰り返しで冗長的な記述になってしまいました.では,テーブル駆動テスト にリファクタリングすることでこの問題を解決してみましょう.

実際に実装してみると・・・

calc_fruits_test.go
func TestCalcFruitsSum2(t *testing.T) {
    // フルーツの値段を格納するmap
    var fruits_prices = map[string]int{}
    fruits_prices["apple"] = 100
    fruits_prices["orange"] = 80
    fruits_prices["banana"] = 120

    type Case struct {
        fruits_name  string
        fruits_count int
        want         int
    }

    cases := map[string]Case{
        "apple":  {"apple", 3, 300},
        "orange": {"orange", 2, 160},
        "grape":  {"grape", 5, -1},
    }

    for name, tt := range cases {
        tt := tt
        t.Run(name, func(t *testing.T) {
            t.Parallel()
            if got := calcfruits.CalcFruitsSum(fruits_prices, tt.fruits_name, tt.fruits_count); tt.want != got {
                t.Errorf("want %d, got %d", tt.want, got)
            }
        })
    }
}

繰り返しの記述がなくなりスッキリしました.イケイケですね.ここに

"banana" を5つ買うと600円になる

ことをテストする新たなケースを追加することを考えてみましょう.最初の実装であれば,

@@ -39,6 +39,11 @@
     if want != got {
         t.Errorf("want %d, got %d", want, got)
     }
+    want = 600
+    got = calc_fruits_sum("banana", 5)
+    if want != got {
+        t.Errorf("want %d, got %d", want, got)
+    }
 }

のようにテストケース全てを新たに書く必要があります.一方で,テーブル駆動であれば,

@@ -52,6 +52,7 @@
         "apple":  {"apple", 3, 300},
         "orange": {"orange", 2, 160},
         "grape":  {"grape", 5, -1},
+        "banana": {"banana", 5, 600},
     }

     for name, tt := range cases {

たった1行の追加でテストケースを書くことができます.テーブル駆動で書くと差分が非常にわかりやすいですね.

「もっとキレイにGoのテーブル駆動テストを書きたい!!」という方は,Goのテーブル駆動テストをわかりやすく書きたい などを参考にしてください.

tips/補足

mapslice の違い

今回はテーブル駆動テストのテストケースを map を使って宣言しました.

cases := map[string]Case{
    "apple":  {"apple", 3, 300},
    "orange": {"orange", 2, 160},
    "grape":  {"grape", 5, -1},
}

実は,slice を使っても同じようにテストを実行することができます.しかし,mapslice では決定的に異なる挙動が1つあります.それは,ループの実行順序が保証されているか否か です.slice であれば,必ず 先頭の要素から順にテストが実行されます.しかし,map ではそのような保証はなく,むしろ

Code should not assume that the elements are visited in any particular order.

「コードは特定の順序で繰り返しが実行されることを仮定してはならない」と決められています.(Go 1 Release Notes より引用)

ということなので,順序に意味があるテストを実装する際には slice を,ない場合には map を使うように気をつけてください.

t.Parallel() ってなに??

実装したテーブル駆動テストのコードの中に t.Parallel() と書いてある箇所がありました.

for name, tt := range cases {
    tt := tt
    t.Run(name, func(t *testing.T) {
        t.Parallel()
        ...

今回の場合に限って言えば,各テストケースのテストを並列に処理する 許可を与えます.あくまでも許可を与えるだけなので,このテストが並列に行われるかどうかまでは保証されません(が現在販売されているPCを ふつうに 使っていれば並列に処理されることが多いです.).

このあたりの話を真面目にするとキリがないので,この辺にしておきます.あとは,tt := tt もわかりにくいですね.

ヘルパー関数を使おう

今回の実装においていえばほとんど使う必要もありませんが,Goの testing パッケージにはヘルパーメソッドを作るための t.Helper があります.t.Helper を記述することで,呼び出し元の関数がヘルパー関数となります.ヘルパー関数を使うことで

  • エラーが発生したときにエラーの発生元を特定しやすくする
  • テストケースにおける共通の前処理を分離することでテーブル駆動テストをキレイにかける

などのメリットがあります.今回の実装だと if got != want のブロックはヘルパー関数を使ってリファクタリングできそうですね. (実装をのせられなくてゴメンナサイ)

まとめ

ということで前回の記事に引き続き,Goのテストについてまとめてみました.ここまで読んでくださったみなさんは,テーブル駆動でテストを書きたくなったかと思います.次回はFuzzingについて記事にしたいと思います!!

参考資料・各種リンク

Discussion