テストケースの名前には条件と結果を含めた方が良い - 感情を込める

テストケースの名前には条件と結果を含めた方が良い

という考えにたどり着いたので、考えのスナップショットをとっておく。


Go言語における、テスト関数名とサブテストのname引数の値を「テストケースの名前」・「テスト名」と呼ぶことにしている。

(*testing.T).Run(name string, f func(t *testing.T)) bool

テスト名に近いものとして、(*testing.T).Error(*testing.T).Logの引数がある。これらはテスト実行時の出力に含まれるが、テストケースを分かつものではない。あくまで、特定のテストケース内の情報を増やすものだ。対するテスト名は、(通常は)テストケースを分割できる最小単位である。

テストケースがテスト名の単位で存在するということは、テスト名はそのテストケースを十分に表現できていたほうがよいということだ。さもなくば、検証・変更しようとする仕様に対応するテストケースや、実行されたテストケースが対応する仕様を特定するのが困難になる。

例えば、日本の「消費税の軽減税率制度」に従って、商品を取引した際の消費税額を計算する処理を作ることを考えてみる。仕様の一部は以下のようになる。

  • 税率区分ごとの合計に対して税額を計算する
    • 1円未満の端数は切り捨てる
  • 最終的な税額は税率区分ごとの税額を合計したものとする

早速、以上の仕様を満たすソフトウェアを検証するテストを書いてみた。(sutのインターフェースでなく、assertionの方に注目してほしい)

func TestSimplifiedInvoice_total(t *testing.T) {
    t.Run("複数の税率区分の商品を追加した場合", func(t *testing.T) {
        sut := SimplifiedInvoice{}
        sut.add(Item{130, CategoryBeverage}, 1)
        sut.add(Item{155, CategoryLiquor}, 1)

        // Act
        got := sut.total()

        // Assert
        t.Run("税率区分ごとの税額は、それぞれの合計金額に対する税額を少数第一位で切り捨てたもの", func(t *testing.T) {
            if got.Reduced != 10 {
                t.Errorf("軽減税率区分は、130*1*0.08=10.4を切り捨てて10を得るはずが、%vを得た", got.Reduced)
            }
            if got.Standard != 15 {
                t.Errorf("標準税率区分は、155*1*0.1=15.5を切り捨てて15に得るはずが、%vを得た", got.Standard)
            }
        })

        t.Run("合計税額は、税率区分ごとの税額を合計したもの", func(t *testing.T) {
            if got.Total != 25 {
                t.Errorf("10+15=25を得るはずが、%vを得た", got.Total)
            }
        })
    })
}

ここで注目したいのは、実行結果の出力だ。例としてすべてのassertionが失敗した状態を取り上げる。GoはテストがFailしたときしかテストケースごとの実行結果を見ない慣習があるからだ。

=== RUN   TestSimplifiedInvoice_total/複数の税率区分の商品を追加した場合/税率区分ごとの税額は、それぞれの合計金額に対する税額を少数第一位で切り捨てたもの
    prog_test.go:70: 軽減税率区分は、130*1*0.08=10.4を切り捨てて10を得るはずが、0を得た
    prog_test.go:73: 標準税率区分は、155*1*0.1=15.5を切り捨てて15に得るはずが、0を得た
=== RUN   TestSimplifiedInvoice_total/複数の税率区分の商品を追加した場合/合計税額は、税率区分ごとの税額を合計したもの
    prog_test.go:79: 10+15=25を得るはずが、0を得た

まずは、テストケースの名前 TestSimplifiedInvoice_total/複数の税率区分の商品を追加した場合/税率区分ごとの税額は、それぞれの合計金額に対する税額を少数第一位で切り捨てたもの を見ていく。
TestSimplifiedInvoice_total がテスト対象のサービスを表している。ここはActフェーズで実行する内容を、型名とメソッド名や関数名などを並べる形で機械的命名している。こうすることで、例えばSimplifiedInvoice型のすべてのテストを実行したいときは以下のようなコマンドを使うことができる。

go test -run 'SimplifiedInvoice'

複数の税率区分の商品を追加した場合 はテストの条件を表しており、続く 税率区分ごとの税額は、それぞれの合計金額に対する税額を少数第一位で切り捨てたもの は期待する結果を表している。条件と結果が別々のサブテストで表現されている理由は、特定条件下で検証すべき仕様が複数あるからだ。仮に条件と仕様が1:1であればひとつにまとめたほうが良いだろう。しかし、今回は税率区分ごとの税額と、合計税額という2つの異なる仕様を検証するためにテストケースを分割した。
まとめるより分割したほうがテスト名をつけやすいという利点もある。複数の仕様を短い言葉で言い表わそうとすると抽象度が上がる傾向があり、抽象度の高いテスト名は何を期待しているのかが曖昧になる。曖昧な期待値のテスト名だとテストコードを読んで何を検証しているのかを確かめなければならないし、新しいテストケースでそれが起こればテストコードのテストとしてのテスト名は役目を果たせなくなる。

ちなみに、条件として 正常系異常系 などの区分を含めるかどうかについてはどうだろうか。
私は入れるべきではないという考えだ。私の経験則では、その情報を整理することによる恩恵を感じたことがなく、区分することで多少の視野の狭まりすら感じるからだ。さて、この経験則がどうしてそうなのかを考えてみると、正常・異常などの区分は曖昧であり判断が難しい、そのうえ、変更されることがままあるという現実があることに気づいた。前者は、同じソフトウェアを開発している人間同士で、何を 異常系 とするかという認識が統一されているならば良いが、少なくとも私はそういう環境に遭遇したことは無い。異常系は想定していない入力であるからFuzzingでしか検証できないと言う人間や、エラーや例外を返すなら異常系だと言う人間など、いろいろな解釈がある。後者は、例えばソフトウェアエンジニアにとっては頭を抱えたくなることだが、想定していない入力で動いてしまっているソフトウェアを、利用者としては当たり前のように利用し始めることがあるということだ。その挙動は異常系なのか正常系なのか。神のみぞ知る。
続いて、両方の現実を受け入れた上でなお対抗した場合も考えてみる。各テストケースが正常系の基準を満たしているか、異常系の基準を満たしているかが、名前から分かるようになった。これは何の役に立つだろうか。パッと思いつくのは、時間がないときに正常系のテストだけ実行してリリースする、とかはあるかもしれない。が、そんなときのための testing.Short を使ったほうが良いと思う。他には、エラーが起きることを異常系とした場合、assertion内容がエラーだけになるのでまとめてテストできる、とかはあるかもしれない。が、エラーが起きる入力は様々なはずなので、assertionだけまとめるとむしろ理解しづらいテストコードになると思う。今の私には何の役に立つのか分からない。
まとめると、正常系や異常系の境界は、ソフトウェアによらず、周囲の人間によって(知らぬ間に)決められるので、ソフトウェアとして記述するのは不毛だし、不毛なことを頑張っても役に立たないと思うのだ。
補足だが、自動テストのテストケースとして分類すべきでないと言うだけで、手動テスト、ブラックボックステストにおいてはその価値があると思う。

次に、検証失敗時のログメッセージ 軽減税率区分は、130*1*0.08=10.4を切り捨てて10を得るはずが、0を得たを見ていく。
検証している項目がなぜ失敗しているのかを表明している。このメッセージを見た開発者が実装のどこがどのようにおかしいかを理解できることを念頭に置いている。検証内容によって何を伝えるべきかはかなり変わってくるので丁寧に書いても損はしないのではなかろうかというのが個人的な思いだ。最も単純な例では xxxを得るはずが、%vを得た というテンプレが汎用的に使えるので、これを基本として組み立てると良いと思う。

さて、ここからはテストケースに条件や結果を含めないパターンについて考えてく。

func Test(t *testing.T) {
    sut := SimplifiedInvoice{}
    sut.add(Item{130, CategoryBeverage}, 1)
    sut.add(Item{155, CategoryLiquor}, 1)

    // Act
    got := sut.total()

    // Assert
    want := Invoice{10, 15, 25}
    if diff := cmp.Diff(want, got); diff != "" {
        t.Errorf("missmatch (-want +got)\n%s", diff)
    }
}
=== RUN   Test
    prog_test.go:96: missmatch (-want +got)
          main.Invoice{
        -   Reduced:  20,
        +   Reduced:  0,
        -   Standard: 46,
        +   Standard: 0,
        -   Total:    66,
        +   Total:    0,
          }

テストコードだけを見て、どんな仕様を検証しているのか分かるだろうか。少なくとも私は分からない。自分で書いた直後だとしたら分かるはずだが、半年も経てば分からなくなると思う。仮に想像で答えを出せたとしても、それが合っている可能性は低いだろう。そして、想像上の仕様は、真の仕様とは違ってくるわけで、、、沼が足湯だと思っている人は幸せな気持ちで沼に沈んでいくのだろうか。

話が逸れた。つまり、仕様を満たすソフトウェアは仕様を満たしているだけであって仕様そのものではない。だからテストだけは仕様そのものに近い存在であったほうが、ソフトウェアは開発しやすいのではなかろうか、ということが言いたかった。
しかし、ソフトウェア自体が仕様と言えるものもある。ソフトウェアから利用されるソフトウェア、いわゆるライブラリはその傾向があるように思う。そういう場合はテストコードだけで十分に仕様を表現できると思うので、そのようにすればいいと思う。むしろノイズになるテストケース名は簡潔にすべきだという意見もあるだろう。同意できる。


スナップショットがとれてひとまず満足した。

参考

gihyo.jp

go.dev