検証する箇所としない箇所〜テストコードのきほん〜

はじめに

テストコードを書く際、

どこを検証すべきでどこを検証してはならないか??

についてまとめました。


テストコードでの検証がテキトーだと

リファクタリング時に結果は正しいのに嘘の警告をするようになります。

それが頻発すると、

開発者がテストコードを無視するようになり、

テストコードの価値も無くなるので

注意が必要です!


実装の詳細を検証してはダメ

テストコードはテスト対象の多くのコードを網羅的に実行でき

退行に対する保護を備えるのは重要です。

しかし、それだけでは価値の高いテストコードとは言えません。

リファクタリングへの耐性も備える必要があります。


詳細については以下の記事をご覧ください。

テストコードに備えさせる4つの特徴とバランス〜テストコードのきほん〜


では、

リファクタリングへの耐性が低くなる原因は何でしょうか??


テストコードがプロダクションコードとより密接に結びつくと、

リファクタリングへの耐性が低くなります。


例えば、

テスト対象の途中過程を検証すると、

リファクタリングへの耐性は低くなります。


リファクタリングへの耐性が低い例


例えば、

リファクタリングで処理4をなくせた、

もしくは処理4を処理5とまとめた場合、

最終結果は正しいのにテストコードは

途中経過の検証部分でNGになります。


では、どうすればリファクタリングへの耐性を

備えさせられるのでしょう??


検証する対象を観察可能な振る舞いのみとし、

最終結果のための細かい手順である実装の詳細には

目を向けないようにするのが重要です。


リファクタリングへの耐性が高い例


テスト対象のコードを呼び出す側の視点で、

呼び出す側にとって意味のある実行結果のみを確認し、

その他のことに関しては何も検証しません。


それ以上検証するということは、

テスト対象の実装の詳細に踏み込むことになり、

リファクタリングへの耐性を失います。


❌リファクタリングへの耐性が備わっていない
    @Test
    fun `calculate returns correct result`() {
        val calculator = ComplexCalculator()

        val result = calculator.calculate(2, 3)

        // ❌計算ロジックの途中経過を検証している
        assertEquals(5, calculator.intermediateSum)
        assertEquals(10, result)
    }


// テスト対象
class ComplexCalculator {
    // ❌計算ロジックの途中経過の状態を公開している
    var intermediateSum: Int = 0
    private var intermediateProduct: Int = 0

    fun calculate(a: Int, b: Int): Int {
        val sum = add(a, b)
        return multiply(sum, 2)
    }

    private fun add(a: Int, b: Int): Int {
        val sum = a + b
        intermediateSum = sum  // update intermediateSum
        return sum
    }

    private fun multiply(a: Int, b: Int): Int {
        val product = a * b
        intermediateProduct = product  // update intermediateProduct
        return product
    }
}


⭕️リファクタリングへの耐性が備わった
    @Test
    fun `calculate returns correct result`() {
        val calculator = ComplexCalculator()

        val result = calculator.calculate(2, 3)

        // ✅外からみた振る舞いである最終結果のみ検証
        assertEquals(10, result)
    }


// テスト対象
class ComplexCalculator {
    // ✅計算ロジックの途中経過の状態は隠蔽
    private var intermediateSum: Int = 0
    private var intermediateProduct: Int = 0

    fun calculate(a: Int, b: Int): Int {
        val sum = add(a, b)
        return multiply(sum, 2)
    }

    private fun add(a: Int, b: Int): Int {
        val sum = a + b
        intermediateSum = sum  // update intermediateSum
        return sum
    }

    private fun multiply(a: Int, b: Int): Int {
        val product = a * b
        intermediateProduct = product  // update intermediateProduct
        return product
    }
}


工数をかけて退行に対する保護を備えたものの、

リファクタリングへの耐性が備わっておらず、

テストコードが価値のないものになる。

という事態に陥った現場はよく見かけるので要注意です!


プロジェクトが進むほどリファクタリングへの耐性がより重要

プロジェクトの初期段階(新規開発が始まった段階)では

リファクタリングの必要がそこまでないため、

退行に対する保護にだけ注意しておけば問題にならない場合も多いです。


しかし、開発したシステムがリリースされ、

保守開発フェーズに入り、

時間が経つにつれてコードの無秩序が多くなってくると

リファクタリングによる整理が必要になります。


ここでテストコードにリファクタリングの耐性が備わっていなければ、

問題になります。


プロジェクトの成長とテストスイートへの影響


多くの開発者は、

退行に対する保護や偽陰性(警告されないバグ)に目がいきがちで、

リファクタリングへの耐性はおろそかになりがちです。


  • テストコードにはリファクタリングへの耐性を備えさせる必要がある
  • リファクタリングへの耐性が備わっていなければ保守開発フェーズ以降必要以上に工数がかかる
  • リファクタリングへの耐性を備えさせるために実装の詳細を検証しない


この辺りは意識してテストコードを書いていきましょう!!


今回のテーマについては以上です。

テストコード初めての方へ向けて記事を書いたので

是非こちらもご覧ください。

初めてテストコードを書く方に向けて|新人プログラマー時代の自分に伝えたいこと


参考文献

第4章 良い単体テストを構成する4本の柱

単体テストの考え方/使い方

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください