A Guide to Test ViewModel with TDD

Mudassir Zulfiqar
5 min readOct 16, 2023

--

Test-Driven Development (TDD) is a software development approach where tests are written before the actual code is implemented. This article aims to explain how to apply TDD principles to ViewModels.

The first step in my approach is always to expose a state holder property, for which the ViewModel is primarily responsible. For the sake of simplicity in this article, I am demonstrating with just one state holder property, although in real scenarios, there might be more than one.

For the initial setup, I intentionally avoid providing the necessary dependencies or any state holders to my ViewModel. Instead, I return hardcoded values as results. This approach aligns with the principles of Test-Driven Development (TDD), allowing me to focus on writing tests that validate the behaviour of the ViewModel independently of external dependencies. By returning hardcoded values initially, I create a baseline for my tests, which I can then iterate upon as I implement the actual logic in the ViewModel. This step-by-step approach ensures that the ViewModel functions as expected and adheres to the specified requirements, all while being thoroughly tested throughout its development process.

In this example, I am using a ViewModel to fetch the current weather of a location, intending to expose the data to a view. Although the example is kept simple, I would like to elucidate some key concepts.

What is this test extension?

To test flows effectively, I utilize a library called Turbine. Since this extension function is called from a suspend function, it must be invoked within a coroutine. To address this, let’s delve into the explanation of the runTest method.

What is runTest?

runTest is a testing utility provided by the Kotlin coroutine testing library. It executes the specified block within a new coroutine, enabling the isolation of coroutine-based testing.

At this point, the tests will eventually pass, but they lack meaningful validation since no actual logic has been implemented yet.

To test the correct behavior, we need an interface that provides the necessary API. Let’s create some classes based on our requirements.

Now, let’s delve into the details of the changes made.

I have created an interface named WeatherApi, which will be provided to our ViewModel to fulfill our use case. To inject this class into the ViewModel constructor, we need to use the Factory Design pattern, applied in a straightforward manner.

Why Interface?

When testing the ViewModel, our focus is solely on the data being handled by the ViewModel, not the data provider. Using an interface allows us to test the class with a fake implementation.

If you attempt to run this code now, it will fail because we need to provide an implementation of the interface. So, let’s create a fake implementation.

Now, the test will pass since the default value of the temperature hardcoded in the actual ViewModel implementation is 0.0. However, we now expect a different temperature, which will cause this test to fail.

This failure occurs because we expect a different value for temperature, but there is no logic written in the fetchWeather method. This marks the beginning of the entire TDD process.

Let’s find a way to change the expected temperature value for each test.

Alternatively, you can use a mock library to return the required value. However, for the purposes of this article, I’m employing this hackish method to achieve the expected outcome.

Now, let’s proceed to the ViewModel class and attempt to make the second test pass by implementing the necessary logic.

Before we proceed, it’s essential to understand some concepts related to coroutines, given that we’re using the coroutine scope provided in the ViewModel. You can find more information here: Kotlin Coroutine Testing.

After setting up all the necessary coroutines, here is the complete code for the tests.

class WeatherViewModelTest {

private lateinit var viewModel: WeatherViewModel
private lateinit var weatherApi: FakeWeatherApi

@get:Rule
val mainDispatcherRule = MainDispatcherRule()

@Before
fun setUp() {

weatherApi = FakeWeatherApi()

val factory = WeatherViewModel.Factory(
weatherApi = weatherApi
)
viewModel = factory.create(WeatherViewModel::class.java)

}

@After
fun tearDown() {
}

@Test
fun `expect temperature when fetchWeather is called`() = runTest {
weatherApi.expectedTemperature = 0.0

val expectedResult = 0.0

val currentLocation = Location(
latitude = 0.0,
longitude = 0.0
)

viewModel.fetchWeather(
location = currentLocation
)

viewModel.data.test {
assert(awaitItem().temperature == expectedResult)
}
}

@Test
fun `expect specific temperature when fetchWeather is called`() = runTest {
weatherApi.expectedTemperature = 22.0

val expectedResult = 22.0

val currentLocation = Location(
latitude = 0.0,
longitude = 0.0
)

viewModel.fetchWeather(
location = currentLocation
)

viewModel.data.test {
val temperature = awaitItem().temperature
assert(temperature == expectedResult)
}
}

class FakeWeatherApi(var expectedTemperature: Double = 0.0) : WeatherApi {
override suspend fun fetchWeather(location: Location) = WeatherResult(
temperature = expectedTemperature
)
}

}
class WeatherViewModel(
private val weatherApi: WeatherApi
) : ViewModel() {

private val _data = MutableStateFlow(WeatherState())
var data = _data.asStateFlow()

fun fetchWeather(location: Location) {
viewModelScope.launch(IO) {
_data.value =
_data.value.copy(temperature = weatherApi.fetchWeather(location).temperature)
}

}

class Factory(val weatherApi: WeatherApi) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return WeatherViewModel(weatherApi) as T
}
}
}
data class WeatherState(
val temperature: Double = 0.0
)
interface WeatherApi {
suspend fun fetchWeather(
location: Location
): WeatherResult

}
class WeatherResult(
val temperature: Double = 0.0
)
class MainDispatcherRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}

override fun finished(description: Description) {
Dispatchers.resetMain()
}
}

Enjoy Coding :)

https://www.linkedin.com/in/mudassir-zulfiqar/

--

--

Mudassir Zulfiqar
Mudassir Zulfiqar

Written by Mudassir Zulfiqar

Contributing my skills in Android/iOS and I love flutter

Responses (1)