はじめに
本記事は単体テストにおける偽陰性に焦点をあてています。偽陽性は別記事にします。
目次
- 振る舞いを検証できていない場合ただの負債になりかねない
- そもそも単体テストの目的って?
- 単体テストで検証したいものって?
- 振る舞い と 内部実装(実装の詳細)
- 現実世界で 振る舞い と 内部実装 を考える
- テストケースに内部実装が漏れ出ている例
- 振る舞いを検証するように修正した例
- おわりに
振る舞いを検証できていない場合、ただの負債になりかねない
コードは書いた時点で負債になるとも言われますが、価値を生まないテストケースの作成は、本当に負債を積んでいるだけになります。
価値を生まないテストの1つに「振る舞いを検証できていない単体テスト」があります。この前、後輩とペアプロしていた際にこのケースを気が付いたので、記事化しようと思いました。
そもそも単体テストの目的って?
結論、意図しない振る舞いの変更を検知すること だと思っています。なぜなら、ソフトウェアの個々のモジュールや関数の意図した動作が、システム全体の意図した動作を支えているからです。意図しない振る舞いの変更はバグを埋め込むリスクたりえます。
単体テストの考え方/使い方 では、単体テストで成し遂げたいことは『ソフトウェアプロジェクトの成長を持続可能なものにすること』と述べられています。質のいい単体テストを用意することで、コードの変更によるバグを検出するセーフティネットがプロジェクトに備わることになり、その結果、開発者は自身のコード変更がバグを生んでいないことに自信を持ちながらのリファクタリング / 機能追加ができるようになります。
ゆえに、我々は目的を達成できるような価値のある単体テストを書いていかねばなりません。
単体テストで検証したいものって?
ここで「振る舞い」について少し触れます。
単体テストにおける単体とは「外部から観察可能な1単位の振る舞い」のことです。また、1単位の振る舞いとはメソッドを介したアウトプットです(ざっくり)。それゆえ、メソッドによっては1単位の振る舞いの中で複数の結果が生じることもあり得ます。
例えば、メソッドに戻り値があればそれが1単位の振る舞いの一部だし、戻り値がなくてもシステムの状態を変更しているならそれが1単位の振る舞い、どっちもやっているならそれらすべてが1単位の振る舞いになる、というイメージです。
よって、メソッドを実行することで外部から観察可能な振る舞いを検証するのが単体テストであり、1単位あたりの外部から観察可能な振る舞いすべてが、単体テストで検証したいもの と言えます。
振る舞いをある程度抽象的に扱う場合、一般的な用語として扱うと、ほぼ同じような意味の「対象における何らかのイベントにひも付く外部から見える変化」といえそうです。
価値のある単体テストでは、1単位の振る舞いが検証されます。逆に検証の対象となるものが1単位の振る舞いでない場合、その単体テストは何を検証しようとしているのかが曖昧になるり、質の悪い単体テストとなってしまいます。
振る舞い と 内部実装(実装の詳細)
さて、振る舞いをもう少し深堀りするために、振る舞いとよくセットで語られる内部実装という概念について説明します。対比で使われることもありますが、私は別に対になる概念ではない気がしています。
内部実装(実装の詳細)とは実装の詳細とも呼ばれます。ある目的を達成するために外部には公開する必要のないもの。1単位の振る舞いを生み出すうえでのHow?の部分、つまり過程の処理のことです。
分かりやすくするために、「観察可能な振る舞い」と「実装の詳細」をレストランで注文した料理を例にしてみます。
観察可能な振る舞い とは
レストランで注文した料理がテーブルに運ばれてきたときの結果になります。
- 料理がどのように見えるか
- 味がどうか
- 温度は適切か
などが含まれ、これらはすべて顧客が直接観察でき、評価できる要素になります。
内部実装(実装の詳細) とは
厨房での、提供する料理の調理過程です。
- 食材をどこで仕入れたか
- 食材をどの順番で使ったか
- どのように調理したか
- どの道具を使ったか
など、顧客からは見えない部分、つまり内部実装(実装の詳細)になります。 しかし、これらの詳細が最終的な「観察可能な振る舞い」、つまり料理の出来上がりに大きく影響します。
注意:振る舞いと内部実装は視点によって変化する
どこを切り取るかで、単体テストで検証すべき対象は変化します。
例えば methodA
を観察可能な振る舞いをとして検証する際には、methodB
や methodC
は知る必要のない内部実装になります。また、methodB
を観察可能な振る舞いをとして検証する際には、methodD
や methodE
、methodF
は知る必要のない内部実装になります。
よく private
なメソッドはテストする必要がないと言われるのはこれが理由で、private
なメソッドは、メソッドを使う側が知る必要のない内部実装であるから です。単体テストのためだけに、プライベートであったメソッドを公開することは「観察可能な振る舞いのみを検証する」に反することになります。なので、private
なメソッドを観察可能な振る舞いの一部に含め、間接的に検証させるようにしようね、と言われているのです。(※ もちろん責務を考えて別クラスのpublicメソッドとして再定義することはあり得る)
検証したいものは外部から観察可能な1単位の振る舞い
単体テストにおいては「観察可能な振る舞い」、つまり外部から見える結果に注目します。実装の詳細については知る必要がないからです。最終的なアウトプット(What)が正しければ過程(How)は何だっていい わけです。
現実世界で 振る舞い と 内部実装 を考える
突然ですが、私は今すごく PEACE
(紙タバコの銘柄)が欲しい。吸いたい。とにかく PEACE
めっちゃ吸いたい。でも自分は手が空いてないから買いに行くことがことができない。だから隣にいる暇そうな友人にお願いしよう。
この場合の私の関心事は、PEACE
(タバコ)を受け取ることです。なので、友人にお願いするとしたら、『ちょっとPEACE買ってきて』です。
このお願いの仕方はソフトウェアの設計原則的には Tell, Don't Ask
(尋ねるな、命じよ)の原則と言われたりします。私が友人に求める振る舞いとしてはPEACEを渡してくれること、それだけです。これが振る舞いのみに関心がある場合(『ちょっとPEACE買ってきて』)です。
一方、『ちょっとPEACE買ってきて』と言われた友人にはいくつかの選択肢があります。
- 近くのセブンイレブンで買う
- スーパーのサービスカウンターで買う
- 昔ながらのタバコ屋さんで買う
これらの友人が持つ複数の選択肢が、私から見たときの内部実装(実装の詳細)です。私が PEACE
の購入を依頼する際には、この実装の詳細について関心を持っていません。とにかくPEACEが手に入れば何でもいいからです。
ソフトウェアで考えると、私は友人の内部実装について、関心を持つべきではありません。なぜなら、メソッド(私:使う側)が別のメソッド(友人:使われる側)の振る舞いだけでなく実装の詳細まで知ってしまうと、カプセル化が壊れ、ソフトウェアは密結合化していくからです。
つまり、ソフトウェアの設計原則に則ると、『ちょっとPEACE買ってきて』はOKですが、『ちょっとそこのセブイレでPEACE買ってきて』はNG なわけです。
これはテストでも大事な考え方で、テストケースの中にプロダクトコードの内部実装(実装の詳細)が漏れ出ていると、以下の問題を引き起こす可能性が生まれてしまいます。
- プロダクトコードをリファクタリングしただけでテストが失敗する(偽陽性)
- プロダクトコードの振る舞いが変わったのにテストが成功する(偽陰性)
本記事の元ネタになったのはプロダクトコードの振る舞いが変わったのにテストが成功するパターンの方だったので、以下ではこのリスクについて説明します。(プロダクトコードをリファクタリングしただけでテストが失敗するパターンは別記事にする)
テストケースに内部実装が漏れ出ている例
テスト対象の MyClass.java
には、「 "This tobacco is PEACE"
という文字列を要素としたListを返す」という振る舞いを持った generateTobacco()
メソッドが定義されています。
// 今回のテスト対象クラス・メソッド
public class MyClass {
public List<String> generateTobacco() throws Exception {
List<String> tobacco = new ArrayList<>();
tobacco.add(TobaccoGenerator.getTobacco(Brand.PEACE)); // PEACEはタバコの銘柄
return tobacco; // "This tobacco is PEACE" という文字列を要素としたListを返す
}
}
public class TobaccoGenerator {
public static String getTobacco(String brand) {
return "This tobacco is " + brand;
}
}
public class Brand {
public static final String PEACE = "PEACE";
}
後輩は、きれいな AAAパターン で書かれたテストクラスを書いてくれました。
public class MyClassTest {
@Test
void test() throws Exception {
// Arrange
MyClass myClass = new MyClass();
List<String> actual = new ArrayList<>();
// Act
actual = myClass.generateTobacco();
// Assert
assertThat(actual.get(0), is(TobaccoGenerator.getTobacco(Brand.PEACE)));
}
}
このテストケースの問題点
問題点は、メソッドの振る舞いが変わってもテストが通ってしまう 点です。なぜなら、テストクラスでの期待値生成において、プロダクトコードの内部実装をそのまま使っているからです。内部実装がテストケースに漏れ出ており、これでは振る舞いのテストができていません。
これは先ほどの私が友人にタバコを買ってきてもらう例で考えてみると、「 PEACE
(タバコ)の入手経路としてセブンイレブンを利用していること」を検証しているようなイメージです。
テストケースで内部実装の検証をしている場合...
例えば今回のケースで言うと、プロダクトコードの振る舞いが変わったのにテストが成功してしまいます。
例えば、以下メソッドの戻り値が 英語 ⇒ 日本語 に変更されたとします。
// メソッドに仕様の変更があったとする
public class TobaccoGenerator {
public static String getTobacco(String brand) {
// return "This tobacco is " + brand; ← もともとのコード
return "このタバコは " + brand + " という銘柄";
}
}
public class MyClassTest {
@Test
void test() throws Exception {
// Arrange
MyClass myClass = new MyClass();
List<String> actual = new ArrayList<>();
// Act
actual = myClass.generateTobacco();
// "このタバコは PEACE という銘柄" という文字列を要素としたListを返す
// Assert
assertThat(actual.get(0), is(TobaccoGenerator.getTobacco(Brand.PEACE)));
// テストケースにプロダクトコードが漏れ出ており、
// 期待値も同じメソッドで生成しているので、当然テストは通る
}
}
このメソッドのもともとの振る舞いは「 "This tobacco is PEACE"
という文字列を要素としたListを返す」でしたが、「 "このタバコは PEACE という銘柄"
という文字列を要素としたListを返す」に変更されてしまいました。
しかしテストが落ちないので、振る舞いの変更に気が付くことができません。
振る舞いの変更があったのにテストが落ちない(偽陰性:False Negative)
振る舞いに変更が加わりました、しかしテストは落ちません。失敗すべきテストが失敗しない、これは偽陰性と言われます。
今回はプロダクトコードの内部実装がテストコードへ漏れ出していたことが原因でした。これではテストが検証したい対象(1単位当たりの振る舞い)が検証できておらず、テストの目的であるバグの検知が果たせません。
振る舞いを検証するように修正した例
このメソッドの外部から観察可能な振る舞いは、「 "This tobacco is PEACE"
という文字列を要素としたListを返すこと」でした。今回はべた書きで期待値を書くように修正します。
public class MyClassTest {
@Test
void test() throws Exception {
// Arrange
MyClass myClass = new MyClass();
List<String> actual = new ArrayList<>();
// 期待値の作成
List<String> expected = new ArrayList<>();
expected.add("This tobacco is PEACE");
// Act
actual = myClass.generateTobacco();
// Assert
assertThat(actual, is(expected));
}
}
今回のテストにおいて重要なのは、「 "This tobacco is PEACE"
という文字列を要素としたListを返すこと」という観察可能な振る舞いを検証することであるので、検証のための期待値をべた書きして、実行結果であるListオブジェクトを検証するように修正しました。
このように、プロダクトコードとテストコードを切り離して、メソッド実行によって得られる最終的な結果を確認(検証)することはとても大切です。プロダクトコードとテストコードの結びつきが強い場合には、今回のような偽陰性(False Negative)だけでなく、偽陽性(false positive)も持ち込むリスクが高まるからです。
単体テストで検証するのは「外部から観察可能な1単位の振る舞い」です、胸に刻みましょう。
おわりに
今回は偽陰性の例を1つ取り上げたので、どこかで偽陽性についても書こうと思います。