はじめに
テスト対象の依存関係が複雑になると
テストコードではモックやスタブを利用します。
しかし、
これらを過剰に利用してしまうと、
テストコードが実装の詳細と結びつき
リファクタリングへの耐性を失うことになります。
今回はモックやスタブ利用時の注意点や、
利用すべきケースと利用すべきでないケース
についてまとめました。
動画にもまとめたのでご覧ください。
モックとスタブの違い
プロダクションコードには含まれず、
テストでしか使われない偽りの依存として表現される全てのものを
包括的にテストダブルと呼びます。
テストの際に使われる依存を用意するのが難しい場合、
(例えば外部システムとのシステム間連携部分など)
実際の依存のかわりにテストダブルをテスト対象に使わせることで、
テストを容易に行えるようにします。
テストダブルには
大きくモックとスタブがあります。
モック
テスト対象から外部に向かうコミュニケーション(出力)を模倣し、
検証するのに使われます。
(例えばメールの送信部分をモックにするなどがあります)
class EmailClientTest {
@Test
fun `sendWelcomeEmail sends an email`() {
val emailServiceMock = mockk<EmailService>(relaxed = true)
val emailClient = EmailClient(emailServiceMock)
emailClient.sendWelcomeEmail("test@example.com")
verify { emailServiceMock.sendEmail("test@example.com", "Welcome!", "Thanks for signing up.") }
}
}
スタブ
依存からテスト対象に向かうコミュニケーション(入力)を模倣するのに使われます。
(例えばデータベースからのデータ取得をスタブにするなどがあります)
class UserServiceTest {
@Test
fun `getUserName returns the user name from the database`() {
val databaseStub = mockk<Database>()
every { databaseStub.fetchUserName(1) } returns "John Doe"
val userService = UserService(databaseStub)
val userName = userService.getUserName(1)
assertEquals("John Doe", userName)
}
}
モックとスタブ利用時の注意点
ここからはモックとスタブそれぞれの
利用時のポイントや注意点について見ていきます。
勝手に変更できない依存関係との連携のみをモックする
テスト対象が依存しているオブジェクト(協力者オブジェクト)を
全て、もしくはテキトーにモックしてしまうと、
テストケースがリファクタリングへの耐性を失います。
ではどこをモックすれば良いかというと・・・
その依存との連携をテスト対象の都合で変更しても問題ない箇所をモックします。
テスト対象の都合で、
上の例で言うと、
リファクタリング時にメールサービス連携部分を
勝手に変更すると動かなくなります。
変更不可能ということは、
テスト対象の外から見た振る舞いとして守るべき契約となります。
一方、
ドメインとの連携部分を
勝手に変更するのはどうでしょう??
恐らく大抵の開発現場では可能です。
変更可能ということは、
テスト対象の実装の詳細として扱えるということです。
実装の詳細をモックし始めると、
テストコード(のモック)が実装の詳細と強く結びつき、
リファクタリングへの耐性を失ってしまいます。
そうなるとそのテストケースの価値はゼロになります。
詳しくはこちら参照ください。
テストコードに備えさせる4つの特徴とバランス〜テストコードのきほん〜
このようにテスト対象が依存しているモノを正しく分析し、
モックやスタブを行う判断を行う必要があります。
では、もう一つだけ
テスト対象がいろんな依存をしてる例をみてみましょう。
是非皆さんもどこをモックすべきか考えてみてください!
モックすべき箇所はこれらです。
- 外部サービス
- メールサービス
- DB(外部サービス公開)
これらはテスト対象の外から見た振る舞いであり、
テスト対象が動作した際に守るべき契約事項です。
この部分は後方互換を保つ必要があり、
そこをモックして後方互換が保たれることを検証します。
スタブで検証はしない
スタブの部分は、テスト対象の最終的な結果ではなく
最終結果を出すまでの途中経過であることが多いのが理由です。
例えば、
いろんなテーブルからデータを取得して
最終的なAPIのレスポンスを加工して返却するのを考えると、
とあるテーブルからデータを取得しているのは
APIの最終的なレスポンスの途中経過です。
途中経過(実装の詳細)を検証すると、
リファクタリングへの耐性を失います。
これを過剰検証と呼び、
テスト対象とその依存との
コミュニケーション検証時に陥りがちです。
モックやスタブ利用時にテスト対象の歪さを検知する
コマンドクエリ分離(CQS)の原則が守れていない例
まずコマンドクエリ分離(CQS)の原則では、
全てのメソッドはコマンドかクエリのどちらかになるべきで、
両方の性質を持つべきではないと提唱しています。
コマンドとは戻り値がなく副作用を起こすメソッドで、
クエリとは副作用がなく戻り値のみを返すメソッドのことで、
このような分離を明確に行うことでコードが読みやすくなります。
このコマンドクエリ分離の原則に従った場合、
コマンドの代わりとして使われるテスト・ダブルがモックであり、
クエリの代わりとして使われるテスト・ダブルがスタブになります。
もし、モックを作ろうとしたのに、
モック兼スタブになった。という場合は、
テスト対象とモック対象のコミュニケーション部分が
コマンドクエリ分離の原則に違反していることを疑い、
設計を見直しましょう。
interface EmailService {
// CQS違反したコード
fun sendEmail(to: String, subject: String, body: String): String
}
class EmailClient(val emailService: EmailService) {
fun sendEmailAndGetResponse(to: String, subject: String, body: String): String {
return emailService.sendEmail(to, subject, body)
}
}
class EmailClientTest {
@Test
fun `sendEmailAndGetResponse sends an email and returns a response`() {
val emailServiceMock = mockk<EmailService>()
// stubとしての振る舞いを定義
every { emailServiceMock.sendEmail(any()) } returns "Email sent successfully"
val emailClient = EmailClient(emailServiceMock)
val response = emailClient.sendEmailAndGetResponse("test@example.com", "Subject", "Body")
// mockとしての検証を定義
verify { emailServiceMock.sendEmail("test@example.com", "Subject", "Body") }
assertEquals("Email sent successfully", response)
}
}
単一責任の原則が守られていない例
単一責任の原則では、
テスト対象のコードは
プロセス外依存とのやり取りを行うコントローラーか、
ビジネス的に重要で複雑なロジックを取り扱うドメインモデルか、
のどちらかになります。
この原則に従えば、
ドメインモデルの層とコントローラの層に自然と分かれ、
テストコードは、
ドメインモデルに対する検証は単体テスト、
コントローラに対する検証は統合テストで行うことになります。
モックに置き換えるのは管理下にない依存だけなので、
モックを利用するテストコードは
コントローラをテスト対象とするテストのみになるはずです。
しかし、テスト対象が単一責任の原則に従っていなければ、
単体テストの中でモックが使いたくなったり、
統合テストが過度に複雑になったりします。
fun transfer(sourceAccountId: String, targetAccountId: String, amount: Double, accounts: List<Account>): String {
val sourceAccount = accounts.find { it.id == sourceAccountId }
val targetAccount = accounts.find { it.id == targetAccountId }
if (sourceAccount == null || targetAccount == null) {
return "Invalid account(s)"
}
if (sourceAccount.balance < amount) {
return "Insufficient funds"
}
// Business logic within application service layer - bad practice
sourceAccount.balance -= amount
targetAccount.balance += amount
return "Transfer successful"
}
テストコードを書く際、
このようなテスト対象の設計の歪さを
検知できるチャンスがあるので、
見逃さないようにしましょう。
モックの対象は自身のプロジェクトが所有する型のみ
サードパーティ製のライブラリが提供する型やクライアントは
そのまま利用せずアダプタを独自に作成して、
アダプタ経由でライブラリは利用します。
モックもアダプタ部分をモックするのを基本方針とすべきです。
理由は、
サードパーティ製のライブラリが
実際にどのように機能しているのかを深く知ることは滅多にないからです。
サードパーティ製のインタフェースをそのまま利用し、
テストコードでもモックに置き換えていると、
モックの振る舞いとライブラリの実際の振る舞いを
一致させなければならず、それはリスクです。
アダプタを挟むことで、
ライブラリに含まれる技術的な詳細を隠蔽でき、
自身のアプリケーションの用語で
ライブラリとの関係を定義できるようになります。
例えば、ライブラリのバージョンを上げた際、
どのようにライブラリのコードが変更になるのかは予想できません。
もし、ライブラリのコードに変更があると、
自身のコードが全体的に影響を受けてしまう可能性が出てきます。
しかし、
このようにアダプタを挟んで抽象化層を追加しておけば
変更による影響をそのアダプタだけに抑えられるようになります。
今回のテーマについては以上です。
テストコード初めての方へ向けて記事を書いたので
是非こちらもご覧ください。
初めてテストコードを書く方に向けて|新人プログラマー時代の自分に伝えたいこと参考文献
第5章 モックの利用とテストの壊れやすさ
単体テストの考え方/使い方