mirror of
https://github.com/bitwarden/android.git
synced 2026-05-02 21:28:52 -05:00
162 lines
5.0 KiB
Markdown
162 lines
5.0 KiB
Markdown
/**
|
|
* 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()
|
|
}
|