<-- mermaid -->

なぜテストコードを書くのだろう?

こんにちは、NewsPicksの北見です。

ところで皆様、テストコードって書いてますか...?

ネットでテストコードについて検索すると

  • 「テストコードを書きましょう」
  • 「テストコードとはこうあるべし」
  • 「TDD(Test Driven Development)だ」

等々が叫ばれています。

ただ、なんとなく「方法論ありきでとにかくテストを書け」と言われているようで、テストの必要性について納得感に欠けている方もいらっしゃるのではないでしょうか?

なぜ テストコードを書くのでしょうか?

テストコードを書く理由

諸説ありますが、私が思うテストコードを書く理由は

  • 将来リファクタリングをしやすくする
  • テストコード書く途中で、開発者自身が仕様を理解し、成長できる

の2つです。

将来リファクタリングをしやすくする

「昔書かれたこのコードはどういう挙動になるべきなんだ...?さっぱり分からん!」

ある程度コードを書いたことのある方なら、そう思わなかった事はないでしょう。

そして正直に申し上げると、NewsPicksのリポジトリにも、テストが書かれてないコードが少なからず存在し、苦労させられることがしばしば起こります。

特にモバイルアプリ向けに提供している API は後方互換を残す必要があり、安易に既存の挙動を変えてしまうのはNGです。

そんな API の挙動をテストにおこしてみましょう。

// Kotestを使ったAPIテストの例
class SelfControllerTest : FunSpec({
    ...

    context("/self/coupons"){
        test("利用可能なクーポン一覧が返される"){
            // given
            val mockMvc = ...

            // when
            val mvcResult = mockMvc.get("/self/coupons")
            val expectedJson = """
                {
                    "validCoupons": [
                        {
                            "name": "書籍「OPEN」ダウンロード",
                            "endDate": "2020-05-05T04:59:09Z"
                        }
                    ],
                    "expiredCoupons": [
                        {
                            "name": "書籍「PURPOSE」ダウンロード",
                            "endDate": "2020-05-05T04:59:09Z"
                        }
                    ]
                }""".trimIndent()

            // then
            mvcResult.response.status shouldBe 200
            mvcResult.response.contentAsString shouldMatchJson expectedJson
        }
    }
})

「あれ、このAPIが返すjsonってどうなってるべきなんだっけ?デグレにならないかな?」と後から悩んだり、わざわざ動作確認する必要が少なくなりましたね。

テストコード書く途中で、開発者自身が仕様を理解し、成長できる

実はテストコードを書く最も大きな効能がこれではないでしょうか?

「とりあえず動いている」ことと「正しく動いていること」には天と地ほどの差があります。

例えば、定期購読を解除するこんなコードを見てみましょう。

val userSubscriptionId = ...
val subscription = subscriptionService.cancelImmediately(UserSubscriptionId(userSubscriptionId))

たったこれだけのコードですが、色々考えなければいけないことはありますよね?

実装している時は「なんとなく」わかっているつもりでも、意外と抜け落ちていることは多いものです。

新たにコードを書いている過程では、脳は「こうなったら動くだろう」という思考になりがちで、それはある意味仕方の無いことです。

ですがテストコードを書くことで、脳を「ちゃんと動くのだろうか?」モードに思考に切り替えることができます。

テストコードを書く過程で「なんとなく」を言語化し、「異常系を含めて挙動を説明できる」ところまで開発者の理解レベルを引き上げ、結果として良質なコードが出来上がっていきます。

先に紹介した subscriptionServicecancelImmediately メソッドは

  • 該当する userSubscriptionId をもつレコードがない場合ってどうなるんだっけ?
  • DB更新失敗したらどうなるの?
  • 成功時に返ってくる subscription ってどんな値が入っていれば正しいんだっけ?

こんなツッコミ対して、テストコードを書いて回答してみましょう。

// Kotestを使ったServiceのテスト例
class SubscriptionServiceTest : FunSpec({
    ...

    val now = ZonedDateTime.of(2023, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"))
    mockkStatic(ZonedDateTime::class)
    beforeTest {
        every { ZonedDateTime.now() } returns now
    }

    ...
    context("cancelImmediately"){
        test("該当UserSubscriptionが存在しない場合、ResourceNotFoundExceptionが投げられる"){
            // given
            every { subscriptionRepository.findById(UserSubscriptionId(100)) } returns Option.empty()

            // when
            val exception = shouldThrow<ResourceNotFoundException> {
                subscriptionService.cancelImmediately(UserSubscriptionId(100))
            }

            // then
            exception.message shouldBe "UserSubscription is not found. userSubscriptionId: 100"
        }

        test("UserSubscriptionの更新に失敗した場合DatabaseUpdateFailureExceptionが投げられる"){
            // given
            every { subscriptionRepository.findById(UserSubscriptionId(100)) } returns UserSubscription(...)
            every { subscriptionRepository.updateSubscription(any()) } returns Option.empty()

            // when
            val exception = shouldThrow<DatabaseUpdateFailureException> {
                subscriptionService.cancelImmediately(UserSubscriptionId(100), EventTrigger.ADMIN_USER)
            }


            // then
            exception.message shouldBe "Already updated! UserSubscriptionId : 100"
        }

        test("成功した場合、statusはCANCELED、subscriptionTerm.endDateが現在に更新されたUserSubscriptionが返却される"){
            // given
            every { subscriptionRepository.findById(UserSubscriptionId(100)) } returns UserSubscription(...)
            every { subscriptionRepository.updateSubscription(any()) } returns UserSubscription(...).cancelImmediately()

            // when
            val canceledUserSubscription = subscriptionService.cancelImmediately(UserSubscriptionId(100))

            // then
            canceledUserSubscription.id shouldBe UserSubscriptionId(100)
            canceledUserSubscription.status shouldBe SubscriptionStatus.CANCELED
            canceledUserSubscription.subscriptionTerm.startDate shouldBe ZonedDateTime.of(2022, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"))
            canceledUserSubscription.subscriptionTerm.endDate shouldBe ZonedDateTime.of(2023, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"))
            canceledUserSubscription.latestTerm.startDate shouldBe ZonedDateTime.of(2022, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"))
            canceledUserSubscription.latestTerm.endDate shouldBe ZonedDateTime.of(2023, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"))
            ...
        }
    }
})

テストコードを書く前は、何となく「定期購読をキャンセルできればそれでいいよー」程度の感覚でしたが、 テストコードを書く過程で、 cancelImmediately がどうあるべきか、その仕様がはっきりしましたね。

加えて、異常系まで考慮したテストコードを書く途中で、「あのケースでバグらないかな?これはどうだろう?」と思考を巡らす事により、思わぬ落とし穴を事前に見つかることもできるでしょう。

これはバグを減らすのに役立つだけでなく、事前に様々なエラーケースを想定する癖がつくことで、エンジニアとして成長することができるのではないでしょうか。

最後に

テストを書くことそれ自体が目的じゃないと思うんですよね。

手間のかかる割に報われにくいテストは、後に回すかやらなくていいんじゃないでしょうか。

まずは

  • mock 化しやすいように DI を利用できるコードにする
  • mock に対して正常系と異常系の両方のテストを書く

くらいから始めるのがちょうど良い塩梅に思います。

将来の開発者(あるいは自分達)へのプレゼントとして、そして異常系も考えられるエンジニアとしてご自身が成長するために、ぜひ「適度に」テストコードを書いてみてください。

Page top