Android testing patterns
Skill esaldgut/ai-native-engineering-workspace/global-skills/android/android-testing-patterns
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.From its SKILL.md
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.
SKILL.md
8.9 KB, ~1.9k tokens by cl100k_base, 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.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.