Skip to Content
Course content

157: Testing Android ViewModels

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're testing a high-end remote control for a smart TV. To make sure the remote works, you don't actually need the TV plugged into a wall, a screen glowing, or a cable subscription active. You just need a way to "sniff" the infrared signal the remote sends. If you press the "Volume Up" button and the remote sends the exact binary code for VOLUME_INCREASE, the remote is doing its job. The TV's ability to actually make the room louder is a separate problem for the TV engineers.

Testing a ViewModel is exactly the same. The ViewModel is your remote. The Activity or Fragment is the TV. I've seen too many developers try to test their logic by launching an emulator and clicking buttons. That's slow and brittle. Instead, we want to trigger a function in the ViewModel and then "sniff" the resulting state—usually a StateFlow or LiveData—to see if it changed to what we expected.

The Main Dispatcher Headache

Before we get to the code, we have to address the elephant in the room: Dispatchers.Main. Your ViewModel likely uses viewModelScope, which is hard-wired to the Android Main thread. But JUnit tests run on a JVM, and the JVM has no idea what an "Android Main thread" is. If you try to run a ViewModel test without a fix, it'll crash immediately with a RuntimeException telling you the Main looper is not mocked.

I usually handle this by creating a JUnit Rule or a helper function that swaps Dispatchers.Main for a TestDispatcher. This tricks the ViewModel into thinking it's on the main thread while it's actually running on your test thread.

Putting the WeatherViewModel to the Test

Let's look at a real scenario. Suppose we have a WeatherViewModel that fetches the temperature for a city. It has a UIState that can be Loading, Success, or Error.


class WeatherViewModel(private val repository: WeatherRepository) : ViewModel() {
    private val _uiState = MutableStateFlow<WeatherState>(WeatherState.Idle)
    val uiState: StateFlow<WeatherState> = _uiState

    fun fetchWeather(city: String) {
        viewModelScope.launch {
            _uiState.value = WeatherState.Loading
            try {
                val temp = repository.getTemperature(city)
                _uiState.value = WeatherState.Success(temp)
            } catch (e: Exception) {
                _uiState.value = WeatherState.Error("Could not fetch weather")
            }
        }
    }
}

To test this, we don't want to call a real API. We'll mock the WeatherRepository. I prefer using kotlinx-coroutines-test to control time. By using runTest, we can execute coroutines instantly without adding Thread.sleep() (which is a cardinal sin in unit testing).


class WeatherViewModelTest {
    private val testDispatcher = StandardTestDispatcher()
    private lateinit var repository: WeatherRepository
    private lateinit var viewModel: WeatherViewModel

    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
        repository = mockk() // Using MockK for this example
        viewModel = WeatherViewModel(repository)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `fetchWeather updates state to Success when repository returns data`() = runTest {
        // Arrange: Tell the mock repository what to return
        coEvery { repository.getTemperature("London") } returns 22

        // Act: Trigger the action
        viewModel.fetchWeather("London")
        
        // Fast-forward the coroutine execution
        advanceUntilIdle()

        // Assert: Check if the state is what we expect
        assertEquals(WeatherState.Success(22), viewModel.uiState.value)
    }
}

The nuance of StateFlow timing

One thing that trips people up is that StateFlow is asynchronous. If you call viewModel.fetchWeather() and immediately check the value on the next line, you might still see WeatherState.Idle because the coroutine hasn't actually started executing yet. That's why I used advanceUntilIdle(). It tells the test dispatcher, "Run everything currently pending in the queue before you move to the assertion." Without it, your tests will flake or fail randomly.




📋 Practical Task

Validating the Task Addition Flow in TaskViewModel

You are building a Todo app. You have a TaskViewModel with a StateFlow<List<Task>> and a function addTask(name: String). The function should ignore empty strings and only add tasks that have a name longer than 3 characters.

Your task: Write a JUnit test class for TaskViewModel. You must implement two specific test cases:

  1. Successful Addition: Verify that when addTask("Buy Milk") is called, the list in the StateFlow increases in size by one.
  2. Validation Failure: Verify that when addTask("Hi") (a string too short) is called, the list size remains zero.

Ensure you use Dispatchers.setMain in your setup and runTest for your test methods to handle the viewModelScope correctly.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.