💤

テスト関数は不可分にしよう

2023/12/16に公開

テストコードの可読性を上げるための持論です。今回はテスト関数を不可分にしましょうという話です。不可分とはそれ以上分解できない状態とでも思っていただけると良いと思います。

テスト関数は自由だ

テストフレームワークはテストを関数として実装し、その関数を実行することでプロダクトコードをテストすることができます。これを本記事ではテスト関数と表現しています。このテスト関数自体は命名規則等に従って定義しておけばフレームワークによって自動実行してもらえるという特徴があるだけで実態は普通の関数です。ただの関数であるため、1つのテスト関数の中にいくらでもいろんなテストケースを自由に実装することができます。
ところが、そのような自由があるからと言って1つのテスト関数に複数のテストケースを記述することは、テストコードの可読性を著しく低下させる原因となってしまいます。なぜなら、別の記事で説明したテスト対象因子検証項目と因子の関係が何であるかが不透明となってしまうからです。

簡単な例を見てみましょう(今回はrspecです。厳密には関数じゃないですが簡単にかけるのでこれでご勘弁を)。

describe Target do
  it do
    t = Target.new('hoge')
    result1 = t.do_something1()
    expect(result1).to eq('ho')

    result2 = t.do_something2()
    expect(result2).to eq('ge')
  end
end

上記のテスト関数ではdo_something1メソッドとdo_something2メソッドの2つの挙動をテストしています。一見何も問題ないようにも思えますが実際にはdo_something2の因子としてdo_something1を呼ぶことが必要なように見えるという問題があります。例が単純なのでそれほど問題に見えないかもしれませんが、プロダクコードの仕様が複雑であればあるほどこの問題は大きくなっていきます。

4フェーズテストで不可分なテスト関数を作る

上記の問題を解決するためには1つのテスト関数の中で実行するテストケースがたった1つになるようにしましょう。つまりテスト関数をこれ以上分解できない不可分な状態にしましょう。しかし、何を持ってテストケースを1つと呼ぶべきかが結構わからなかったりします。そんな人のために簡単にテスト関数を不可分にする方法があります。それが4フェーズテストというテスト関数の書き方です。

4フェーズテストとは1つのテスト関数の中で記述する処理を以下の4つフェーズに分類し、それぞれのフェーズが1回だけ出てくるように実装する方法です。(フェーズはあくまで概念的なまとまりを指しているだけなので、4つのステートメントに分けるという意味ではない点にだけ注意)

  1. setup
  2. exercise
  3. verify
  4. teardown

setupフェーズはテスト対象をテスト可能な状態にするための処理の実行や、必要なデータの生成などを担うフェーズです。私の言う因子を準備するのがこのフェーズと言っても良いです。WebサービスのE2Eテストのログイン処理なんかがこれに当てはまります。他にもDBのレコードを生成したり、単にオブジェクトを生成したり状態を変化させたりすることも含まれます。

exerciseフェーズはテスト対象を実際に動作させて検証可能な状態にします。具体的にはテストしたい関数や機能を実行するフェーズとなります。関数の呼び出し、フォームの送信などが当てはまり、基本的には1つのステートメントによって構成されます。このフェーズに当てはまらない処理は必ずsetupまたは後述するverifyに分類されると考えて良いです。

verifyフェーズはexerciseを行うことで発生する結果を期待値と比較して、テストが成功しているか失敗しているかを判断するフェーズになります。基本的にはテストフレームワークが提供するアサーションを呼び出すことになります。必要に応じてDBから結果として書き込まれている値を取得したりする処理も含まれたりします。

teardownフェーズはテスト関数内で生成したデータなどが他のテスト関数に影響を与えないようにするための後片付けをするフェーズになります。非常に重要なフェーズになりますが、明示的に記述しない場合も多いです。例えば生成したインスタンスのメモリの開放、DBのclean upやログアウトといった作業を行います。基本的にフレームワークの機能を使って実装し、テストが異常終了した場合でも必ず実行されるようにする必要があります。

先程のテストを各フェーズに当てはめるとだいたいこんな感じになります。

describe Target do
  it do
    t = Target.new('hoge') # setup
    result1 = t.do_something1() # exercise
    expect(result1).to eq('ho') # verify

    result2 = t.do_something2() # exercise
    expect(result2).to eq('ge') # verify
  end
end

do_something1の実装の詳細を知らないのでexerciseかsetupかわかりかねますが、今回はexerciseが2つあるという状況だとして、このテストには2つのテストケースが混ざっているということがわかるかと思います。

それでは、4フェーズテストを適用するとどうなるか見てみましょう。

describe Target do
  it do
    t = Target.new('hoge') # setup
    result = t.do_something1() # exercise
    expect(result).to eq('ho') # verify
  end

  it do
    t = Target.new('hoge') # setup
    result = t.do_something2() # exercise
    expect(result).to eq('ge') # verify
  end
end

これなら最初の状態と違ってdo_something1do_something2との間に因果関係は特にないこと、テスト対象はそれぞれの関数、因子となる値はhogeという値であることが読み取りやすくなったのではなかろうかと思います。
4フェーズテストがあれば簡単にテスト関数を不可分にして可読性をあげられますね。

ちなみにちらっと触れましたが、4つのフェーズはあくまで概念的なまとまりなので、テスト関数を以下のようにコンパクトに修正しても構いません。(私は変数による冗長性の排除のために変数を使わない実装を好んでます)

describe Target do
  it do
    expect(Target.new('hoge').do_something1()).to eq('ho')
  end

  it do
    expect(Target.new('hoge').do_something2()).to eq('ge')
  end
end

可読性以外の4フェーズテストの恩恵

ここまで可読性の面でだけ4フェーズテストを語りましたが、それ以外の面でも恩恵があります。それはすべてのテストが必ず実行されるという点です。大抵のテストフレームワークではアサーションで失敗と判定されると例外を発生させて後続の処理を実行しなくなってしまいます。そのため、1つのテスト関数の中で複数のテストを用意してしまうとすべてのテストが実行されず、テストがどこまでなら成功しどこまでが失敗しているのかという情報が正確にわからなくなってしまいます。CIでテストを実行するときはこれは大変にコスパが悪いし、デバッグの効率も悪くなってしまいます。このような問題を起こさないためにも4フェーズテストは役に立ちます。

リスト型のデータが入力のときも気をつける

リスト型のデータを入力とするテストケースの場合、4フェーズテストを守っているのに複数のテストースが盛り込まれた状態になってしまうことがあります。例えば、何かの条件に合致する要素だけを返すような関数をテストする際には何が因子なのか分かりづらくなってしまうので、リストに複数の要素が必要な場合でも因子そのものがたった1つであることがわかるようにすると可読性は向上するでしょう。

終わりに

テスト関数を不可分にすればそれだけテストコードの保守が容易になります。また、実装するのも楽になるので4フェーズテストを活用して不可分なテスト関数を構築できればみんな幸せになれると思います。

Discussion