mcpField
P
picking-test-doubles

picking-test-doubles

github.com/skydovesテスト・品質33964 回インストール22 週間前に更新
リポジトリ

picking-test-doubles について

Android 開発者が fake、mock、stub、spy、dummy、Robolectric の shadow を選ぶためのテストスキルです。優先順位と判断表を示し、実装上の呼び出しではなく振る舞いに焦点を当てるテストを支援します。

主な機能

  1. 016 種類のテストダブルの定義
  2. 02動作する代替実装として fake を推奨
  3. 03mock で相互作用を検証する場面の説明
  4. 04stub、spy、dummy の違い
  5. 05Robolectric shadow の解説

活用シーン

  1. 01依存関係に使うテストダブルの選択
  2. 02過剰な mock を使う状態テストの fake 化
  3. 03mock による必要な相互作用の検証

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/skydoves/android-testing-skills --skill picking-test-doubles

インストール後、Claude Code で/picking-test-doublesを入力して読み込まれたか確認します。

詳細

テストダブルの選び方 — フェイクを優先し、モックはインタラクションの確認にのみ使用する

Google は /training/testing/fundamentals/test-doubles で、テストダブルの明確な優先順位を 3 回にわたって示しています。多くの Android コードベースではモックを使いすぎ、フェイクを十分に使っていません。その結果、システムがどのような状態になったかという振る舞いではなく、どのメソッドが呼び出されたかという実装の詳細に依存するテストが生まれます。このスキルでは、6 種類のテストダブル、原文どおりの優先順位、および適切なものを選ぶための判断マトリクスを定めています。モックライブラリ自体のフレームワークの仕組みについては、関連するスキル(../../../jvm-tests/mocking/mocking-with-mockito/SKILL.md、../../../jvm-tests/mocking/mocking-with-mockk/SKILL.md)を参照してください。

このスキルを使う場合

  • ユーザーが「フェイクとモックのどちらを使うべきか」「Mockito と MockK のどちらか」「スタブとスパイの違いは何か」と尋ねている。
  • ユーザーがテスト内の依存関係を置き換えようとしており、どのテストダブルを使えばよいか迷っている。
  • レビュー中の PR で verify(repo).getUser(any()) がどこでも使われていて、エージェントがモックの使いすぎを疑っている。
  • ユーザーが Robolectric の「シャドウ」に触れ、それがテストダブルの分類のどこに当てはまるか尋ねている。
  • ユーザーが「テスト対象の振る舞いがないのに every { repo.getUser() } returns User('Alice') としている」と述べており、エージェントがフェイクを提案すべきである。

このスキルを使わない場合

  • ユーザーがすでにフェイク/モックを選んでいて、フレームワークの仕組み(whenever/every/verify/argumentCaptor)を知りたい場合 — ../../../jvm-tests/mocking/mocking-with-mockito/SKILL.md または ../../../jvm-tests/mocking/mocking-with-mockk/SKILL.md を使用してください。
  • ユーザーが Hilt を使ってバインディングを差し替えようとしている場合 — このスキルと ../../strategies/applying-testing-strategies/SKILL.md(Hilt のセクション)を組み合わせてください。
  • ユーザーがテスト方法ではなく、何をテストするかを選んでいる場合 — ../../concepts/choosing-what-to-test/SKILL.md を使用してください。
  • ユーザーが特定の API で例外をスローする Robolectric のシャドウをデバッグしている場合 — ../../../jvm-tests/robolectric/using-robolectric-correctly/SKILL.md を使用してください。

前提条件

  • 依存関係が注入されているテスト対象(コンストラクターインジェクションまたはインターフェースベースの DI)。ハードコードされたシングルトンにはリファクタリングなしでテストダブルを差し替えられません。
  • モック/スタブ/スパイが必要な場合は、testImplementation に mockito-core と mockito-kotlin、または io.mockk:mockk が必要です。../../strategies/organizing-test-source-sets/SKILL.md を参照してください。
  • シャドウを使う場合は、testImplementation に org.robolectric:robolectric を追加し、testOptions.unitTests.includeAndroidResources = true を設定してください。

6 種類のテストダブル(原文どおりの定義)

以下の 6 種類の定義(フェイク、モック、スタブ、スパイ、ダミー、シャドウ)はすべて、developer.android.com/training/testing/fundamentals/test-doubles の原文どおりです。シャドウは Robolectric 固有のテストダブルであり、Google のページでは従来の 5 種類とともに取り上げられています。

フェイク — 優先

「フェイク:クラスの「動作する」実装を持つテストダブルですが、テストには適していても本番環境には適さない方法で実装されています。例:インメモリデータベース。フェイクにはモックフレームワークが不要で、軽量です。フェイクが優先されます。」 — developer.android.com/training/testing/fundamentals/test-doubles

モック — インタラクションの確認に使用

「モック:プログラムしたとおりに動作し、インタラクションに関する期待を持つテストダブルです。インタラクションが定義した要件に一致しない場合、モックはテストを失敗させます。通常、これらすべてを実現するためにモックフレームワークで作成します。例:データベースのメソッドが正確に 1 回呼び出されたことを確認する。」 — developer.android.com/training/testing/fundamentals/test-doubles

スタブ — あらかじめ用意した応答、期待なし

「スタブ:プログラムしたとおりに動作しますが、インタラクションに関する期待を持たないテストダブルです。通常、モックフレームワークで作成します。シンプルさのため、スタブよりフェイクが優先されます。」 — developer.android.com/training/testing/fundamentals/test-doubles

スパイ — 実オブジェクトをラップする

「スパイ:実オブジェクトをラップし、モックと同様に追加情報も記録するものです。複雑さが増すため、通常は避けられます。そのため、スパイよりもフェイクまたはモックが優先されます。」 — developer.android.com/training/testing/fundamentals/test-doubles

ダミー — 渡されるが使われない

「ダミー:渡されるものの使われないテストダブルです。たとえば、パラメーターとして渡す必要があるだけの場合です。例:クリック時のコールバックとして渡される空の関数。」 — developer.android.com/training/testing/fundamentals/test-doubles

シャドウ — Robolectricのバイトコード経由のフェイク

「シャドウ:Robolectricで使われるフェイクです。」 — developer.android.com/training/testing/fundamentals/test-doubles

シャドウは、AndroidフレームワークのクラスをJVM実装でバイトコードレベルで置き換えます(ShadowSystemClock、ShadowLooper、ShadowApplicationなど)。意図としてはフェイク(動作する実装であり、本番環境には適しません)ですが、開発者がほかの方法では置き換えられないAndroidフレームワークのクラスに固有のものです。../../../jvm-tests/robolectric/using-robolectric-correctly/SKILL.mdを参照してください。

優先順位(PRレビューで引用してください)

同じページからの3つの原文引用で優先順位を示します。

  1. 「フェイクはモックフレームワークを必要とせず、軽量です。フェイクが優先されます。」

  2. 「シンプルにするため、スタブよりフェイクが優先されます。」

  3. 「そのため、スパイよりもフェイクまたはモックが優先されます。」

要約:

Fake > Mock ≈ Stub > Spy
                 ↑ (mock when verifying interactions; stub when only canned data)

ダミーはこの順序には含まれません。ダミーはプレースホルダーであり、実際のテストロジックではないためです。シャドウは構造上フェイクであり、フレームワークのクラス専用です。

Androidの具体例

フェイク — インメモリマップを使ったFakeUserRepository

/test-doublesページには、フェイクのサンプルが掲載されています。ページの例をそのままコピーしてはいけません。val const UserAlice = User("Alice")という誤記が含まれています(正しいKotlinはconst valであり、val constではありません)。tasks/research/R8-android-fundamentals.mdで確認済みです。修正し、内容を拡充した次のバージョンを使ってください。

// RIGHT — fake repository with an in-memory map
class FakeUserRepository : UserRepository {
    private val users = mutableMapOf<UserId, User>()

    override suspend fun get(id: UserId): User? = users[id]

    override suspend fun save(user: User) {
        users[user.id] = user
    }

    override fun observeAll(): Flow<List<User>> = flow {
        while (currentCoroutineContext().isActive) {
            emit(users.values.toList())
            delay(100)
        }
    }

    // Test-only seeding helpers; production interface does NOT expose these.
    fun seed(vararg users: User) {
        users.forEach { this.users[it.id] = it }
    }
    fun clear() = users.clear()
}

@Test
fun loadsSeededUser() = runTest {
    val repo = FakeUserRepository().apply { seed(User(UserId("u1"), "Alice")) }
    val vm = UserViewModel(repo)
    vm.load(UserId("u1"))
    assertEquals("Alice", vm.state.value.name)
}

フェイクはテストソースセットにコンパイルされ、本番コードから参照されることはありません。every { }ブロックも、モックの初期化も、検証の手順もありません。

モック — 呼び出しの検証に使うmockk<UserRepository>()

操作そのものがSUTであり、結果の状態ではない場合にモックを使います。例:保存後に同期エンジンがmarkDirty(id)をちょうど1回呼び出す必要がある場合です。

@Test
fun save_marksDirty_exactlyOnce() = runTest {
    val repo = mockk<UserRepository>(relaxUnitFun = true)
    val syncQueue = mockk<SyncQueue>(relaxUnitFun = true)
    val sut = UserSaveOrchestrator(repo, syncQueue)

    sut.save(User(UserId("u1"), "Alice"))

    coVerify(exactly = 1) { syncQueue.markDirty(UserId("u1")) }
}

Mockito-kotlinの同等のコード(CORPUSのSection G.3による標準的なAndroidパターン — androidxチェックアウトでは「400以上のKotlinファイルがorg.mockitoをインポートし、io.mockkをインポートするファイルは0」):

@Test
fun save_marksDirty_exactlyOnce() = runTest {
    val repo: UserRepository = mock()
    val syncQueue: SyncQueue = mock()
    val sut = UserSaveOrchestrator(repo, syncQueue)

    sut.save(User(UserId("u1"), "Alice"))

    verify(syncQueue).markDirty(UserId("u1"))
    verifyNoMoreInteractions(syncQueue)
}
スタブ — 定型データを返し、検証はしない

テストに必要なのが戻り値だけで、かつフェイクを簡単に書けない場合に使います。

@Test
fun loadsCachedAvatarUrl() = runTest {
    val avatarApi: AvatarApi = mock()
    whenever(avatarApi.getUrl(UserId("u1"))).thenReturn("https://cdn/u1.png")

    val sut = AvatarPresenter(avatarApi)
    sut.load(UserId("u1"))

    assertEquals("https://cdn/u1.png", sut.url.value)
    // Note: no verify {} — this is a stub, not a mock.
}

AvatarApiに複数のメソッドがある場合や、テストで多数の呼び出しを仕込む必要がある場合は、優先順位に従ってスタブをFakeAvatarApiに置き換えてください。

スパイ — 実オブジェクトをラップする(避ける)

スパイは「複雑さが増すため通常は避ける」とされています。正当な用途としては、フェイクにするのが難しいクラスを完全なフェイクにすることが妥当でない場合に、一部だけを置き換える方法があります。それでも、可能であればまずクラスをリファクタリングしてください。

// WORKS but DISCOURAGED
val realCache = LruCache<String, Bitmap>(8)
val spied = spyk(realCache)
every { spied.evictAll() } answers { /* no-op for the test */ }

このスパイを使うと、ほかの部分は実際のままにして、テスト内で1つのメソッドを上書きできます。代償として、隠れた結合が生じます。evictAll以外ではテストがLruCacheの実際の動作に依存するため、どちらかをリファクタリングすると、分かりにくい形でテストが壊れます。

ダミー — プレースホルダー引数
val noOpClickHandler: () -> Unit = {}   // dummy — type-checks, never invoked
val sut = ClickableComponent(onClick = noOpClickHandler)
sut.bind(...)  // test does not interact with onClick at all
シャドウ — バイトコード経由のフェイクとしてのShadowSystemClock

SUTがSystemClock.uptimeMillis()を直接呼び出す場合、これを抽象化するにはリファクタリングが必要です。Robolectricには、テストから明示的に操作できるシャドウが用意されています。

@RunWith(AndroidJUnit4::class)
class TimerTest {
    @Test
    fun reportsElapsedTime() {
        ShadowSystemClock.setCurrentTimeMillis(0)
        val timer = Timer()
        ShadowSystemClock.advanceBy(Duration.ofMillis(1500))
        assertEquals(1500L, timer.elapsed())
    }
}

ShadowSystemClockはフェイクです(インメモリ実装であり、本番環境には適しません)。本番コードが、DIのシームではインターセプトできないfinalなAndroidフレームワークのメソッドを呼び出すため、Robolectricのシャドウとして提供されているだけです。

判断マトリックス

The intent of the test                                     Pick
----------------------------------------------------------------
Need a working substitute for a class with state           Fake
  (in-memory DB, in-memory user map, in-memory clock)
Need to verify a specific interaction happened             Mock
  ("markDirty was called once with id u1")
Need a canned return value, no behavior, no verification   Stub
  (and a Fake would be heavyweight for this one method)
Need partial real / partial fake on the same class         Spy (last resort; refactor preferred)
Need a placeholder so a parameter type-checks              Dummy
Need to substitute an Android framework class (final API)  Shadow (Robolectric)

2つのダブルが当てはまる場合は、優先順位リストで先にある方を選んでください。

パターン

パターン:誤りと正解 — 状態に対するフェイクとモック
// WRONG — using a mock to provide state
@Test
fun loadsAndDisplaysFirstUser() = runTest {
    val repo = mockk<UserRepository>()
    coEvery { repo.get(UserId("u1")) } returns User(UserId("u1"), "Alice")
    coEvery { repo.get(UserId("u2")) } returns User(UserId("u2"), "Bob")
    coEvery { repo.observeAll() } returns flowOf(
        listOf(User(UserId("u1"), "Alice"), User(UserId("u2"), "Bob"))
    )

    val vm = UserViewModel(repo)
    vm.load(UserId("u1"))
    assertEquals("Alice", vm.state.value.name)
}
// WRONG because: the test uses a mocking framework purely to seed data. Per /test-doubles:
// "Fakes are preferred over stubs for simplicity" and stubs are at least the lighter form
// of mock-as-data-source. A FakeUserRepository compiles, has no every {} ceremony, and
// the seed helper is reusable across tests.
// RIGHT — fake provides state; ViewModel test asserts state
@Test
fun loadsAndDisplaysFirstUser() = runTest {
    val repo = FakeUserRepository().apply {
        seed(User(UserId("u1"), "Alice"), User(UserId("u2"), "Bob"))
    }
    val vm = UserViewModel(repo)
    vm.load(UserId("u1"))
    assertEquals("Alice", vm.state.value.name)
}
パターン:誤りと正解 — フェイクに対する検証
// WRONG — verifying a method call on a fake
@Test
fun save_callsRepository() = runTest {
    val repo = FakeUserRepository()
    val sut = UserSaveOrchestrator(repo)
    sut.save(User(UserId("u1"), "Alice"))

    // Now the test wants to verify save() was called. Fakes don't track calls.
    // Tempting fix: switch to spyk(repo). DON'T.
    val tracking = spyk(repo)
    coVerify { tracking.save(any()) }
}
// WRONG because: this confuses "did the right state result?" (fake territory) with
// "was the right method called?" (mock territory). Pick one. If the test is verifying
// state, assert on repo.get(UserId("u1")) instead. If the test is verifying interaction,
// drop the fake and use a mock.
// RIGHT — fake answers state assertions
@Test
fun save_persistsUser() = runTest {
    val repo = FakeUserRepository()
    val sut = UserSaveOrchestrator(repo)
    sut.save(User(UserId("u1"), "Alice"))

    assertEquals(User(UserId("u1"), "Alice"), repo.get(UserId("u1")))
}

// RIGHT — mock answers interaction assertions (separate test)
@Test
fun save_emitsSyncSignal() = runTest {
    val repo: UserRepository = mock()
    val syncQueue: SyncQueue = mock()
    val sut = UserSaveOrchestrator(repo, syncQueue)
    sut.save(User(UserId("u1"), "Alice"))

    verify(syncQueue).markDirty(UserId("u1"))
}
パターン:誤りと正解 — スパイの過剰な使用
// WRONG
val cache = spyk(LruCache<String, Bitmap>(8))
every { cache.put(any(), any()) } answers {
    // intercept and log
    println("put: ${invocation.args[0]}")
    callOriginal()
}

誤りの理由:/test-doubles によると、スパイは「複雑さを増すため、通常は避けられます。そのため、スパイよりもフェイクまたはモックが推奨されます」。ここでのインターセプターは、テストで検証できるよう呼び出しをリストに記録するフェイク LRU 実装に含めるべきです。

// RIGHT
class RecordingCache : Cache<String, Bitmap> {
    val puts = mutableListOf<Pair<String, Bitmap>>()
    override fun put(key: String, value: Bitmap) { puts += key to value }
    override fun get(key: String): Bitmap? = null
}
パターン:誤りと正解 — Hilt とフェイクのバインディング
// WRONG — production module with no test override
@Module @InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds abstract fun bind(impl: RealUserRepository): UserRepository
}

@HiltAndroidTest
class HomeScreenTest {
    @Inject lateinit var repo: UserRepository      // real repo, real DB, slow + non-hermetic
    // ...
}
// RIGHT — replace at the module boundary
@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [RepositoryModule::class],
)
abstract class FakeRepositoryModule {
    @Binds abstract fun bind(fake: FakeUserRepository): UserRepository
}

このフェイクはテストのソースセットでのみコンパイルされるため、本番環境のグラフには影響しません。Hilt のルールの順序付けパターン全体については、../../strategies/applying-testing-strategies/SKILL.md を参照してください。

必須ルール

  • 必須: 状態を持つ依存関係の置き換えには、モックやスタブよりフェイクを優先してください。/test-doubles にあるとおり、「フェイクが推奨されます」。
  • 必須: インタラクションのアサーションには、スタブやスパイよりモックを優先してください。スタブは検証を伴わないモックです。インタラクションを検証しない場合は、フェイクに切り替えてください。
  • 禁止: 他のすべてのダブルが実用的でなく、部分的な実装との結合によるコストをテストに記載する場合を除き、スパイを使用しないでください。
  • 禁止: フレームワークやライブラリの呼び出しを検証しないでください。(../../concepts/choosing-what-to-test/SKILL.md の「テスト対象にしないもの」を参照してください。)
  • 禁止: /test-doubles の FakeUserRepository のスニペットをそのままコピーしないでください。構文エラー val const UserAlice = User(...) が含まれています。private val または const val を使用してください(この順序で)。
  • 推奨: リポジトリーインターフェースごとに正規の FakeXRepository を1つ、testFixtures ソースセットまたは共有の core-testing モジュールに保持し、ViewModel のテスト間で再利用してください。
  • 推奨: クラスをフェイクにするのが難しい場合は、スパイを使う前に(インターフェースを抽出するなどして)リファクタリングしてください。
  • 推奨: Android の新規コードでは Mockito-kotlin(mock<T>()、whenever、argumentCaptor)を使用してください。CORPUS §G.3 によると、これは androidx/ 自体でも事実上の標準です。MockK は、Kotlin 固有の機能(コルーチン、トップレベル関数、シールドクラスのモック)で必要な場合にのみ使用してください。

検証

  • スイート内のすべての mockk<X>()/mock<X>() に verify { }/verify(x) の呼び出しがある(本物のモックである)か、FakeX に置き換えられている(実際にはフェイクをモックとして扱うアンチパターンだった)こと。
  • フェイクやモックが実用的でない理由を説明するインラインコメントなしに、spyk(x)/spy(x) を使用するテストがないこと。
  • 各リポジトリーインターフェースに、テスト間で共有される正規の FakeXRepository が最大1つであること。
  • 本番コードが Fake* クラスを参照していないこと(テストのソースセットがリリースに漏れないこと)。
  • Hilt ベースの UI テストでは、@TestInstallIn または @UninstallModules と @BindValue を使って実際のバインディングをフェイクに置き換えていること。実際に注入されたインスタンスに対して every { } を使わないこと。
  • 壊れた Kotlin コード val const UserAlice を /test-doubles のページからコピーしたものが、リポジトリー内に存在しないこと。

参考資料

  • developer.android.com/training/testing/fundamentals/test-doubles — 定義、3つの好みに関する引用、Hilt への相互リンクの原文。
  • developer.android.com/training/testing/local-tests — 「注意:複雑なモックは避けてください。代わりに、フェイクや Robolectric のシャドウなど、さまざまな種類のテストダブルを使用できます。」
  • developer.android.com/training/dependency-injection/hilt-testing — @TestInstallIn、@UninstallModules、@BindValue、最初に実行されるよう順序付けられた HiltAndroidRule。
  • tasks/research/R8-android-fundamentals.md — テストダブルに関する引用の原文と、Google のページにある誤記(val const UserAlice)の記載。
  • developer.android.com/develop/ui/compose/testing — Compose 側のテストダブル(複数分野にまたがる内容)。
  • Software Engineering at Google、第13章(「Test Doubles」)— フェイクを優先する理由を詳しく説明しています。https://abseil.io/resources/swe-book
  • 関連スキル:../../concepts/understanding-the-testing-pyramid/SKILL.md、../../concepts/choosing-what-to-test/SKILL.md、../../strategies/applying-testing-strategies/SKILL.md、../../strategies/organizing-test-source-sets/SKILL.md。
  • カテゴリ横断(フレームワークの仕組み):../../../jvm-tests/mocking/mocking-with-mockito/SKILL.md、../../../jvm-tests/mocking/mocking-with-mockk/SKILL.md、../../../jvm-tests/robolectric/using-robolectric-correctly/SKILL.md、../../../jvm-tests/coroutines/testing-coroutines-with-runtest/SKILL.md、../../../jvm-tests/coroutines/testing-flows-with-turbine/SKILL.md。

コメント