テスト対象を分析して4つに分ける〜テストコードのきほん〜

はじめに

今回は

  • テスト対象を分析して4つに分類する方法
  • テスト対象の分類に応じてやるべきこと

についてまとめました。


テストコードの価値を高めるためには、

テスト対象を分析し、

その分析結果に応じてリファクタリングや

テストコードの削除・改善などを適切に行う必要があります。


動画にもまとめたのでご覧ください。


テスト対象を4つに分類する

テスト対象は2つの視点で分類できます。

  • コードの複雑さとドメインにおける重要性
  • 協力者オブジェクト(依存)の数


コードの複雑さは、

テスト対象の分岐の数(判断を行う箇所)の数で計測でき、

ドメインのにおける重要性は、

ビジネスの問題領域におけるテスト対象の重要性で決まります。

このような箇所には、

重点的に単体テストを書く必要があります。


逆にコードがシンプルでドメインにおける重要性も低い箇所に

テストコードを形だけ書いている場合、

その単体テストに価値はあまりありません。


一方で、

テスト対象の協力者オブジェクト(依存)が多い場合、

テストコードはテストの準備で、依存をモックしたり、

テストコードが長く・複雑になるため

保守しずらいテストコードになります。


テスト対象を

これら2つの観点で4つに分類するとこのようになります。


テスト対象の分析観点と4つの分類


それでは、

4つに分類されたテスト対象の特徴と、

それぞれに対しての適切な対処についてそれぞれ見ていきます。


ドメイン・モデル/アルゴリズムに分類されるテスト対象

ビジネス的に重要だったり、

ビジネスとは直接関係ないが複雑だったりするロジックです。

そのため、特に退行に対する保護を強く備えておく必要があります。


ビジネス的に重要な部分にテストコードを書く
class NormalRankDelivery: Delivery {
    override fun getPostage(purchaseAmount: Int): Int {
        return if (purchaseAmount >= 3_000) 0 else 100
    }

    override fun canTodayDelivery(): Boolean {
        return false
    }

}


class TestNormalRankDelivery {

    @Nested
    inner class GetPostage {

        @Test
        fun `購入金額が3000円未満のため送料は100円`() {
            val normalRankDelivery = NormalRankDelivery()

            val actual = normalRankDelivery.getPostage(2999)

            Assertions.assertEquals(100, actual)
        }

        @Test
        fun `購入金額が3000円以上のため送料は無料`() {
            val normalRankDelivery = NormalRankDelivery()

            val actual = normalRankDelivery.getPostage(3000)

            Assertions.assertEquals(0, actual)
        }

    }
}


テスト対象として実行されるコードが複雑や重要なロジックであるため、

テストコードでの検証により退行に対する保護をより強く備えられます。


また、

テスト対象となるコードに協力者オブジェクトが含まれることも少なく、

テスト実行のためにモックなどの準備が不要で、

シンプルな単体テストコードになり、

テストコードの保守コストが低く抑えられ、

迅速なフィードバックも得られます。


この分類へのテストは非常にコスパがいいです。


コントローラーに分類されるテスト対象

このコード自体は複雑でビジネス的に重要なことは行わず、

ドメインモデル、外部システム連携、データベースアクセス

などを行う複数のコンポーネントを連携させて、

1つのビジネス的なユースケースを実現します。


ここに分類されるテスト対象には、

統合テストを書きます。


統合テスト
    @Test
    fun `支払い登録できる`() {
        every { memberRepository.findBy(any()) } returns MotherPaymentMember.default()
        every { paymentRepository.save(any()) } returns Unit
        every { distributeCouponRepository.findBy(any()) } returns null

        target.execute(param())

        verifySequence {
            memberRepository.findBy(MemberCode("1"))
            paymentRepository.save(any())
            distributeCouponRepository.findBy(MemberCode("1"))
        }
    }


取るに足らないコードに分類されるテスト対象

setterやgetter、ガード節などのないコンストラクタなど、

複雑さがなく、ビジネス的に重要でもないような箇所です。


❌複雑さがなくビジネス的に重要でもない箇所へテストは書かない
class Size(val width: Int, val height: Int, val depth: Int)


class TestSize {
    @Test
    fun `こんなテストは必要ない`() {
        val width = 100
        val height = 100
        val depth = 100

        val size = Size(width = width, height = height, depth = depth)

        Assertions.assertEquals(width, size.width)
        Assertions.assertEquals(height, size.height)
        Assertions.assertEquals(depth, size.depth)
    }

}


もしそのような箇所へのテストコードがあっても

保守コストがかかりテストに価値はないので削除します。


僕の基本方針としては、

ドメイン層だとしてもメチャクチャシンプルな

値オブジェクトやエンティティ

(ビジネス的には重要と分類され、複雑さはほぼゼロ)

へのテストもシンプルなうちは書かない。

という方針で良いと考えています。


過度に複雑なコードに分類されるテスト対象

コントローラーとして

データベース連携、外部システム連携などを行うのに加えて

ドメインロジックなどのビジネス的に重要で複雑なロジックも

持つようなコードです。


ここに分類されたテスト対象に対して、

そのままテストコードを書こうとしてはいけません。


もしここに分類されたら、

テスト対象の設計を見直し、

ドメイン・モデル/アルゴリズムとコントローラーに

分解するリファクタリングを行います。


過度に複雑なコードは責務を2つ持っているので、

作りが歪で、テストもしずらいです。



過度に複雑なコードへのテストは難しい




過度に複雑なコードは、

テストコードだけでなんとかするアプローチだと、

なんの解決にもならず、

テストコードの保守コストが跳ね上がるので注意が必要です。


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

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

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

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


参考文献

第7章 単体テストの価値を高めるリファクタリング

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

コメントを残す

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

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