Learn how to test Android ViewModels, UI states, user actions, one-time events, SavedStateHandle, state restoration, navigation, duplicate actions, MVVM state holders and MVI reducers using fake repositories, mocks, coroutine test dispatchers and deterministic time providers.
1. Why ViewModels Need Tests
A ViewModel usually coordinates:
- User actions
- Use cases and repositories
- Loading, success and error states
- Input validation
- Navigation events
- Retry behaviour
- Data transformation
- State restoration
- Coroutine execution
It contains important presentation logic even though it does not draw the UI itself.
Example flow
flowchart LR
UI["UI action"]
VM["ViewModel"]
UC["Use case"]
RP["Repository"]
STATE["UI state"]
UI --> VM
VM --> UC
UC --> RP
RP --> VM
VM --> STATE
STATE --> UIViewModel tests help verify that every input produces the correct state or event.
2. ViewModel Test Boundaries
A ViewModel unit test should normally include:
- Real ViewModel
- Real state-transition logic
- Real input validation owned by the ViewModel
- Fake or mocked use cases/repositories
- Test coroutine dispatcher
- Controlled clock or ID generator when needed
It should normally exclude:
- Real network
- Real database
- Activity or Fragment
- Compose UI
- Android navigation controller
- Real system clock
flowchart LR
ACTION["User action"]
VM["Real ViewModel"]
FAKE["Fake repository"]
STATE["State/Event"]
API["Real API"]
UI["Real UI"]
ACTION --> VM
VM --> FAKE
VM --> STATE
FAKE -. excluded .-> API
STATE -. tested separately .-> UIMain objective
Test the ViewModel through:
- Public functions
- Public state
- Public events
Avoid testing private methods or internal variables.
Common Example Used in These Notes
Models and states
data class Task(
val id: Long,
val title: String
)
sealed interface TaskUiState {
data object Loading : TaskUiState
data object Empty : TaskUiState
data class Success(val tasks: List<Task>) : TaskUiState
data class Error(val message: String) : TaskUiState
}
sealed interface TaskEvent {
data class ShowMessage(val message: String) : TaskEvent
data class NavigateToDetails(val taskId: Long) : TaskEvent
}Repository
interface TaskRepository {
suspend fun getTasks(): List<Task>
suspend fun saveTask(title: String): Task
}ViewModel
class TaskViewModel(
private val repository: TaskRepository,
private val dispatcher: CoroutineDispatcher,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _state =
MutableStateFlow<TaskUiState>(TaskUiState.Loading)
val state: StateFlow<TaskUiState> =
_state.asStateFlow()
private val _events =
MutableSharedFlow<TaskEvent>(extraBufferCapacity = 1)
val events: SharedFlow<TaskEvent> =
_events.asSharedFlow()
fun loadTasks() {
viewModelScope.launch(dispatcher) {
_state.value = TaskUiState.Loading
_state.value = try {
val tasks = repository.getTasks()
if (tasks.isEmpty()) {
TaskUiState.Empty
} else {
TaskUiState.Success(tasks)
}
} catch (exception: IOException) {
TaskUiState.Error("Check your internet connection")
} catch (exception: Exception) {
TaskUiState.Error("Something went wrong")
}
}
}
fun retry() {
loadTasks()
}
fun onTaskClicked(taskId: Long) {
savedStateHandle["selected_task_id"] = taskId
_events.tryEmit(
TaskEvent.NavigateToDetails(taskId)
)
}
}3. Testing Initial State
Initial state is the value available before any user action or asynchronous operation.
@Test
fun initialState_isLoading() = runTest {
val viewModel = createViewModel()
assertEquals(
TaskUiState.Loading,
viewModel.state.value
)
}Possible initial states include:
- Loading
- Empty form
- Previously restored data
- Idle
- Unauthenticated
The initial state should be explicit and predictable.
4. Testing Loading State
Loading state indicates that work is in progress.
Because work may finish immediately in tests, capture emissions instead of checking only the final value.
@Test
fun loadTasks_emitsLoadingBeforeSuccess() = runTest {
val repository = FakeTaskRepository(
tasks = listOf(Task(1, "Testing"))
)
val viewModel = createViewModel(repository)
viewModel.state.test {
assertEquals(TaskUiState.Loading, awaitItem())
viewModel.loadTasks()
assertEquals(
TaskUiState.Success(
listOf(Task(1, "Testing"))
),
awaitItem()
)
}
}Depending on the initial state, StateFlow may not emit another identical Loading value because equal consecutive values are conflated.
A clearer design may use Idle as the initial state:
sealed interface TaskUiState {
data object Idle : TaskUiState
data object Loading : TaskUiState
// Other states
}Then the expected transition becomes:
Idle → Loading → Success
5. Testing Success State
Verify the complete data, not only the state type.
@Test
fun repositoryReturnsTasks_stateBecomesSuccess() = runTest {
val expected = listOf(
Task(1, "Learn JUnit"),
Task(2, "Test ViewModel")
)
val repository = FakeTaskRepository(tasks = expected)
val viewModel = createViewModel(repository)
viewModel.loadTasks()
advanceUntilIdle()
assertEquals(
TaskUiState.Success(expected),
viewModel.state.value
)
}Also test:
- Correct order
- Correct mapping
- Duplicate removal if required
- Cached-data indicator
- Pagination metadata
6. Testing Empty State
An empty response is often different from an error.
@Test
fun repositoryReturnsEmptyList_stateBecomesEmpty() = runTest {
val repository = FakeTaskRepository(
tasks = emptyList()
)
val viewModel = createViewModel(repository)
viewModel.loadTasks()
advanceUntilIdle()
assertEquals(
TaskUiState.Empty,
viewModel.state.value
)
}The test confirms that:
No data ≠ FailureExamples:
- No search result → Empty
- First-time user has no tasks → Empty
- API failure → Error
7. Testing Error State
The repository should produce a controlled failure.
@Test
fun repositoryThrowsIOException_stateShowsNetworkError() = runTest {
val repository = FakeTaskRepository(
exception = IOException("No network")
)
val viewModel = createViewModel(repository)
viewModel.loadTasks()
advanceUntilIdle()
assertEquals(
TaskUiState.Error(
"Check your internet connection"
),
viewModel.state.value
)
}Important error scenarios:
- No network
- Timeout
- Unauthorized
- Server error
- Parsing failure
- Unknown exception
- Database failure
Do not expose raw technical messages such as stack traces to users.
8. Testing Retry Actions
Retry should start the operation again and recover when the dependency succeeds.
@Test
fun retry_afterFailure_loadsDataSuccessfully() = runTest {
val repository = FakeTaskRepository(
exception = IOException()
)
val viewModel = createViewModel(repository)
viewModel.loadTasks()
advanceUntilIdle()
assertTrue(viewModel.state.value is TaskUiState.Error)
repository.exception = null
repository.tasks = listOf(Task(1, "Recovered"))
viewModel.retry()
advanceUntilIdle()
assertEquals(
TaskUiState.Success(
listOf(Task(1, "Recovered"))
),
viewModel.state.value
)
assertEquals(2, repository.loadCount)
}Retry tests should verify:
- Operation runs again
- Loading appears if required
- Previous error is cleared
- New result is displayed
- Retry does not create unnecessary duplicate work
9. Testing State Transitions
A state-transition test verifies the complete sequence.
stateDiagram-v2
[*] --> Idle
Idle --> Loading: Load
Loading --> Success: Data received
Loading --> Empty: No data
Loading --> Error: Request fails
Error --> Loading: RetryUsing Turbine:
@Test
fun loadTasks_emitsCorrectStateSequence() = runTest {
val viewModel = createViewModel(
FakeTaskRepository(
tasks = listOf(Task(1, "Testing"))
)
)
viewModel.state.test {
assertEquals(TaskUiState.Idle, awaitItem())
viewModel.loadTasks()
assertEquals(TaskUiState.Loading, awaitItem())
assertEquals(
TaskUiState.Success(
listOf(Task(1, "Testing"))
),
awaitItem()
)
cancelAndIgnoreRemainingEvents()
}
}Test the sequence when intermediate states are meaningful. Test only the final state when intermediate emissions do not matter.
10. Testing User Actions
Convert UI interactions into explicit ViewModel functions or actions.
Examples:
* `onRefresh()`
* `onRetry()`
* `onTaskClicked(id)`
* `onSearchQueryChanged(query)`
* `onSaveClicked()`
* `onDeleteConfirmed()`@Test
fun searchQueryChanged_updatesQueryState() {
val viewModel = createViewModel()
viewModel.onSearchQueryChanged("Android")
assertEquals(
"Android",
viewModel.state.value.query
)
}Test:
- Valid action
- Invalid action
- Repeated action
- Action during loading
- Action after error
- Action with restored state
11. Testing One-Time UI Events
State and events have different purposes.
| State | Event |
|---|---|
| Current screen condition | Something that happens once |
| Loading | Show snackbar |
| Task list | Navigate |
| Error content | Open dialog |
| Form values | Request permission |
One-time events can be represented with:
- SharedFlow
- Channel
- An effect stream in MVI
SharedFlow test
Start collecting before producing the event:
@Test
fun saveSucceeds_emitsSuccessMessage() = runTest {
val viewModel = createViewModel()
viewModel.events.test {
viewModel.saveTask("Learn testing")
advanceUntilIdle()
assertEquals(
TaskEvent.ShowMessage("Task saved"),
awaitItem()
)
}
}Common issue
A SharedFlow with replay = 0 does not replay an old event to a collector that subscribes later.
12. Testing Error-Message Mapping
Technical errors should map to stable UI messages or UI error types.
sealed interface AppError {
data object Network : AppError
data object Unauthorized : AppError
data object Server : AppError
data object Unknown : AppError
}Prefer state containing a resource identifier or typed error:
data class Error(
@StringRes val messageRes: Int
) : TaskUiStateTest mapping:
@Test
fun unauthorizedError_mapsToSessionExpiredMessage() {
val result = errorMapper.map(
AppError.Unauthorized
)
assertEquals(
UiError.SessionExpired,
result
)
}Avoid asserting localized text in a pure ViewModel test when a typed error or resource identifier is sufficient.
13. Testing ViewModel with a Fake Repository
A fake is a simplified working repository.
class FakeTaskRepository(
var tasks: List<Task> = emptyList(),
var exception: Exception? = null
) : TaskRepository {
var loadCount = 0
val savedTitles = mutableListOf<String>()
override suspend fun getTasks(): List<Task> {
loadCount++
exception?.let { throw it }
return tasks
}
override suspend fun saveTask(title: String): Task {
exception?.let { throw it }
savedTitles += title
return Task(savedTitles.size.toLong(), title)
}
}Advantages:
- Readable
- Reusable
- Supports state
- Supports success and failure
- Less coupled to implementation
Example:
@Test
fun saveTask_validTitle_repositoryReceivesTitle() = runTest {
val repository = FakeTaskRepository()
val viewModel = createViewModel(repository)
viewModel.saveTask("Android Testing")
advanceUntilIdle()
assertEquals(
listOf("Android Testing"),
repository.savedTitles
)
}14. Testing ViewModel with Mocks
Mocks are useful when interactions matter.
MockK example
@Test
fun loadTasks_callsRepositoryOnce() = runTest {
val repository = mockk<TaskRepository>()
coEvery {
repository.getTasks()
} returns listOf(Task(1, "Testing"))
val viewModel = createViewModel(repository)
viewModel.loadTasks()
advanceUntilIdle()
coVerify(exactly = 1) {
repository.getTasks()
}
assertEquals(
TaskUiState.Success(
listOf(Task(1, "Testing"))
),
viewModel.state.value
)
}Use mocks to verify:
- Repository is not called for invalid input
- Duplicate submission is prevented
- Logout clears a session once
- Analytics event is sent
- Correct request reaches a dependency
Do not verify every internal call.
15. Testing SavedStateHandle
SavedStateHandle stores small values needed to recreate ViewModel state.
Reading navigation arguments
class DetailViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
val taskId: Long =
checkNotNull(savedStateHandle["task_id"])
}Test
@Test
fun taskId_isReadFromSavedStateHandle() {
val handle = SavedStateHandle(
mapOf("task_id" to 101L)
)
val viewModel = DetailViewModel(handle)
assertEquals(101L, viewModel.taskId)
}Testing writes
@Test
fun queryChanged_queryIsStored() {
val handle = SavedStateHandle()
val viewModel = SearchViewModel(handle)
viewModel.onQueryChanged("JUnit")
assertEquals(
"JUnit",
handle.get<String>("search_query")
)
}Store small, serializable state—not repositories, Activities or large object graphs.
16. Testing State Restoration
State-restoration testing verifies that a recreated ViewModel uses previously saved values.
@Test
fun recreatedViewModel_restoresSearchQuery() {
val handle = SavedStateHandle(
mapOf("search_query" to "Android")
)
val recreatedViewModel = SearchViewModel(handle)
assertEquals(
"Android",
recreatedViewModel.state.value.query
)
}Test restoration of:
- Search query
- Selected filter
- Selected item ID
- Form draft
- Current page
- Pending transaction ID
Do not assume SavedStateHandle replaces permanent storage. Important durable data belongs in a repository or database.
17. Testing Navigation Events
A ViewModel should normally emit navigation instructions rather than using NavController directly.
@Test
fun taskClicked_emitsNavigationEvent() = runTest {
val viewModel = createViewModel()
viewModel.events.test {
viewModel.onTaskClicked(10L)
assertEquals(
TaskEvent.NavigateToDetails(10L),
awaitItem()
)
}
}Good navigation event:
NavigateToDetails(taskId = 10)Avoid passing Android objects:
NavigateWithIntent(intent) // ViewModel now depends on AndroidThe UI converts the event into an actual navigation call.
18. Testing Duplicate Actions
Duplicate actions can cause:
- Multiple API requests
- Duplicate payments
- Repeated navigation
- Duplicate database records
- Multiple dialogs
Example ViewModel protection
private var saveJob: Job? = null
fun onSaveClicked(title: String) {
if (saveJob?.isActive == true) return
saveJob = viewModelScope.launch(dispatcher) {
repository.saveTask(title)
}
}Test
@Test
fun saveClickedTwice_whileSaving_repositoryCalledOnce() = runTest {
val repository = mockk<TaskRepository>()
coEvery {
repository.saveTask(any())
} coAnswers {
delay(1_000)
Task(1, firstArg())
}
val viewModel = createViewModel(repository)
viewModel.onSaveClicked("Testing")
viewModel.onSaveClicked("Testing")
advanceUntilIdle()
coVerify(exactly = 1) {
repository.saveTask("Testing")
}
}For payment applications, also use server-side idempotency. UI/ViewModel protection alone is not sufficient.
19. Testing MVI Reducers
A reducer converts the previous state and a result into a new state.
New State = Reducer(Old State,Result)
A reducer should ideally be a pure function.
data class TaskState(
val isLoading: Boolean = false,
val tasks: List<Task> = emptyList(),
val error: String? = null
)
sealed interface TaskResult {
data object Loading : TaskResult
data class Loaded(val tasks: List<Task>) : TaskResult
data class Failed(val message: String) : TaskResult
}fun reduce(
current: TaskState,
result: TaskResult
): TaskState {
return when (result) {
TaskResult.Loading ->
current.copy(isLoading = true, error = null)
is TaskResult.Loaded ->
current.copy(
isLoading = false,
tasks = result.tasks,
error = null
)
is TaskResult.Failed ->
current.copy(
isLoading = false,
error = result.message
)
}
}
fun reduce(
current: TaskState,
result: TaskResult
): TaskState {
return when (result) {
TaskResult.Loading ->
current.copy(isLoading = true, error = null)
is TaskResult.Loaded ->
current.copy(
isLoading = false,
tasks = result.tasks,
error = null
)
is TaskResult.Failed ->
current.copy(
isLoading = false,
error = result.message
)
}
}Reducer test
@Test
fun failedResult_stopsLoadingAndSetsError() {
val current = TaskState(isLoading = true)
val result = reduce(
current,
TaskResult.Failed("No network")
)
assertEquals(
TaskState(
isLoading = false,
error = "No network"
),
result
)
}Reducers are easy to test because they do not require Android or coroutine dependencies.
20. Testing Intents, Actions, State and Effects
A common MVI flow is:
flowchart LR
I["Intent"]
A["Action"]
R["Result"]
S["State"]
E["Effect"]
I --> A
A --> R
R --> S
R --> EDefinitions
| Element | Meaning | Example |
|---|---|---|
| Intent | User/system input | Save clicked |
| Action | Work to perform | Validate and save |
| Result | Operation outcome | Saved or failed |
| State | Persistent UI data | Loading, tasks, error |
| Effect | One-time output | Navigate or snackbar |
What to test
1. Intent produces correct action.
2. Action invokes correct dependency.
3. Result produces correct state.
4. One-time result produces correct effect.
5. Invalid intent does not trigger work.
6. Repeated intent is handled correctly.
@Test
fun saveIntent_validTitle_emitsSavedEffect() = runTest {
viewModel.effects.test {
viewModel.process(
TaskIntent.Save("Learn MVI")
)
assertEquals(
TaskEffect.ShowSavedMessage,
awaitItem()
)
}
}21. Testing MVVM State Holders
An MVVM ViewModel commonly exposes:
StateFlow<UiState>- Public event-handling functions
SharedFlow<UiEvent>for one-time events
data class LoginUiState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val error: UiError? = null
)Tests should verify:
- Input updates state
- Invalid form prevents login
- Loading begins
- Success produces navigation
- Failure sets error
- Retry clears the error
- Password is not restored if security policy forbids it
Test the complete state value when practical:
assertEquals(
LoginUiState(
email = "user@example.com",
password = "",
isLoading = false,
error = UiError.PasswordRequired
),
viewModel.state.value
)22. Making ViewModels Independent of Android Classes
A ViewModel should avoid directly depending on:
ActivityFragmentViewNavController- Android
Context ToastIntent- Android resources where avoidable
Hard-to-test design
class LoginViewModel(
private val context: Context
) : ViewModel() {
fun showError() {
Toast.makeText(
context,
"Login failed",
Toast.LENGTH_SHORT
).show()
}
}Testable design
class LoginViewModel : ViewModel() {
private val _events =
MutableSharedFlow<LoginEvent>()
val events = _events.asSharedFlow()
fun onLoginFailed() {
viewModelScope.launch {
_events.emit(LoginEvent.ShowError)
}
}
}The UI decides how to display the error.
23. Dispatcher Injection
Hardcoded dispatcher
viewModelScope.launch(Dispatchers.IO) {
repository.getTasks()
}This makes scheduling harder to control in tests.
Injected dispatcher
class TaskViewModel(
private val repository: TaskRepository,
private val ioDispatcher: CoroutineDispatcher
) : ViewModel() {
fun loadTasks() {
viewModelScope.launch(ioDispatcher) {
repository.getTasks()
}
}
}Production:
TaskViewModel(
repository = repository,
ioDispatcher = Dispatchers.IO
)Test:
val dispatcher =
StandardTestDispatcher(testScheduler)
val viewModel = TaskViewModel(
repository = fakeRepository,
ioDispatcher = dispatcher
)All test dispatchers should share the same TestCoroutineScheduler.
Main Dispatcher Test Rule
viewModelScope uses Dispatchers.Main, so local JVM tests commonly replace it.
class MainDispatcherRule(
val dispatcher: TestDispatcher =
StandardTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}Usage:
@get:Rule
val mainDispatcherRule = MainDispatcherRule()Then:
@Test
fun loadTasks_updatesState() = runTest {
val viewModel = TaskViewModel(
repository = fakeRepository,
dispatcher = mainDispatcherRule.dispatcher,
savedStateHandle = SavedStateHandle()
)
viewModel.loadTasks()
advanceUntilIdle()
assertTrue(
viewModel.state.value is TaskUiState.Success
)
}24. Clock and Time-Provider Injection
Code using the real system time can become nondeterministic.
Hard-to-test code
val isExpired =
System.currentTimeMillis() > expiryTimeInjectable clock
interface TimeProvider {
fun currentTimeMillis(): Long
}
class SystemTimeProvider : TimeProvider {
override fun currentTimeMillis(): Long {
return System.currentTimeMillis()
}
}Fake time provider
class FakeTimeProvider(
var currentTime: Long
) : TimeProvider {
override fun currentTimeMillis(): Long {
return currentTime
}
}Test
@Test
fun currentTimeAfterExpiry_sessionIsExpired() {
val timeProvider = FakeTimeProvider(
currentTime = 2_000L
)
val checker = SessionExpiryChecker(timeProvider)
assertTrue(
checker.isExpired(expiryTime = 1_000L)
)
}Inject time for:
- Token expiry
- Cache expiry
- Retry windows
- Transaction timestamps
- Date grouping
- Countdown timers
- Scheduled actions
Java’s Clock can also be injected instead of creating a custom interface.
25. ViewModel Testing Common Mistakes
1. Testing private functions
2. Creating a real API or database
3. Using Thread.sleep()4. Hardcoding Dispatchers.IO5. Forgetting to replace Dispatchers.Main6. Checking only the final state when transition order matters
7. Expecting StateFlow to re-emit an equal value
8. Collecting SharedFlow after the event was emitted
9. Not cancelling infinite Flow collection
10. Verifying every repository call
11. Using raw exception messages as UI text
12. Passing Activity, Context or NavController into ViewModel
13. Making tests depend on execution order
14. Sharing mutable fakes between tests
15. Ignoring error and empty states
16. Ignoring duplicate user actions
17. Testing Compose UI inside a ViewModel unit test
18. Using real system time
19. Testing only mock interactions without asserting state
20. Putting all business logic in the ViewModel
21. Assuming ViewModel tests replace integration and UI tests
22. Storing large objects in SavedStateHandle
Practical Testing Matrix
| Behaviour | Expected result |
|---|---|
| ViewModel created | Initial state |
| Load starts | Loading |
| Repository returns data | Success |
| Repository returns no data | Empty |
| Repository fails | Error |
| Retry succeeds | Error → Loading → Success |
| Task clicked | Navigation event |
| Search changed | State and SavedStateHandle updated |
| ViewModel recreated | Search restored |
| Save tapped twice | One repository request |
| Network error occurs | User-friendly error |
| MVI result received | Correct reduced state |
---
Interview Questions and Answers
1. Why should a ViewModel be unit-tested?
Because it contains presentation logic, handles user actions and converts domain results into UI states and events.
2. What should be included in a ViewModel test boundary?
The real ViewModel and its state logic, with fake or mocked external dependencies and controlled coroutine scheduling.
3. Should a ViewModel test use a real database or API?
No. Those components belong in integration tests.
4. How do you test the initial state?
Create the ViewModel and assert the current value of its exposed state before performing an action.
5. How do you test a loading state?
Trigger the operation and collect state emissions to verify that loading appears before the result.
6. How do you test a success state?
Configure the dependency to return data, execute the action, advance the test scheduler and compare the complete success state.
7. How should an empty result be represented?
Use a distinct empty state when “no data” requires different UI behaviour from success or failure.
8. How do you test an error state?
Make the fake or mock dependency throw a controlled exception and verify the mapped UI error.
9. How do you test retry behaviour?
First produce an error, change the fake dependency to succeed, call retry and verify the new state sequence.
10. What is state-transition testing?
It verifies the ordered movement between states, such as Idle → Loading → Success.
11. How should user actions be exposed to a ViewModel?
Through public functions or typed intents, such as onRetry() or process(TaskIntent.Refresh).
12. What is a one-time UI event?
A temporary occurrence such as navigation, snackbar display or permission request that should not behave like persistent screen state.
13. StateFlow vs SharedFlow in a ViewModel?
StateFlow represents current state and always has a value. SharedFlow is commonly used for event streams and does not require an initial value.
14. How do you test a SharedFlow event?
Begin collecting before triggering the action and assert the emitted event using Turbine or another controlled collector.
15. Why may a SharedFlow event be missed?
With replay = 0, events emitted before the collector subscribes are not replayed.
16. How should errors be represented in ViewModel state?
Prefer typed UI errors or string-resource identifiers instead of raw technical exception messages.
17. Fake repository vs mocked repository for ViewModel testing?
A fake provides reusable stateful behaviour. A mock is useful for verifying important interactions and invocation counts.
18. When should a mock be used in a ViewModel test?
When the interaction is part of the requirement, such as ensuring a payment request is submitted once.
19. What is SavedStateHandle?
It is a key-value mechanism used by ViewModels to retain small restorable values across recreation and system-initiated process restoration.
20. How do you test SavedStateHandle?
Create it with a test map, pass it to the ViewModel and verify that values are read or written correctly.
21. Is SavedStateHandle a replacement for a database?
No. It is meant for small restorable UI state, not permanent or large application data.
22. How should ViewModel navigation be tested?
The ViewModel should emit a typed navigation event. The test asserts the event, while the UI performs actual navigation.
23. Should a ViewModel depend on NavController?
Generally no. That creates Android/UI coupling and makes unit testing harder.
24. How do you prevent duplicate actions?
Track active work, disable actions through state, debounce when appropriate, or use idempotency for critical server operations.
25. Why is client-side duplicate prevention insufficient for payment?
Multiple requests may still reach the server because of retries or network behaviour. Server-side idempotency is also required.
26. What is an MVI reducer?
A pure function that creates a new state from the previous state and an operation result.
27. Why are reducers easy to test?
They use explicit input and output and normally have no Android or asynchronous dependencies.
28. What are intent, action, result, state and effect?
* Intent: input from user or system
* Action: work to perform
* Result: outcome of that work
* State: persistent UI representation
* Effect: one-time output
29. State vs effect in MVI?
State describes what the screen currently displays. An effect describes a one-time operation such as navigation.
30. What should be tested in an MVVM state holder?
Initial state, input changes, loading, success, empty, errors, retries and events.
31. Why should a ViewModel avoid Android classes?
It improves separation, portability and local JVM testability and prevents UI references from leaking into presentation logic.
32. How can UI messages be handled without Context?
Expose a typed error or string-resource identifier and let the UI resolve and display it.
33. Why inject coroutine dispatchers?
It lets tests control coroutine execution, scheduling and virtual time.
34. Why must Dispatchers.Main be replaced in JVM tests?
viewModelScope uses the Main dispatcher, which is normally unavailable as a real Android main thread in local JVM tests.
35. What is StandardTestDispatcher?
A test dispatcher that queues coroutine work under a controllable test scheduler.
36. Why call advanceUntilIdle()?
It runs scheduled coroutine work until no pending tasks remain.
37. Why inject a clock or time provider?
It makes time-dependent behaviour deterministic and allows tests to simulate exact timestamps.
38. What is the main rule for ViewModel testing?
Test public actions, observable states and effects—not private implementation details.
39. Should every state transition always be asserted?
Only when the transition sequence is part of the expected behaviour. Otherwise, asserting the final state may be sufficient.
40. Do ViewModel tests replace UI tests?
No. ViewModel tests verify presentation logic; UI tests verify that the screen correctly renders and handles that state.
Quick Revision
| Concept | Description |
|---|---|
| ViewModel Input | User actions or MVI intents received from the UI. |
| ViewModel Output | UI state updates and one-time events/effects exposed to the UI. |
| StateFlow | Holds the current persistent UI state and emits updates to collectors. |
| SharedFlow | Emits one-time events such as navigation, snackbars, or toasts. |
| SavedStateHandle | Stores small pieces of state that survive process death and recreation. |
| Fake | Test double that provides reusable state-based behavior for testing. |
| Mock | Test double used to verify interactions and method invocations. |
| Initial → Loading → Success/Empty/Error | Verify the complete state transition sequence during data loading. |
| Error → Retry → Success | Verify retry logic correctly recovers from failures. |
| Click → Navigation Event | Verify a click emits exactly one navigation event. |
| Recreation → Restored State | Verify state is restored after configuration changes or process recreation. |
| Double Click → One Operation | Verify rapid repeated clicks trigger only one operation. |
| Inject Repository | Replace the real repository with a fake or mock for testing. |
| Inject Dispatcher | Use a test dispatcher to control coroutine execution. |
| Inject Clock | Control time-dependent behavior deterministically. |
| Inject ID Generator | Produce predictable IDs during tests. |
| Avoid Context | Keep ViewModel independent of Android Context. |
| Avoid Activity/Fragment | Do not reference UI components directly from the ViewModel. |
| Avoid View | Never hold View references inside the ViewModel. |
| Avoid NavController | Emit navigation events instead of calling NavController directly. |
| Avoid Real API/Database | Use fakes or mocks instead of real network or database calls. |
| Avoid Real Time | Inject a clock instead of relying on the system time. |
Avoid Thread.sleep() | Use coroutine test utilities instead of blocking sleeps. |
| Avoid Private Method Testing | Test observable public behavior rather than implementation details. |