LoginSignup
1
1

テストコードにおけるMockの利用について

Last updated at Posted at 2024-03-03

参考にした書籍

こちらの記事では以下の書籍を参考にしており、内容としては古典派を肯定する内容に偏っています。

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

Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考
image.png
Googleのソフトウェアエンジニアリング ―持続可能なプログラミングを支える技術、文化、プロセス
image.png

モックを使用する問題点について

モックを使用する場合、意味のないテストコードになったり、リファクタリング耐性のないテストコードになるリスクがあります。

筆者は自然にモック派アプローチを採用し、依存関係の大部分がモックアタはスタブ化されたユニットテストを作成したように思います。当時はこれについてあまり考えておらず、楽そうという理由だけで、モックはアプローチを使っていました。
しかし後になって後悔しました。関数などが実際に機能することを適切にテストできず、コードのリファクタリングが非常に困難になったためです。

「Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考」 引用

ある機能に5行の変更を行って何十個もの無関係なテストが破綻したのを発見する羽目になったことがこれまでにあったなら、脆いテストの煩わしさを感じたことがあるということだ。時間の経過とともに、この煩わしさのために、コードベースを健全に保つために必要なリファクタリングの実施をチームが控えるようになりかねない。脆いテストの最も有害な問題は、モックオブジェクトの誤った利用から生じる。Googleのコードベースはモッキングフレームワークの不正な利用により相当ひどい害を被ったため、それによって「モックは二度と使わない!」と宣言するエンジニアも出てくるに至った。

「Googleのソフトウェアエンジニアリング」 引用

どちらの書籍も使い所によっては非常に有用ですが、モックを無闇に使うことに関して否定的な意見が記述されています。
具体的にどのような場合に、モックが問題となるのか見ていきます。

モックを使用する具体的な問題点について

モックまたはスタブをプロダクションの依存関係とは異なる動作をするように構成している場合、実態からかけ離れたテストになるつながる可能性がある。
テストが実装の詳細と密接に結びつく可能性があり、リファクタリングが困難になる可能性がある。

「Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考」 引用

実態からかけ離れたテストになるつながる可能性がある例

以下はモックを使用している為、本来は失敗するはずのテストが成功してしまっている例になります。

プロダクトコード

class UserService {
    /**
     * UserAdmin 権限 and 削除対象のユーザの license が PAID 場合に削除可能
     * 削除条件が不足している場合は例外が発生する
     */
    fun delete(executor: User, targetUser: User) {
        TODO()
    }
}
class UserDeleteUseCase(
    private val userService: UserService
) {
    fun sync(executor: User, targetUser: User) {
        userService.delete(executor, targetUser)
    }
}

テストコード

class UserDeleteUseCaseTest : FreeSpec({
    "ユーザを削除できること" {
        val executor = User(
            userId = 1,
            license = License.FREE,
            name = "削除実行者"
        )
        val targetUser = User(
            userId = 2,
            license = License.FREE,
            name = "削除対象のユーザ"
        )
        val mockUserService = mockk<UserService>(relaxed = true)
        val sut = UserDeleteUseCase(mockUserService)

        // 実際には削除に失敗して例外になるはずが、モックを使用している為テストに成功する
        shouldNotThrowAny { sut.sync(executor, targetUser) }
    }
})

モックを使用する場合は、以下の場合に実際の挙動とテスト結果が異なる場合があります。

  • 使用するclass、メソッドを正確に理解できていない場合
  • 使用する側がモック化するclassの仕様を勘違いしている、または仕様変更の対応漏れ
  • モック化しているclassの仕様が変わった場合

テストが実装の詳細と密接に結びつく可能性がある例

プロダクトコード

class UserUpdateService(
    private val userFactory: UserFactory,
    private val userRepository: UserRepository
) {
    /**
     * ユーザを更新する
     */
    fun execute(input: UserUpdateServiceInput) {
        val user = userFactory.create(input.id)
        val updateUser = user.changeEmail(input.email)
        userRepository.add(updateUser)
    }
}

テストコード

class UserUpdateServiceTest : FreeSpec({
    "ユーザを更新できること" {
        // given
        val user = User(id = UserId(1), email = Email("test@phoneappli.net"))
        val mockUserFactory = mockk<UserFactory> {
            every { create(any()) } returns user
        }
        val mockUserRepository = mockk<UserRepository> {
            justRun { add(any()) }
        }
        val sut = UserUpdateService(
            userFactory = mockUserFactory,
            userRepository = mockUserRepository
        )
        val input = UserUpdateServiceInput(
            id = UserId(1),
            email = Email("test@phoneappli.net")
        )

        // when,then
        sut.execute(input)

        // 期待される順番でモックが呼ばれていること
        verifySequence {
            mockUserFactory.create(UserId(1))
            mockUserRepository.add(user)
        }
    }
})

コードが期待通りに関数を呼び出したかをテストしていますが、このクラスを使う人の本当に関心のあることは、ユーザを更新できること。関数をどのように呼び出すかは実装の詳細になります。
実装の詳細をテストしていると、リファクタリングなどで挙動に変更がないにも関わらず、テストに失敗する場合があります。

どのような時にモックを使用するか

  • 管理下にないプロセス外依存
  • テストを簡単にする為
  • テストから外側の世界を守る
  • テストを外側の世界から守る

管理下にないプロセス外依存をモック化する

管理下にあるプロセス外依存

他のサービスとは共有していないアプリケーションのDBなど

管理下にないプロセス外依存

Message busやメールサービスなど

「単体テストの考え方/使い方」では管理下にないプロセス外依存をモック化することを推奨しています。
管理下にあるプロセス外依存に関しては、モックを使用しないことを推奨しており、以下のような実装を推奨しております。

  • ドメインモデルやドメインサービスにビジネスロジックを実装する
  • 管理下にあるプロセス外依存(DBなど)へアクセスが必要な処理を実装するclassでは、ロジックが入らないシンプルな実装にする
  • 管理下にあるプロセス外依存(DBなど)へアクセスする処理のテストは統合テストで実施する

image.png
単体テストの考え方/使い方の著者のブログから引用

テストを簡単にする為

一部の依存関係はテストで使いづらく面倒である。依存関係には、多くのセットアップが必要な場合や、その依存関係からさらなる大量の依存関係(サブ依存関係)もセットアップが必要になる場合もある。こうなると、テストが複雑になり、実装の詳細と密接に結びつく可能性がある。プロダクションの依存関係の代わりにテストダブルを使用すると、テストが簡単になる可能性がある。

「Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考」 引用

テストから外側の世界を守る

コードの依存関係の1つがプロダクションのサーバにリクエストを送信したり、プロダクションのデータベースに値を書き込んだりすると、ユーザやビジネスの重大なプロセスに悪影響を与える可能性がある。このようなシナリオでは、テストから外側の世界のシステムを守るために、テストダブルを使用することがある。

「Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考」 引用

テストを外側の世界から守る

外側の世界の動きは予測できないことがある。データベースが返す値は時間の経過とともに変化する可能性がある。そういったときにはテストがフレーキーになる場合があるが、テストダブルを使うと、常に1つの決まった結果を使ってテストするように構成できる

「Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考」 引用

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1