Android testing patterns
Skill esaldgut/ai-native-engineering-workspace/global-skills/android/android-testing-patterns
AI-native engineering workspace — 42 Claude Code agent skills, platform-base workflow docs, and a freshness system that re-verifies each pattern against vendor docs.
npx -y skills add esaldgut/ai-native-engineering-workspace --skill android-testing-patternsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Pick the right test type per Clean Architecture layer the canonical Android way — domain/UseCase/ repository as JVM unit tests (JUnit5 via the mannodermaus android-junit5 plugin + MockK with coEvery/ coVerify), ViewModel StateFlow emission sequences with Turbine's test {} + awaitItem(), and Composables as instrumented JUnit4 tests using createComposeRule() + onNodeWithText(). Enforces runTest (NOT runBlockingTest), Dispatchers.setMain for viewModelScope, and useUnmergedTree for granular semantics assertions. Use when writing tests for any Android/Compose layer.
SKILL.md
8.9 KB, as published. Nobody here has run it
Android testing patterns (per-layer test type)
The Android test stack stabilized around four pieces: JUnit5 on the JVM unit tier (via the
mannodermaus android-junit5 plugin), MockK for
mocking (Kotlin-first, suspend-aware), Turbine for asserting
Flow/StateFlow emission sequences, and JUnit4 + Compose Test for instrumented UI. This skill
maps each Clean Architecture layer to its correct tier and template.
When to invoke
- You're writing a test for any layer: a domain
data class, a UseCase, a repository, aViewModel, or a@Composable. - You see
runBlockingTest,everyused on asuspendfunction, or a ViewModel test that asserts onlyuiState.valueand misses the Loading → Loaded transition — fix to the canonical form below.
Announce on invoke: "Using android-testing-patterns to select the per-layer test type (JUnit5 +
MockK + Turbine on the JVM, JUnit4 + Compose Test on device) per developer.android.com."
The per-layer matrix
| Layer | Tier | Framework |
|---|---|---|
Domain data class / pure logic | unit (JVM) | JUnit5 — no Android, no mocks |
| UseCase / Repository | unit (JVM) | JUnit5 + MockK (coEvery) + Turbine (if it returns a Flow) |
| ViewModel | unit (JVM) | JUnit5 + MockK + Turbine + runTest + Dispatchers.setMain |
| Composable (UI) | instrumented (device/emulator) | JUnit4 + createComposeRule() + semantics finders |
| End-to-end | instrumented | JUnit4 + Compose semantics (or Espresso for legacy Views) |
JUnit5 on the JVM tier, JUnit4 on the instrumented Compose tier is the pragmatic split:
developer.android.com examples and the ComposeTestRule are JUnit4 TestRules, so JUnit4 stays the
documented default for UI tests, while JUnit5 (via the plugin, AGP ≥ 8.2) is production-ready for unit
tests. The artifacts: androidx.compose.ui:ui-test-junit4 + debugImplementation androidx.compose.ui:ui-test-manifest for UI; app.cash.turbine:turbine, io.mockk:mockk,
org.jetbrains.kotlinx:kotlinx-coroutines-test for the JVM tier.
The rules (load-bearing — break them and the test lies)
runTest { }, NOTrunBlockingTest.runBlockingTest(kotlinx-coroutines-test ≤ 1.5) is deprecated;runTest { }(1.6+) is the current builder. It auto-skips delays and provides aTestScope.coEvery/coVerifyfor suspend functions, neverevery/verify. Kotlin can't overloadeveryfor asuspendreturn, so MockK provides theco-prefixed variants. Usingeveryon a suspend function compiles but the stub silently never matches the real suspending call. MockK also hascoAnswers { }for computed suspend returns.Dispatchers.setMain(testDispatcher)before constructing a ViewModel. Without it,viewModelScope.launch { }throwsIllegalStateException: Module with the Main dispatcher had failed to initialize. Wrap it in a JUnit5@BeforeEach/extension (or a JUnit4MainDispatcherRule) andDispatchers.resetMain()after.- Turbine for emission sequences;
StateFlow.valueonly for the final snapshot. Assertingvm.uiState.valuecatches only the terminal state and misses Loading → Loaded → Error transitions. Turbine'stest { }+awaitItem()enforces the full event log. End the block withcancelAndIgnoreRemainingEvents()(this is the current API — notcancelAndConsumeRemainingEvents). useUnmergedTree = truefor child-node assertions. The semantics tree merges leaf nodes into their parent for accessibility; to assert on aTextinside aButtonyou needonNodeWithText("…", useUnmergedTree = true), otherwise the test passes/fails for the wrong reason.
Canonical example
// ---------- ViewModel test: JUnit5 + MockK + Turbine ----------
@ExtendWith(MainDispatcherExtension::class) // calls Dispatchers.setMain in beforeEach / resetMain in after
class MyFeatureViewModelTest {
private val repo = mockk<MyRepository>()
private lateinit var vm: MyFeatureViewModel
@BeforeEach
fun setUp() {
coEvery { repo.fetchItem("42") } returns MyItem("42", "hello") // co-prefixed for suspend
vm = MyFeatureViewModel(repo)
}
@Test
fun `emits Loading then Loaded`() = runTest { // runTest, not runBlockingTest
vm.uiState.test { // Turbine
assertEquals(MyUiState.Loading, awaitItem())
assertEquals(MyUiState.Loaded(MyItem("42", "hello")), awaitItem())
cancelAndIgnoreRemainingEvents() // current Turbine API
}
coVerify(exactly = 1) { repo.fetchItem("42") } // co-prefixed verify
}
}
// ---------- Composable test: JUnit4 + Compose Test ----------
class MyFeatureScreenTest {
@get:Rule val composeTestRule = createComposeRule() // androidx.compose.ui.test.junit4
@Test
fun showsLoadedItem() {
composeTestRule.setContent { MyFeatureScreen(viewModel = fakeLoadedViewModel()) }
composeTestRule.onNodeWithText("hello").assertIsDisplayed()
}
}
Decision aids
- MockK vs hand-written fakes for the ViewModel test? Both are valid. MockK is faster to write for
one-off stubs; a
class FakeRepository : MyRepositoryis clearer when many tests share the same canned data and you want zero mocking framework in the ViewModel tier. Pick fakes when the fake is reused ≥3×. - One dispatcher helper or two? Provide a single
MainDispatcherExtension(JUnit5) for the JVM tier and add aMainDispatcherRule(JUnit4) only if some tests stay on JUnit4. Don't maintain both unless the codebase genuinely runs a mixed tier. StandardTestDispatchervsUnconfinedTestDispatcher?StandardTestDispatcher(default inrunTest) queues coroutines so you control ordering withadvanceUntilIdle();UnconfinedTestDispatcherruns them eagerly. Use Unconfined when astateIn/WhileSubscribedupstream must emit immediately for the Turbine assertion.
Related skills
global-skills/android/compose-clean-architecture-module-scaffold/SKILL.md— the layered code these tests target (theuiState: StateFlowshape the Turbine test asserts on).global-skills/android/android-security-checklist/SKILL.md— negative/boundary security tests (injection, malformed tokens) complement these functional tests.
Sources
- Test your Compose layout · Compose testing APIs · Testing Kotlin flows on Android
- Turbine · MockK · mannodermaus/android-junit5
Last verified: 2026-06-03 against developer.android.com (Compose testing + flow testing live),
Turbine 1.2.1 (cancelAndIgnoreRemainingEvents confirmed; cancelAndConsumeRemainingEvents does not
exist), MockK 1.14.x, android-junit5 plugin 1.14.0.0.
Re-check after: Google I/O 2026 + next Compose BOM major, or by 2026-12-01. Decay risk: medium.
Found a drift? Run /skill-pattern-freshness-audit android.