こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。
この記事は、毎週木曜の定期連載 "Weekly Report" 共有の第 4 回です。Weekly Report については、第 1 回の記事を参照してください。
ドア破壊テスト
以下の IntAdder
は、add
で与えられた整数値の合計を保持します。flush
が呼び出されると、現在の合計値を 0 にリセットし、「リセットされる直前の合計値」を戻り値として返します。
val adder = IntAdder()
adder.add(1)
adder.add(2)
println(adder.flush()) // Shows 3
adder.add(100)
adder.add(200)
println(adder.flush()) // Shows 300
IntAdder
の実装は以下のようになっているとします。
class IntAdder {
private var currentSum: Int = 0
fun add(value: Int) {
currentSum += value
}
fun flush(): Int {
val result = currentSum
currentSum = 0
return result
}
}
ここまでのコードは、仕様を実現するために必要十分であり、特に問題はないと仮定します。
次に、ある開発者が IntAdder
のユニットテストを作ろうとして、currentSum
の可視性を private
から internal
に変えたとします。このように、テストのためだけに可視性を変えたときは、Guava ライブラリの @VisibleForTesting
などを使って「プロダクションコード用の可視性ではない」ことを明示できます。また、テストのための可視性は、Lint ツール等によって検証することもできます。
class IntAdder {
var currentSum: Int = 0
@VisibleForTesting internal get
private set
この currentSum
を使って、以下のような「現在の合計値が正しいか」を検証するユニットテストが作成さ れました。
// Unit test code
adder.add(100)
assertEquals(100, adder.currentSum)
adder.add(200)
assertEquals(300, adder.currentSum)
assertEquals(300, adder.flush())
assertEquals(0, adder.currentSum)
このユニットテストのコードに問題点はありますか?
空いた窓だけでテストする
ユニットテストでは、内部の詳細な働きよりも、観測できる振る舞いが仕様と一致するか をテストする方がより好ましいです。言い換えると、ユニットテストで確認するべきことは主に以下の 2 点になります。
- 関数の戻り値や例外
- 外部から与えられるオブジェクトとの相互作用 (実引数として与えられたオブジェクト、コンストラクタ引数として与えられたオブジェクト、インジェクトされたオブジェクトなど)
戻り値のテスト
先程の IntAdder
の実装では、現在の合計値を数値として保持していました。しかし、これは仕様の変更によって変わることもありえます。例えば、「値の変更履歴」の取得が必要になった場合、var currentSum: Int
というプロパティは val valueHistory: MutableList<Int>
という形に変わるかもしれません。その場合でも、add
と flush
に対するテストは変わらず有効であるべきです。しかし実際には、currentSum
というプロパティ自体が変わったことにより、既存のテストはコンパイルできなくなってしまいます。
この問題を解決するためには、currentSum
の値を覗き見るテストを書くのではなく、関数の戻り値をテストするのが良いでしょう。
// Unit test code
adder.add(100)
adder.add(200)
assertEquals(300, adder.flush())
assertEquals(0, adder.flush())
外部から与えられるオブジェクトとの相互作用のテスト
外部から与えられるオブジェクトとの相互作用の例を示すために、IntAdder
に「トランザクション」という新たな機能を追加します。トランザクションの実装には、コンストラクタ引数として与えられる transactionLogger
というオブジェクトを利用します。
class IntAdder(private val transactionLogger: TransactionLogger) {
...
fun inTransaction(action: Adder.() -> Unit) {
... // Use `transactionLogger` here
}
}
この transactionLogger
との相互作用をテストしたい場合は、transactionLogger
の mock
(test double の語句では test spy) を用意しておき、それに対する関数呼び出しを検証すればよいでしょう。
val logger: TransactionLogger = mock()
val adder = IntAdder(logger)
adder.inTransaction { add(100) }
inOrder(logger) {
verify(logger).write(100)
verify(logger).commit()
}
もちろん、外部から与えられるオブジェクトをすべて mock にする必要はありません。そのオブジェクトがテストの環境に閉じていて、かつ、テスト対象の関数の観測で十分ならば、mock でないオブジェクトを渡しても問題ありません。