Input中心のブログ

Goにおけるテストのドキュメント化についての話

August 02, 2020

昨日のお昼くらいにTDDBCの基調講演とライブコーディングを見ました。 ライブコーディングの内容はJava, TDDを使ってFizzBuzzを書くといった内容でした。 僕にとってはとても学びが多かったので、Golang, TDDでFizzBuzzを書いてみました。

そこで最終的なテストのリファクタリングで詰まったポイントがありました。 それは以下の内容です。

「テストコードは動作するドキュメントにしろ」

これって、Golangでよく使われているTable Driven Testではどうやってやるんだろう:thinking:となりました。 そこでGolangのTable Driven Testを使いつつ動作するドキュメントを作成するのに試行錯誤したのでその過程を書いておきます。

ちなみに動画内での最終的なJavaでのテストはこんな感じでした。

class FizzBuzzTest {
  @Nested
  class 数を文字列に変換する {
    @Nested
    class _3の倍数の時にはFizzに変換する {
      @Test
      void _3を渡すとFizzに変換する {...}
    }

    @Nested
    class _5の倍数の時にはBuzzに変換する {
      @Test
      void _5を渡すとBuzzに変換する {...}
    }
  }
}

これだとテスト自身がドキュメントになっているのでFizzBuzzの仕様がわかりやすくなっています。 数か月後に変更を加えたくなったときはテストを見れば仕様をすぐに思い出せますね。

これをGo & Table Driven Testを使って表現する方法を考えていきたいと思います。 何も考えずにFizzBuzzをTable Driven Testでやるとこんな感じになると思います。 というか今まで僕はこんな感じでテストを書いていたのでとても反省しています。。

package fizzbuzz_test

func TestConvert(t *testing.T) {
  tests := []struct{
    input int
    output string
  }{
    {1, "1"},
    {2, "2"},
    {3, "Fizz"},
    {5, "Buzz"},
    {15, "FizzBuzz"},
  }

  for _, tt := range tests {
    result := fizzbuzz.Convert(tt.input)
    if result != tt.output {
      t.Errorf("Convert(%d) expected <%s>, but got <%s>", tt.input, tt.output, result)
    }
  }
}

まあ、これだとドキュメントの代わりにはなりませんよね。 例として1, 2, 3, 5, 15が入力として与えられていますが、それ以外の入力ではどのような結果になるのか実装を見ないとわからないです。

これの改善方法の一つとしていいなと思ったのはTable Driven Testのデータを配列ではなく map を使うという方法です。 map[string]struct{} 型にして置き、stringにテストの説明を入れておくというものです。 これはメルカリのライブコーディングを見ていていいなと思った方法です。

mapを使うとこのようなテストになると思います。

func TestConvert(t *testing.T) {
  tests := map[string]struct{
    input int
    output string
  }{
    `1を入力すると"1"に変換する`: {1, "1"},
    `2を入力すると"2"に変換する`: {2, "2"},
    `3を入力すると"Fizz"に変換する`: {3, "Fizz"},
    `5を入力すると"Buzz"に変換する`: {5, "Buzz"},
    `15を入力すると"FizzBuzz"に変換する`: {15, "FizzBuzz"},
  }

  for name, tt := range tests {
    t.Run(name, func (t *testing.T) {
      result := fizzbuzz.Convert(tt.input)
      if result != tt.output {
        t.Errorf("Convert(%d) expected <%s>, but got <%s>", tt.input, tt.output, result)
      }
    })
  }
}

これで少し改善した気がしますね。 何をテストしようとしているかのコメントを書くことで少しドキュメントとしてはまだましになってきた気がしました。 また、t.Runを使うことでテストが小分けされてテストが落ちた時にどのデータで落ちたのかが分かりやすくなりました。

しかし、これだけだとまだドキュメントとしては分かりにくいなという印象をぬぐい切れません。 先ほど言ったように6とかを入力に入れたときにどんな結果になるのが正しいかがテストからは伝わってきませんね。

さて、ではもう少し詳しい説明を入れていきましょう。 仕様についての説明を加えるのをTable Driven Testでごり押しするとこんな感じになると思います。

func TestConvert(t *testing.T) {
  tests := map[string]map[string]struct{
    input int
    output string
  }{
    `3の倍数を入力すると"Fizz"に変換する`: {
      `3を入力すると"Fizz"に変換する`: {3, "Fizz"},
    },
    `5の倍数を入力すると"Fizz"に変換する`: {
      `5を入力すると"Buzz"に変換する`: {5, "Buzz"},
    },
    `3と5の両方の倍数を入力すると"FizzBuzz"に変換する`: {
      `15を入力すると"FizzBuzz"に変換する`: {15, "FizzBuzz"},
    },
    `それ以外の数字はそのまま文字列に変換する`: {
      `1を入力すると"1"に変換する`: {1, "1"},
      `2を入力すると"2"に変換する`: {2, "2"},
    },
  }

  for name, tt := range tests {
    t.Run(name, func (t *testing.T) {
      for name, ttt := range tt {
        t.Run(name, func (t *testing.T) {
          result := fizzbuzz.Convert(ttt.input)
          if result != ttt.output {
            t.Errorf("Convert(%d) expected <%s>, but got <%s>", ttt.input, ttt.output, result)
          }
        }
      }
    })
  }
}

「さすがにmap[string]map[string]structは無理あるやろ。。」と思いながら書きましたが、意外と読めるなと自分でもびっくりしています。 ただ、これは今回のテスト対象であるConvert関数が入力も出力も1つという簡単な関数だからこそ出来たことです。 実際にはより複雑な関数のテストになることが想定されるので、この手法はあまり使いやすくはないなと思います。

これをどうにか読みやすいかつ書く量を減らしたいと試行錯誤した結果以下のような書き方にたどり着きました。 これだとTable Driven Testではありませんが、そこそこ読みやすい。(結局無難な方法に落ち着いてしまった。)

func TestConvert(t *testing.T) {
  testConvert := func(t *testing.T, input int, output string) {
    result := fizzbuzz.Convert(input)
    if result != output {
      t.Errorf("Convert(%d) expected <%s>, but got <%s>", input, output, result)
    }
  }

  t.Run(`3の倍数を入力すると"Fizz"に変換する`, func(t *testing.T) {
    t.Run(`3を入力すると"Fizz"に変換する`, func(t *testing.T) {
      testConvert(t, 3, "Fizz")
    })
  })

  t.Run(`5の倍数を入力すると"Buzz"に変換する`, func(t *testing.T) {
    t.Run(`5を入力すると"Buzz"に変換する`, func(t *testing.T) {
      testConvert(t, 5, "Buzz")
    })
  })

  t.Run(`15の倍数を入力すると"FizzBuzz"に変換する`, func(t *testing.T) {
    t.Run(`15を入力すると"FizzBuzz"に変換する`, func(t *testing.T) {
      testConvert(t, 15, "FizzBuzz")
    })
  })

  t.Run(`そのほかの数字を入力するとそのまま文字列に変換する`, func(t *testing.T) {
    t.Run(`1を入力すると"1"に変換する`, func(t *testing.T) {
      testConvert(t, 1, "1")
    })

    t.Run(`2を入力すると"2"に変換する`, func(t *testing.T) {
      testConvert(t, 2, "2")
    })
  })
}

まとめ

というわけで、Table Drive Testでどのようにドキュメントのようなテストを書くかを考えていきました。 僕にはTable Driven Testを使ってドキュメントみたいにする良い方法は思いつきませんでした。 というかTable Driven Testは「仕様が自明」かつ「テストすべき項目が多い」時に使うべきなんだろうなと思いました。 今まではGoでテスト書くから何となくTable Driven Testという感じで書いていましたが、一度これはどっちで書くべきなんだろうと考えて書くようにしたいと思います。