android/.claude/skills/testing-android-code/examples/viewmodel-test-example.md
Patrick Honkonen d49629de9e
Add Android testing skill for Claude (#6370)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 18:42:01 +00:00

5.0 KiB

/**

  • Complete ViewModel Test Example
  • Key patterns demonstrated:
    • Extending BaseViewModelTest
    • Testing StateFlow with Turbine
    • Testing EventFlow with Turbine
    • Using stateEventFlow() for simultaneous testing
    • MockK mocking patterns
    • Test factory method design (accepts domain state, not SavedStateHandle)
    • Complete state assertions (assert entire state objects) */ package com.bitwarden.example.feature

import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.ui.platform.base.BaseViewModelTest import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test

class ExampleViewModelTest : BaseViewModelTest() {

// Mock dependencies
private val mockRepository: ExampleRepository = mockk()
private val mockAuthDiskSource: AuthDiskSource = mockk {
    every { userStateFlow } returns MutableStateFlow(null)
}

/**
 * StateFlow has replay=1, so first awaitItem() returns current state
 */
@Test
fun `initial state should be default state`() = runTest {
    val viewModel = createViewModel()

    viewModel.stateFlow.test {
        assertEquals(DEFAULT_STATE, awaitItem())
    }
}

/**
 * Test state transitions: initial -> loading -> success
 */
@Test
fun `LoadData action should update state from idle to loading to success`() = runTest {
    val expectedData = "loaded data"
    coEvery { mockRepository.fetchData(any()) } returns Result.success(expectedData)

    val viewModel = createViewModel()

    viewModel.stateFlow.test {
        assertEquals(DEFAULT_STATE, awaitItem())

        viewModel.trySendAction(ExampleAction.LoadData)

        assertEquals(DEFAULT_STATE.copy(isLoading = true), awaitItem())
        assertEquals(DEFAULT_STATE.copy(isLoading = false, data = expectedData), awaitItem())
    }

    coVerify { mockRepository.fetchData(any()) }
}

/**
 * EventFlow has no replay - MUST call expectNoEvents() first
 */
@Test
fun `SubmitClick action should emit NavigateToNext event`() = runTest {
    coEvery { mockRepository.submitData(any()) } returns Result.success(Unit)

    val viewModel = createViewModel()

    viewModel.eventFlow.test {
        expectNoEvents() // CRITICAL for EventFlow
        viewModel.trySendAction(ExampleAction.SubmitClick)
        assertEquals(ExampleEvent.NavigateToNext, awaitItem())
    }
}

/**
 * Use stateEventFlow() helper for simultaneous testing
 */
@Test
fun `complex action should update state and emit event`() = runTest {
    coEvery { mockRepository.complexOperation(any()) } returns Result.success("result")

    val viewModel = createViewModel()

    viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
        assertEquals(DEFAULT_STATE, stateFlow.awaitItem())
        eventFlow.expectNoEvents()

        viewModel.trySendAction(ExampleAction.ComplexAction)

        assertEquals(DEFAULT_STATE.copy(isLoading = true), stateFlow.awaitItem())
        assertEquals(DEFAULT_STATE.copy(data = "result"), stateFlow.awaitItem())
        assertEquals(ExampleEvent.ShowToast("Success!"), eventFlow.awaitItem())
    }
}

/**
 * Test state restoration from saved state.
 * Note: Use initialState parameter, NOT SavedStateHandle directly.
 */
@Test
fun `initial state from saved state should be preserved`() = runTest {
    // Build complete expected state - always assert full objects
    val savedState = ExampleState(
        isLoading = false,
        data = "restored data",
        errorMessage = null,
    )

    val viewModel = createViewModel(initialState = savedState)

    viewModel.stateFlow.test {
        assertEquals(savedState, awaitItem())
    }
}

/**
 * Factory method accepts domain state, NOT SavedStateHandle.
 * This hides Android framework details from test logic.
 */
private fun createViewModel(
    initialState: ExampleState? = null,
): ExampleViewModel = ExampleViewModel(
    savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
    repository = mockRepository,
    authDiskSource = mockAuthDiskSource,
)

}

private val DEFAULT_STATE = ExampleState( isLoading = false, data = null, errorMessage = null, )

// Example types (normally in separate files) data class ExampleState( val isLoading: Boolean = false, val data: String? = null, val errorMessage: String? = null, )

sealed class ExampleAction { data object LoadData : ExampleAction() data object SubmitClick : ExampleAction() data object ComplexAction : ExampleAction() }

sealed class ExampleEvent { data object NavigateToNext : ExampleEvent() data class ShowToast(val message: String) : ExampleEvent() }