.claude/skills/testing-pyramid/SKILL.md
Write and fix Unit, Hilt integration, and Espresso tests following MovieFinder patterns
npx skillsauth add ChooJeongHo/MovieFinder testing-pyramidInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Write or fix tests for $ARGUMENTS following this project's exact test patterns.
@OptIn(ExperimentalCoroutinesApi::class)
class FooViewModelTest {
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
// init mocks
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
}
ViewModel
UiState transitions: Loading → Success → ErrorSavedStateHandle argument survivalChannel.CONFLATED@Test
fun `load detail - success emits Success state`() = runTest {
coEvery { getMovieDetailUseCase(123) } returns Result.success(fakeMovie)
viewModel.loadDetail(123)
testDispatcher.advanceUntilIdle()
assertIs<DetailUiState.Success>(viewModel.uiState.value)
}
UseCase
@Test
fun `invoke delegates to repository`() = runTest {
useCase(123)
coVerify(exactly = 1) { repository.getMovie(123) }
}
Repository Implementation
PagingSource
LoadResult.Page with correct nextKey/prevKeynextKey = nullLoadResult.Error@Test
fun `load returns page with nextKey`() = runTest {
val result = pagingSource.load(
PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
)
val page = assertIs<PagingSource.LoadResult.Page<Int, Movie>>(result)
assertEquals(2, page.nextKey)
}
RemoteMediator
REFRESH with expired cache → calls API, writes to DBREFRESH with fresh cache → skips API (Success(endOfPaginationReached = false))APPEND with no more pages → Success(endOfPaginationReached = true)Notification / Helper
// suspend function
coEvery { repository.getMovie(any()) } returns fakeMovie
coVerify(exactly = 1) { repository.getMovie(123) }
// Flow
every { repository.getFavorites() } returns flowOf(listOf(fakeMovie))
// void suspend
coEvery { repository.save(any()) } returns Unit
// ExperimentalTime opt-in if using Clock/Instant
@file:OptIn(kotlin.time.ExperimentalTime::class)
@Test
fun `favorites flow emits list`() = runTest {
every { repository.getFavorites() } returns flowOf(listOf(fakeMovie))
viewModel.favorites.test {
assertEquals(listOf(fakeMovie), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
// build.gradle.kts — already set in this project:
testOptions {
unitTests.isReturnDefaultValues = true // Android API stubs return default values
}
Used for: Repository with real Room DB, DataStore reads.
@HiltAndroidTest
class FavoriteRepositoryTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject lateinit var repository: FavoriteRepository
@Inject lateinit var db: MovieDatabase
@Before
fun setup() { hiltRule.inject() }
@After
fun tearDown() { db.close() }
}
Key rules:
testInstrumentationRunner = "com.choo.moviefinder.HiltTestRunner" (already configured)@HiltAndroidTest + HiltAndroidRule on every test classRoom.inMemoryDatabaseBuilder()kspAndroidTest(libs.hilt.compiler) already in build.gradle.kts@HiltAndroidTest
class MainActivityTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun bottomNav_clickSearch_navigatesToSearchFragment() {
onView(withId(R.id.navigation_search)).perform(click())
onView(withId(R.id.searchFragment)).check(matches(isDisplayed()))
}
}
Rules:
./gradlew connectedDebugAndroidTest@Binds DI wiring (Hilt compile-time checked)| Failure | Cause | Fix |
|---------|-------|-----|
| UninitializedPropertyAccessException | Mock not set in @Before | Add mockk() init |
| MockKException: no answer found | suspend fun not stubbed with coEvery | Change every → coEvery |
| ClassCastException on withTransaction | Room transaction mocking limitation | Use @Transaction on DAO abstract class instead |
| Test passes locally, fails on CI | Dispatchers.Main not reset | Add Dispatchers.resetMain() in @After |
| Flow never emits in test | Missing testDispatcher.advanceUntilIdle() | Add after triggering the action |
## Test Plan: [Target Class]
### Coverage Target
[Which behaviors need tests? List as: "when X → should Y"]
### Test Cases
[For each test: method name + setup + assertion]
### Mock Setup
[Which dependencies need mocking + what to stub]
### Generated Tests
[Full Kotlin test code]
development
Systematically diagnose and fix bugs using evidence-driven root cause analysis
tools
Diagnose and fix Kotlin coroutine race conditions, Flow issues, and thread safety bugs
development
Optimize Gradle build speed with Configuration Cache, parallel execution, and AGP 9 tuning
development
Enforce Clean Architecture and Offline-first patterns in this MovieFinder codebase