LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

コード品質向上のテクニック: 第 4 回(ドア破壊テスト)

こんにちは。コミュニケーションアプリ「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 でないオブジェクトを渡しても問題ありません。


一言まとめ: ユニットテストでは、内部的な動作についてよりも、仕様にあった振る舞いをしているかを確認するほうが好ましい。

キーワード: unit test, dependency, specification