ViewModel Tests
This page covers testing patterns for ViewModels in the Visla GPS Android app. For coroutine test setup and mocking strategies used across all ViewModel tests, see the Testing Strategy overview.
IntentViewModel Patternβ
All ViewModels extend IntentViewModel<Intent>, which provides handle(intent) for synchronous state mutations and handleAsync(intent, block) for coroutine work. The base class is tested in isolation:
class IntentViewModelTest {
@Test
fun `handle calls reduce with the intent`() {
viewModel.handle(TestIntent.SimpleAction)
assertEquals(1, viewModel.reducedIntents.size)
assertEquals(TestIntent.SimpleAction, viewModel.reducedIntents[0])
}
@Test
fun `handleAsync executes block in viewModelScope`() = runTest(testDispatcher) {
var executed = false
viewModel.testHandleAsync(TestIntent.AsyncAction) {
executed = true
}
advanceUntilIdle()
assertTrue(executed)
}
@Test
fun `handleAsync handles exceptions without crashing`() = runTest(testDispatcher) {
viewModel.testHandleAsync(TestIntent.AsyncAction) {
throw RuntimeException("Test error")
}
advanceUntilIdle()
// No crash β handleAsync catches exceptions
}
}
Screen ViewModel Testsβ
Screen ViewModels are tested by injecting mocked use cases via constructor, then driving behavior through handle(Intent) and asserting on uiState StateFlow values. Tests are organized into sections with comment headers.
@OptIn(ExperimentalCoroutinesApi::class)
class LoginViewModelTest {
private lateinit var loginUseCase: LoginUseCase
private lateinit var tokenManager: TokenManager
private lateinit var viewModel: LoginViewModel
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
loginUseCase = mockk(relaxed = true)
tokenManager = mockk(relaxed = true)
viewModel = LoginViewModel(
loginUseCase = loginUseCase,
// ... other use cases
tokenManager = tokenManager
)
}
// ==================== Form Input Tests ====================
@Test
fun `updateEmail updates state`() {
viewModel.handle(LoginIntent.UpdateEmail("test@example.com"))
assertEquals("test@example.com", viewModel.uiState.value.email)
}
// ==================== Validation Tests ====================
@Test
fun `login with empty email shows error`() = runTest {
viewModel.handle(LoginIntent.UpdateEmail(""))
viewModel.handle(LoginIntent.UpdatePassword("password123"))
viewModel.handle(LoginIntent.Login)
testScheduler.advanceUntilIdle()
assertEquals("Please enter email and password", viewModel.uiState.value.errorMessage)
coVerify(exactly = 0) { loginUseCase(any(), any()) }
}
// ==================== Successful Login Tests ====================
// ==================== 2FA Flow Tests ====================
// ==================== Error Handling Tests ====================
// ==================== State Management Tests ====================
}
Conventions:
- Tests use backtick-quoted method names for readability.
- Each test file has a
createTest*()helper for building domain objects. mockk(relaxed = true)is used for dependencies that don't need specific stub behavior.- Sections are separated by
// ==================== ... ====================comment blocks. - Synchronous intents (form updates, UI toggles) are tested without
runTest. - Async intents (API calls) use
runTest+advanceUntilIdle().
Flow-Based ViewModelsβ
ViewModels that observe reactive data sources (e.g., DevicesViewModel) use MutableStateFlow as test doubles:
class DevicesViewModelTest {
private val devicesFlow = MutableStateFlow<List<DeviceWithPosition>>(emptyList())
private val connectionStateFlow = MutableStateFlow(false)
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
devicesInteractor = mockk(relaxed = true)
every { devicesInteractor.devicesFlow } returns devicesFlow
every { observeConnectionStateUseCase() } returns connectionStateFlow
}
@Test
fun `devicesFlow update - updates ui state`() = runTest {
coEvery { devicesInteractor.refreshDevices() } returns emptyList()
viewModel = createViewModel()
advanceUntilIdle()
val newDevices = listOf(createDeviceWithPosition(createTestDevice(1, "New Device")))
devicesFlow.value = newDevices
advanceUntilIdle()
val state = viewModel.uiState.value
assertTrue(state is DevicesUiState.Success)
assertEquals(1, (state as DevicesUiState.Success).devices.size)
}
}