Note* Article assumes basic knowledge of Android, Jetpack, Compose, Kotlin, Co-Routines, Retrofit and Dependency Injection.

Test project

Today we are going to develop a sample project following test-driven development (TDD) best practices. Our end goal is to display a list of fake post data fetched form a Rest API service. Eventually, we want to end up with the screen below.

Project goal: A list of fake post data fetched from a REST API service.

Here are different components we're going to use:

UI

Tech/tools

Modern architecture

If you don't choose the right architecture for your Android project, you'll have a hard time maintaining it as your codebase grows and your team expands. With proper architecture, you can achieve simplicity, testability and low-cost maintenance.

Test-driven development (TDD)

What is TDD?

Test-driven development one way to test new code. Using TDD, you write the test requirements for the thing you're adding before you write the code to implement it (as opposed to developing the software first and testing it later).

Why is TDD important?

There are many benefits to employing a TDD development methodology, including:

  • Faster development times
  • Automatic, up-to-date documentation
  • More maintainable code
  • Greater confidence in your code
  • Higher test coverage
  • And more

The five steps of TDD

Throughout the TDD process, you'll write a number of tests. You usually want a test for the happy path and at least one sad path. If there is a method with a lot of branching, it's ideal to have a test for each of the branches. 

TDD is accomplished by following five steps:

  1. Add a test: Anytime you start a new feature, fix or refactor, you write a test for it. This test will specify how this change or addition should behave. You only write the test at this step and just enough code to make it compile.
  2. Run it, watch it fail: Here, you run the tests. If you did step one correctly, these tests should fail but for the right reason.
  3. Write code made pass test: This is when you write your feature, fix or refactor. It will follow the specifications you laid down in the tests.
  4. Run tests, ensure they pass: Now you get to run your tests again! They should pass at this point. If they don't, go back to step three until all your tests are green.
  5. Refactoring: Now that you have a test that ensures your implementation matches the specifications, you can adjust and refactor the implementation so it's clean and structured the way you want without any worries that you'll break what you just wrote.

Instrumentation and unit tests

Instrumentation tests are for the parts of your code dependent on the Android framework but that do not require the UI. These require an emulator or physical device to run because of this dependency. These tests go in an 'app/src/androidTest/' directory with the same package structure as your project. We are not going to cover instrumentation test in this tutorial.

A unit test, in contrast, focuses on the small building blocks of your code. It's generally concerned with one class at a time, testing one function at a time. Unit tests typically run the fastest of the different kinds of tests. That's because they are small and independent of the Android framework and do not need to run on a device or emulator. JUnit is usually used to run these tests.

To ensure you are purely testing just the class of interest, you mock or stub dependencies when writing unit tests. Because unit tests are independent of the Android framework, they generally go in the 'app/src/test/ ' directory with the same package structure as your project.

Project architecture

The project is layered traditionally with a View, Presentation, Model separation and presents a blend between MVVM and MVI and adapted to Compose.

Architecture layers:

  • View: Composable screens that consume state, apply effects and delegate events.
  • ViewModel: AAC ViewModel that manages and reduces the state of the corresponding screen. Additionally, it intercepts UI events and produces side-effects. The ViewModel lifecycle scope is tied to the corresponding screen composable.
  • Model: Repository classes that retrieve data. In a clean architecture context, one should leverage use-cases that tap into repositories.
Project architecture overview

As the architecture blends MVVM with MVI, there are a three core components described:

  • State: Data class that holds the state content of the corresponding screen (e.g., list of Stores, loading status, etc.). The state is exposed as a Compose runtime MutableState object from that perfectly matches the use-case of receiving continuous updates with initial value.
  • Event: Plain object that is sent through callbacks from the UI to the presentation layer. Events should reflect UI events caused by the user. Event updates are exposed as a MutableSharedFlow type, which is similar to StateFlow and behaves as if in the absence of a subscriber, meaning any posted event will be immediately dropped.
  • Effect: Plain object that signals one-time side-effect actions that should impact the UI (e.g., triggering a navigation action, showing a Toast, SnackBar, etc.). Effects are exposed as ChannelFlow, which behave such that each event is delivered to a single subscriber. An attempt to post an event without subscribers will suspend as soon as the channel buffer becomes full, waiting for a subscriber to appear.

Every screen/flow defines its own contract class that states all corresponding core components described above: state content, events and effects.

Here are different high level class files we are going to work on:

  • MainActivity.kt: This is where you'll put any changes that affect the view.
  • HomeViewModel.kt: The ViewModel will contain the logic you'll work with.
  • HomeViewModelTest.kt: Likewise, this is where you'll test HomeViewModel class.
  • HomeRepository.kt: Repository layer to fetch data from REST API.
  • HomeRepositoryTest.kt: HomeRepository test class.
  • HomeDataSource.kt: Data Source Layer to make REST API service.
  • HomeDataSourceTest.kt: HomeDataSource test class.
  • HomeDataService.kt: REST API service interface.
  • HomeScreen.kt: Composable screen for UI.
  • HomeScreenContract.kt: Contract to update state or effect to HomeScreen.
  • HomeScreenTest.kt: HomeScreen test class.

Let's code following TDD

To me it always make sense to implement backend first, which means developing viewmodel and its subsequent dependent classes first. If we start with HomeScreen (UI) test (we will be using Robolectric library to write Unit test for UI), then we will end up creating lots of backend things that we can learn better way by following TDD. This way, when we move to develop the HomeScreen, we'll have all dependencies ready to wire-in.

Building HomeViewModel

Let's start building our HomeViewModel logic first. Here we won't create HomeViewModel or its functionality directly; rather, we will let our test class drive the stuff for us.

Create new test case class file "HomeViewModelTest.kt" under the "app/src/test/<package name>/home" directory.

class HomeViewModelTest {}

We need a "testObject" instance — which is nothing but a "HomeViewModel" instance — to test class functionality.

testObject is the instance on which we will be performing some actions and, as a result, validate result/expected data.

This will show an error on HomeViewModel as this class does not exist yet. Let's create an instance using tooltip help, a very handy feature.

Follow along step-by-step via the screenshots below:

So here we have driven our actual class creation following TDD. We have not yet touched our actual class file, or even file creation.

It's always good idea if to know what functionality we are going to implement. Based on that, we can start writing empty test cases as shown below: 

import org.junit.Test

class HomeViewModelTest {
    private lateinit var testObject: HomeViewModel

    @Test
    fun `on home view model init validate state default values`() {
    }

    @Test
    fun `on home view model init get posts returns success with posts list data`() {
    }

    @Test
    fun `on home view model init get posts returns error with socket connection exception`() {
    }

    @Test
    fun `on home view model init get posts returns error with http exception`() {
    }

    @Test
    fun `on receiving error retry get posts returns success with posts list data`() {
    }
}
  • First Test: Validates initial state values for state object (e.g., whether the show loading indicator should be true and posts list should be empty).
  • Second Test: Should update posts data on making API request and on receiving success.
  • Third Test: Handling socket connection/no internet error.
  • Fourth Test: Handling HTTP error. For example, 404 and 501 errors.
  • Fifth Test: On getting error, we are going to show Toast message to user with "Retry." So that user can retry to get posts data.

Let's wire up a few things before we start writing our first failing test.

BaseComponents class

Here we are creating some interfaces and BaseViewModel, which can help us keep the code clean.

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch

interface ViewEvent

interface ViewState

interface ViewSideEffect

abstract class BaseViewModel<Event : ViewEvent, UiState : ViewState, Effect : ViewSideEffect> :
    ViewModel() {

    private val initialState: UiState by lazy { setInitialState() }
    abstract fun setInitialState(): UiState

    private val _viewState: MutableState<UiState> = mutableStateOf(initialState)
    val viewState: State<UiState> = _viewState

    private val _event: MutableSharedFlow<Event> = MutableSharedFlow()

    private val _effect: Channel<Effect> = Channel()
    val effect = _effect.receiveAsFlow()

    fun setEvent(event: Event) {
        viewModelScope.launch { _event.emit(event) }
    }

    protected fun setState(reducer: UiState.() -> UiState) {
        val newState = viewState.value.reducer()
        _viewState.value = newState
    }

    protected fun setEffect(builder: () -> Effect) {
        val effectValue = builder()
        viewModelScope.launch { _effect.send(effectValue) }
    }
}

Create a "HomeScreenContract" Class. This class will be sending data/events to composable function, which will use data to show UI or events.

import com.example.androidtddsample.base.ViewEvent
import com.example.androidtddsample.base.ViewSideEffect
import com.example.androidtddsample.base.ViewState
import com.example.androidtddsample.home.model.PostDto

class HomeScreenContract {

    sealed class Event : ViewEvent

    data class State(
        val isLoading: Boolean = false,
        val postsListData: List<PostDto>,
        val error: String?,
    ) : ViewState

    sealed class Effect : ViewSideEffect {
        object OnError : Effect()
    }
}

Extend HomeViewModel with BaseViewModel.

import com.example.androidtddsample.base.BaseViewModel

class HomeViewModel : BaseViewModel<
        HomeScreenContract.Event,
        HomeScreenContract.State,
        HomeScreenContract.Effect
        >() {
    override fun setInitialState(): HomeScreenContract.State {
        TODO("Not yet implemented")
    }
}

All pre-requisites are in place now and it's time to start building our first failing test case.

First Test

@Test
fun `on home view model init validate state default values`() {
}

I am going to refactor this to divide this test into multiples, as the app will have multiple state objects. This is always good idea if a test deals with a single functionality.

@Test
fun `on home view model init validate loading state default value as true`() {
}
@Test
fun `on home view model init validate post data state default value as empty posts list`() {
}

AAA Testing

AAA testing is a method for writing tests. It stands for "Assemble/Arrange, Act, Assert" and describes the basic method for setting up a test.

First, in the Arrange stage, you create all of the objects and variables you require for your test. In the Act stage, you perform the behavior you wish to test. For example, this could be calling a particular method on your test object. Finally, in the Assert stage, you test that the final result (e.g., object property values) is what you'd expect if the behavior had executed correctly.

@Test
fun `on home view model init validate loading state default value as true`() {
    //Assemble
    //Act
    //Assert
}

I usually start with Assert, where I know what I am expecting (and which drives the remaining parts of testing).

@Test
fun `on home view model init validate loading state default value as true`() {
    //Assert
    Assert.assertEquals(true, testObject.viewState.value.isLoading)
}

Act would be creation of a test class object.

@Test
fun `on home view model init validate loading state default value as true`() {
    //Assemble
    // We don't have any pre-requisite for this test

    //Act
    testObject = HomeViewModel()

    //Assert
    Assert.assertEquals(true, testObject.viewState.value.isLoading)
}

Run the test case and see how it behaves. Oops, it fails but for the wrong reason!

Test fails with error — "not implemented".

Here we replaced TODO with actual implementation. 

import com.example.androidtddsample.base.BaseViewModel

class HomeViewModel : BaseViewModel<
        HomeScreenContract.Event,
        HomeScreenContract.State,
        HomeScreenContract.Effect
        >() {
    
    override fun setInitialState(): HomeScreenContract.State =
        HomeScreenContract.State(isLoading = false)
}

Now our test is failing for the right reason as we expect the "isLoading" value to be true, but it's false.

Update functionality in HomeViewModel class and re-run test.

override fun setInitialState(): HomeScreenContract.State =
    HomeScreenContract.State(isLoading = true)

Hurray, we have our first working/passing test.

Second Test

As usual, start with Assert.

@Test
fun `on home view model init validate post data state default value as empty posts list`() {
    //Assemble
    //Act
    //Assert
    Assert.assertEquals(listOf<PostListItemDto>(), testObject.viewState.value.postsListData)
}

Here, neither PostListDto (POJO data class for API response) nor postsListData (Live data object, which holds the PostListDto list data) exist. Follow tooltip help to generate these.

Add required data after generation of model.

data class PostDto(
    val id: Int,
    val title: String,
    val body: String,
)

Continue creating another property in a similar fashion.

Now we have a test case without any errors.

@Test
fun `on home view model init validate post data state default value as empty posts list`() {
    //Assemble
    //Act
    //Assert
    Assert.assertEquals(listOf<PostDto>(), testObject.viewState.value.postsListData)
}

If we try to run the test case, we will get compiler errors and initialize the same in setInitialState of HomeViewModel class.

override fun setInitialState(): HomeScreenContract.State =
    HomeScreenContract.State(isLoading = true, postsListData = emptyList())

Let's move into our actual unit test case.

Third Test

@Test
fun `on home view model init get posts returns success with posts list data`() {
    //Assemble
    //Act
    //Assert
}

Now we need to generate some data.

As we go and add more and more test cases, we will need to create many PostDto objects. So it's good practice if we can automate this to reduce our efforts.

I ended up creating a TestDataUtils class that will generate random test data.

import com.example.androidtddsample.home.model.PostDto
import java.util.*

private val random = Random()

fun String.appendRandom() = "$this-${random.nextInt(10000)}"

fun randomInt() = Random().nextInt()

fun testPostDtoData(
    id: Int = randomInt(),
    title: String = "Title-".appendRandom(),
    body: String = "Body-".appendRandom(),
) = PostDto(
    id = id,
    title = title,
    body = body
)

The beauty of the testPostDtoData() function is that every time we call it without any parameters, we will get PostDto with some random value.

val post1 = testPostDtoData()
val post2 = testPostDtoData(id = 1, title = "Post 1", body = "Post 1 Body")

post1 will be with any random values, whereas post2 will appear with mentioned data.

Here is our current test looks like now:

@Test
fun `on home view model init validate post data state default value as empty posts list`() {
    //Assemble
    val post1 = testPostDtoData()
    val post2 = testPostDtoData(id = 1, title = "Post 1", body = "Post 1 Body")
    //Act
    testObject = HomeViewModel()
    //Assert
    Assert.assertEquals(listOf(post1, post2), testObject.viewState.value.postsListData)
}

And our test fails for the right reason (as we don't have any implementation yet).

We are going to follow Clean Architecture so, introduce "Repository" layer, which will fetch and provide us required Posts data from REST API.

Let's generate a HomeRepository class file following same process we used earlier.

Be sure to select destination directory in "main."

Next, set mock operation on homeRespository, which returns data to HomeViewModel:

Mockito.`when`(homeRepository.getPostsData()).thenReturn(subject.consumeAsFlow())

We also need to add the below annotation on class level to initialize and use Mockito:

@RunWith(MockitoJUnitRunner::class)

To use Mockito, add below dependencies:

testImplementation 'org.mockito:mockito-core:3.9.0' // Mockito framework
testImplementation 'org.mockito:mockito-inline:2.25.0' // mock final classes

Mockito

Mockito is a mocking framework and JAVA-based library used for the  effective unit testing of JAVA applications. Mockito is used to mock interfaces so a dummy functionality can be added to a mock interface that can be used in unit testing.

In our test case we need to mock "homeRepository.getPostsData()" as its source of data for HomeViewModel.

We will end up having HomeRepository as:

import com.example.androidtddsample.home.model.PostDto
import kotlinx.coroutines.flow.Flow

class HomeRepository {
    fun getPostsData(): Flow<List<PostDto>> {
        TODO("Not yet implemented")
    }
}
@Test
fun `on home view model init validate post data state default value as empty posts list`() =
    runBlockingTest {
        //Assemble
        val post1 = testPostDtoData()
        val post2 = testPostDtoData(id = 1, title = "Post 1", body = "Post 1 Body")

        val subject = Channel<List<PostDto>>()

        Mockito.`when`(homeRepository.getPostsData()).thenReturn(subject.consumeAsFlow())

        //Act
        testObject = HomeViewModel()

        // Pre assert few things which needs to be validated before we get api respose.
        Assert.assertEquals(emptyList<PostDto>(), testObject.viewState.value.postsListData)
        Assert.assertEquals(true, testObject.viewState.value.isLoading)

        //Act
        subject.send(listOf(post1, post2))

        //Assert
        Assert.assertEquals(listOf(post1, post2), testObject.viewState.value.postsListData)
        Assert.assertEquals(false, testObject.viewState.value.isLoading)
    }

Why Channel?

In Kotlin Flow, the analog of RxJava Subject is Channel. 

A Subject is a sort of bridge or proxy available in some implementations of ReactiveX that acts both as an observer and as an Observable. Because it is an observer, it can subscribe to one or more Observables; and because it is an Observable, it can pass through the items it observes by reemitting them. It can also emit new items.

Why runBlockingTest?

As getPostsData() is a suspend function, we have to call it from another suspend function or launch it from a Coroutine. We can mitigate the problem by using the runBlockingTest() function. This function runs a new Coroutine and blocks the current thread uninterrupted until completion.

By default, Coroutines work for Dispatcher.Main. But to test Dispatcher.IO, we have to use the TestCoroutineDispatcher for replacing the main dispatchers with the test dispatcher. This works correctly but doesn't scale very well. Whenever our test class increases, we have to use the same boilerplate again and again. 

To overcome this issue, we can create a custom TestCoroutineRule and add it to our test classes with @Rule annotation.

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineScope
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
import kotlin.coroutines.ContinuationInterceptor

@ExperimentalCoroutinesApi
class TestCoroutineRule : TestWatcher(), TestCoroutineScope by TestCoroutineScope() {

    override fun starting(description: Description) {
        super.starting(description)
        Dispatchers.setMain(this.coroutineContext[ContinuationInterceptor] as CoroutineDispatcher)
    }

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

With this our test will still fail as we are yet to wire HomeRepository into HomeViewModel.

It is very important to wire the required dependency properly. While we can create a local instance of HomeRepository in HomeViewModel, we won't be able to mock api call properly (as the mock object from the test class and the instance from the testclass won't be same. So the best way is injecting it as constructor parameter.

testObject = HomeViewModel(homeRepository)

Our test fails again as we have wired up the repository but have yet to use/implement its getPostsData method.

Here our work on the test case finishes and we can move to the actual HomeViewModel class to implement the actual functionality.

Here is piece of implementation:

init {
    fetchPostsData()
}

private fun fetchPostsData() {
    viewModelScope.launch(Dispatchers.IO) {
        homeRepository.getPostsData()
            .collect { postsData ->
                updatePostsDataToView(postsData)
            }
    }
}

private fun updatePostsDataToView(postsData: List<PostDto>) {
    viewModelScope.launch {
        setState {
            copy(
                postsListData = postsData,
                isLoading = false
            )
        }
    }
}

Our test stills fails, which is frustrating! But don't worry as we're about to achieve what we're striving for. 

The test fails because the Dispatchers.IO instance is different than the one stated in test class:

Move "Dispathers.IO" to constructor parameter.

class HomeViewModel(
    private val homeRepository: HomeRepository,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
)

For test, "TestCoroutineDispatcher" comes in handy as it handles coroutines in test:

@ExperimentalCoroutinesApi
@RunWith(MockitoJUnitRunner::class)
class HomeViewModelTest {
    private val testDispatcher = TestCoroutineDispatcher()

    @get:Rule
    var testCoroutineRule = TestCoroutineRule()

Add testDispatcher as part of testObject creation.

testObject = HomeViewModel(homeRepository, testDispatcher)

Now run the test case and yes, it's passing!

Our final test case looks like this:

@Test
fun `on home view model init get posts returns success with posts list data`() =
    runBlockingTest {
        //Assemble
        val post1 = testPostDtoData()
        val post2 = testPostDtoData(id = 1, title = "Post 1", body = "Post 1 Body")

        val subject = Channel<List<PostDto>>()

        Mockito.`when`(homeRepository.getPostsData()).thenReturn(subject.consumeAsFlow())

        testObject = HomeViewModel(homeRepository, testDispatcher)

        // Pre assert few things which needs to be asserted before we get api respose.
        Assert.assertEquals(emptyList<PostDto>(), testObject.viewState.value.postsListData)
        Assert.assertEquals(true, testObject.viewState.value.isLoading)

        //Act
        subject.send(listOf(post1, post2))

        //Assert
        Assert.assertEquals(listOf(post1, post2), testObject.viewState.value.postsListData)
        Assert.assertEquals(false, testObject.viewState.value.isLoading)
    }

We can write the same test a bit differently. Instead of using Channel, we can directly emit data from our mocking statement:

@Test
fun `on home view model init get posts returns success with different posts list data`() =
    runBlockingTest {
        //Assemble
        val post1 = testPostDtoData(id = 11, title = "Post 11", body = "Post 11 Body")
        val post2 = testPostDtoData(id = 12, title = "Post 12", body = "Post 12 Body")

        Mockito.`when`(homeRepository.getPostsData()).thenReturn(flow {
            emit(listOf(post1, post2))
        })

        //Act
        testObject = HomeViewModel(homeRepository, testDispatcher)

        //Assert
        Assert.assertEquals(listOf(post1, post2), testObject.viewState.value.postsListData)
        Assert.assertEquals(false, testObject.viewState.value.isLoading)
    }

Here, the difference is we are emitting the data whenever it's called. Due to this, we cannot assert a pre-state value for isLoading and postsListData.

Let's continue to our next test.

Fourth Test

@Test
fun `on home view model init get posts returns error with socket connection exception`() {
}
//Assert
Assert.assertEquals("Network Error", testObject.viewState.value.error)

Create an error object following tool tip help:

Here's how we can mock an error response:

Mockito.`when`(homeRepository.getPostsData()).thenAnswer { throw ConnectException("Network Error") }

Our final test case looks like this:

@Test
fun `on home view model init get posts returns error with socket connection exception`() =
    runBlockingTest {

        Mockito.`when`(homeRepository.getPostsData()).thenAnswer { throw ConnectException("Network Error") }

        //Act
        testObject = HomeViewModel(homeRepository, testDispatcher)

        //Assert
        Assert.assertEquals("Network Error", testObject.viewState.value.error)
    }

If we run it, it will fail for the right reason:

Handle the exception in HomeViewModel class.

Create global exception handler:

private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
}

And use the same as part of API calling function:

fun fetchPostsData() {
    viewModelScope.launch(dispatcher + exceptionHandler) {
        homeRepository.getPostsData().collect { postsData ->
            updatePostsDataToView(postsData)
        }
    }
}

Error case handling:

private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
    viewModelScope.launch {
        setState {
            copy(
                error = throwable.message,
                isLoading = false 
            )
        }
    }
    setEffect { HomeScreenContract.Effect.OnError }
}

Here we are setting error message. We are also updating the value for isLoading and sending an onError effect to Toast, which will be handled in the HomeScreen compose function.

Sometimes REST API may send different http codes apart from 200. Our next test will handle such use cases and provide users with the proper message.

Below is our passing test case:

@Test
fun `on home view model init get posts returns error with 404 http exception`() =
    runBlockingTest {
        //Aseemble
        Mockito.`when`(homeRepository.getPostsData())
            .thenThrow(HttpException(Response.error<Any>(500,
                "Error".toResponseBody("text/plain".toMediaTypeOrNull()))))

        //Act
        testObject = HomeViewModel(homeRepository, testDispatcher)

        //Assert
        Assert.assertEquals("HTTP 500 Response.error()", testObject.viewState.value.error)
    }

To access HttpException, we used the retrofit library. Add the below dependency:

implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation "com.squareup.okhttp3:okhttp:4.9.0"

Our next test case hovers around retry functionality on receiving error.

Here, on receiving error, we are going to show a permanent toast message at bottom of screen with a "Retry" option so users will see an error as well as an option to retry.

@Test
fun `on receiving error retry get posts returns success with posts list data`() {
}

 We need to mock twice: once with an error and once with a success. We can combine both into one as follows:

Mockito.`when`(homeRepository.getPostsData())
    .thenAnswer { throw ConnectException("Network Error") }.thenReturn(flow {
        emit(listOf(post1, post2))
    })
@Test
fun `on receiving error retry get posts returns success with posts list data`() =
    runBlockingTest {
        //Assemble
        val post1 = testPostDtoData()
        val post2 = testPostDtoData(id = 1, title = "Post 1", body = "Post 1 Body")

        Mockito.`when`(homeRepository.getPostsData())
            .thenAnswer { throw ConnectException("Network Error") }.thenReturn(flow {
                emit(listOf(post1, post2))
            })

        //First Act
        testObject = HomeViewModel(homeRepository, testDispatcher)

        //Error Assertion
        Assert.assertEquals("Network Error", testObject.viewState.value.error)

        //Second Act
        testObject.fetchPostsData()

        //Assert Data
        Assert.assertEquals(listOf(post1, post2), testObject.viewState.value.postsListData)
    }

We have now covered all test cases, lets run everything at once:

Here are our final classes:

import com.example.androidtddsample.home.api.HomeRepository
import com.example.androidtddsample.home.model.PostDto
import com.example.androidtddsample.utils.TestCoroutineRule
import com.example.androidtddsample.utils.testPostDtoData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.flow 
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.runBlockingTest
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.junit.MockitoJUnitRunner
import retrofit2.HttpException
import retrofit2.Response
import java.net.ConnectException

@ExperimentalCoroutinesApi
@RunWith(MockitoJUnitRunner::class)
class HomeViewModelTest {
    private val testDispatcher = TestCoroutineDispatcher()

    @get:Rule
    var testCoroutineRule = TestCoroutineRule()

    @Mock
    private lateinit var homeRepository: HomeRepository

    private lateinit var testObject: HomeViewModel

    @Test
    fun `on home view model init validate loading state default value as true`() {
        //Assemble
        // We don't have any pre-requisite for this test

        //Act
        testObject = HomeViewModel(homeRepository)

        //Assert
        Assert.assertEquals(true, testObject.viewState.value.isLoading)
    }

    @Test
    fun `on home view model init validate post data state default value as empty posts list`() {
        //Assemble
        // We don't have any pre-requisite for this test

        //Act
        testObject = HomeViewModel(homeRepository, testDispatcher)
        //Assert
        Assert.assertEquals(emptyList<PostDto>(), testObject.viewState.value.postsListData)
    }

    @Test
    fun `on home view model init get posts returns success with posts list data`() =
        runBlockingTest {
            //Assemble
            val post1 = testPostDtoData()
            val post2 = testPostDtoData(id = 1, title = "Post 1", body = "Post 1 Body")

            val subject = Channel<List<PostDto>>()

            Mockito.`when`(homeRepository.getPostsData()).thenReturn(subject.consumeAsFlow())

            testObject = HomeViewModel(homeRepository, testDispatcher)

            // Pre assert few things which needs to be asserted before we get api respose.
            Assert.assertEquals(emptyList<PostDto>(), testObject.viewState.value.postsListData)
            Assert.assertEquals(true, testObject.viewState.value.isLoading)

            //Act
            subject.send(listOf(post1, post2))

            //Assert
            Assert.assertEquals(listOf(post1, post2), testObject.viewState.value.postsListData)
            Assert.assertEquals(false, testObject.viewState.value.isLoading)
        }

    @Test
    fun `on home view model init get posts returns success with different posts list data`() =
        runBlockingTest {
            //Assemble
            val post1 = testPostDtoData(id = 11, title = "Post 11", body = "Post 11 Body")
            val post2 = testPostDtoData(id = 12, title = "Post 12", body = "Post 12 Body")

            Mockito.`when`(homeRepository.getPostsData()).thenReturn(flow {
                emit(listOf(post1, post2))
            })

            //Act
            testObject = HomeViewModel(homeRepository, testDispatcher)

            //Assert
            Assert.assertEquals(listOf(post1, post2), testObject.viewState.value.postsListData)
            Assert.assertEquals(false, testObject.viewState.value.isLoading)
        }

    @Test
    fun `on home view model init get posts returns error with socket connection exception`() =
        runBlockingTest {
            //Assemble
            Mockito.`when`(homeRepository.getPostsData())
                .thenAnswer { throw ConnectException("Network Error") }

            //Act
            testObject = HomeViewModel(homeRepository, testDispatcher)

            //Assert
            Assert.assertEquals("Network Error", testObject.viewState.value.error)
        }

    @Test
    fun `on home view model init get posts returns error with 404 http exception`() =
        runBlockingTest {
            Mockito.`when`(homeRepository.getPostsData())
                .thenThrow(HttpException(Response.error<Any>(500,
                    "Error".toResponseBody("text/plain".toMediaTypeOrNull()))))

            //Act
            testObject = HomeViewModel(homeRepository, testDispatcher)

            //Assert
            Assert.assertEquals("HTTP 500 Response.error()", testObject.viewState.value.error)
        }

    @Test
    fun `on receiving error retry get posts returns success with posts list data`() =
        runBlockingTest {
            //Assemble
            val post1 = testPostDtoData()
            val post2 = testPostDtoData(id = 1, title = "Post 1", body = "Post 1 Body")

            Mockito.`when`(homeRepository.getPostsData())
                .thenAnswer { throw ConnectException("Network Error") }.thenReturn(flow {
                    emit(listOf(post1, post2))
                })

            //First Act
            testObject = HomeViewModel(homeRepository, testDispatcher)

            //Error Assertion
            Assert.assertEquals("Network Error", testObject.viewState.value.error)

            //Second Act
            testObject.fetchPostsData()

            //Assert Data
            Assert.assertEquals(listOf(post1, post2), testObject.viewState.value.postsListData)
        }
}
import androidx.lifecycle.viewModelScope
import com.example.androidtddsample.base.BaseViewModel
import com.example.androidtddsample.home.api.HomeRepository
import com.example.androidtddsample.home.model.PostDto
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch

class HomeViewModel(
    private val homeRepository: HomeRepository,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : BaseViewModel<
        HomeScreenContract.Event,
        HomeScreenContract.State,
        HomeScreenContract.Effect
        >() {

    private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        viewModelScope.launch {
            setState {
                copy(
                    error = throwable.message,
                    isLoading = false,
                )
            }
        }
        setEffect { HomeScreenContract.Effect.OnError }
    }

    init {
        fetchPostsData()
    }

    fun fetchPostsData() {
        viewModelScope.launch(dispatcher + exceptionHandler) {
            homeRepository.getPostsData().collect { postsData ->
                updatePostsDataToView(postsData)
            }
        }
    }

    private fun updatePostsDataToView(postsData: List<PostDto>) {
        viewModelScope.launch {
            setState {
                copy(
                    postsListData = postsData,
                    isLoading = false
                )
            }
        }
    }

    override fun setInitialState(): HomeScreenContract.State =
        HomeScreenContract.State(isLoading = true, postsListData = emptyList(), error = null)

}
import com.example.androidtddsample.home.model.PostDto
import kotlinx.coroutines.flow.Flow

class HomeRepository {
    suspend fun getPostsData(): Flow<List<PostDto>> {
        TODO("Not yet implemented")
    }
}

You might notice that HomeRepository has yet to implement, yet we were still able to complete functionality for HomeViewModel by following TDD.

Building HomeRepository

Let's proceed to implement the  HomeRepository class following TDD best practices.

Start by creating a new file "HomeRepositoryTest" in the "app/src/test/<package name>/home/api" directory.

Here is the case for HomeRepository:

fun `get posts data returns posts list data`() {
}

Assert:

Assert.assertEquals(listOf(post1, post2), values[0])

Assemble:

val subject = Channel<List<PostDto>>()
val values = mutableListOf<List<PostDto>>()
val job = launch {
    subject.consumeAsFlow().collect {
        values.add(it)
    }
}
val post1 = testPostDtoData()
val post2 = testPostDtoData(id = 1, title = "Post 1", body = "Post 1 Body")

val testObject = HomeRepository()

Act:

val testObserver = testObject.getPostsData().test(this)

testObserver.assertNoValues()

subject.send(listOf(post1, post2))

Our target function is emitting Flow data, which is of type List<PostDto>. So, we have created a subject used to send data and we are collecting all emitted values under values.

The test case fails with the error: "An operation is not implemented: Not yet implemented" as we have yet to implement target method:

class HomeRepository() {
    suspend fun getPostsData(): Flow<List<PostDto>> {
        TODO("Not yet implemented")
    }
}

We are adding one more layer, which will sends data from API call to HomeDataSource:

Create the required file:

Add HomeDataSource as part of constructor parameter in HomeRepository:

val testObject = HomeRepository(homeDataSource)

Mock the homeDataSource to send data:

Mockito.`when`(homeDataSource.getPostsData()).thenReturn(subject.consumeAsFlow())

Create function getPostsData() for homeDataSource:

import com.example.androidtddsample.home.model.PostDto
import kotlinx.coroutines.flow.Flow

class HomeDataSource {
    fun getPostsData(): Flow<List<PostDto>> {
        TODO("Not yet implemented")
    }
}

Use getPostsData of homeDataSource in HomeRepository:

import com.example.androidtddsample.home.model.PostDto
import kotlinx.coroutines.flow.Flow

class HomeRepository(private val homeDataSource: HomeDataSource) {
    suspend fun getPostsData(): Flow<List<PostDto>> {
        return homeDataSource.getPostsData()
    }
}

Now let's refactor the code a bit to simplify the solution:

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.junit.Assert

class TestObserver<T>(
    scope: CoroutineScope,
    flow: Flow<T>,
) {
    private val values = mutableListOf<T>()

    private val job: Job = scope.launch {
        flow.collect { values.add(it) }
    }

    fun assertNoValues(): TestObserver<T> {
        Assert.assertEquals(emptyList<T>(), this.values)
        return this
    }

    fun assertValues(vararg values: T): TestObserver<T> {
        Assert.assertEquals(values.toList(), this.values)
        return this
    }

    fun finish() {
        job.cancel()
    }
}

fun <T> Flow<T>.test(scope: CoroutineScope): TestObserver<T> {
    return TestObserver(scope, this)
}

We have created an extension function that will help us simplify the solution. This extension function takes care of adding and validating data.

Final "HomeRepositoryTest" class:

import com.example.androidtddsample.home.model.PostDto
import com.example.androidtddsample.utils.TestCoroutineRule
import com.example.androidtddsample.utils.test
import com.example.androidtddsample.utils.testPostDtoData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.junit.MockitoJUnitRunner

@ExperimentalCoroutinesApi
@RunWith(MockitoJUnitRunner::class)
class HomeRepositoryTest {
    @get:Rule
    var testCoroutineRule = TestCoroutineRule()

    @Mock
    private lateinit var homeDataSource: HomeDataSource

    @Test
    fun `get posts data returns posts list data`() = runBlockingTest {
        //Assemble
        val subject = Channel<List<PostDto>>()

        val post1 = testPostDtoData()
        val post2 = testPostDtoData(id = 1, title = "Post 1", body = "Post 1 Body")

        Mockito.`when`(homeDataSource.getPostsData()).thenReturn(subject.consumeAsFlow())

        val testObject = HomeRepository(homeDataSource)

        //Act
        val testObserver = testObject.getPostsData().test(this)

        testObserver.assertNoValues()

        subject.send(listOf(post1, post2))

        //Assert
        testObserver.assertValues(listOf(post1, post2))

        testObserver.finish()
    }
}

Our test case still passes after code refactoring:

Building HomeDataSource

import com.example.androidtddsample.home.model.PostDto
import kotlinx.coroutines.flow.Flow

class HomeDataSource {
    fun getPostsData(): Flow<List<PostDto>> {
        TODO("Not yet implemented")
    }
}

Create a new file "HomeDataSourceTest" in test. Here is our test case:

@Test
fun `get posts data returns posts list data`() = runBlockingTest {
    //Assemble
    val post1 = testPostDtoData()
    val post2 = testPostDtoData(id = 1, title = "Post 1", body = "Post 1 Body")

    val testObject = HomeDataSource()

    //Act
    val testObserver = testObject.getPostsData().test(this)

    //Assert
    testObserver.assertValues(listOf(post1, post2))
    testObserver.finish()
}

On running the test case, we get the error: "An operation is not implemented: Not yet implemented."

Introduce the service layer (interface), which will be doing post api call.

Here is our HomeDataService class:

import com.example.androidtddsample.home.model.PostDto
import retrofit2.http.GET

interface HomeDataService {
    @GET("/public/v2/posts")
    suspend fun getPostsData(): List<PostDto>
}

Here is our updated test case after integration of service layer:

import com.example.androidtddsample.utils.TestCoroutineRule
import com.example.androidtddsample.utils.test
import com.example.androidtddsample.utils.testPostDtoData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.junit.MockitoJUnitRunner

@ExperimentalCoroutinesApi
@RunWith(MockitoJUnitRunner::class)
class HomeDataSourceTest {
    private val testDispatcher = TestCoroutineDispatcher()

    @get:Rule
    var testCoroutineRule = TestCoroutineRule()

    @Mock
    private lateinit var homeDataService: HomeDataService

    @Test
    fun `get posts data returns posts list data`() = runBlockingTest {
        //Assemble
        val post1 = testPostDtoData()
        val post2 = testPostDtoData(id = 1, title = "Post 1", body = "Post 1 Body")

        Mockito.`when`(homeDataService.getPostsData()).thenReturn(listOf(post1, post2))

        val testObject = HomeDataSource(homeDataService, testDispatcher)

        //Act
        val testObserver = testObject.getPostsData().test(this)

        //Assert
        testObserver.assertValues(listOf(post1, post2))
        testObserver.finish()
    }
}

Implement "HomeDataSource" class now:

import com.example.androidtddsample.home.model.PostDto
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn

class HomeDataSource(
    private val homeDataService: HomeDataService,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
    fun getPostsData(): Flow<List<PostDto>> {
        return flow {
            emit(homeDataService.getPostsData())
        }.flowOn(dispatcher)
    }
}

Test case will pass now:

Our back-end implementation is over (except for defining retrofit services, which we will do later as we are following TDD). Run all test cases to make sure everything is working fine.

Let's deep dive into composable screen development by following TDD. We will be using the Robolectric library to write a unit test case for the UI screen.

Build HomeScreen (Composable function)

Create "HomeScreenTest" class.

First, the empty class should be decorated with the run with JUnit 4 annotation eg:

@RunWith(AndroidJUnit4::class)

The next step is to define the compseTestRule:

@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()

This will launch the activity under test and be used to match elements.

For above, we need to add new dependency:

//Compose Test Dependency
testImplementation "androidx.compose.ui:ui-test-junit4:1.0.1"

Robolectric integration. (For details, follow instructions this link.)

//Robolectric Test Dependency
testImplementation "org.robolectric:robolectric:4.7.3"

android {
    testOptions {
        unitTests.returnDefaultValues = true
        unitTests {
            includeAndroidResources = true
        }
    }
}

Add assertions to validate posts data:

//Assemble
val post1 = testPostDtoData(title = "Post 1", body = "Post 1 Body")
val post2 = testPostDtoData(title = "Post 2", body = "Post 2 Body")
//Assert
composeTestRule.onNodeWithText("Post 1").assertIsDisplayed()
composeTestRule.onNodeWithText("Post 1 Body").assertIsDisplayed()

composeTestRule.onNodeWithText("Post 2").assertIsDisplayed()
composeTestRule.onNodeWithText("Post 2 Body").assertIsDisplayed()

Start composable screen:

composeTestRule.setContent {
    AndroidTDDSampleTheme {
        Surface(color = MaterialTheme.colors.background) {
            HomeScreen()
        }
    }
}

Here, we don't have HomeScreen. Let's create it.

import androidx.compose.runtime.Composable

@Composable
fun HomeScreen() {

}

Here is our final test case class:

import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.example.androidtddsample.MainActivity
import com.example.androidtddsample.ui.theme.AndroidTDDSampleTheme
import com.example.androidtddsample.utils.testPostDtoData
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class HomeScreenTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Test
    fun `validate posts data`() {
        //Assemble
        val post1 = testPostDtoData(title = "Post 1", body = "Post 1 Body")
        val post2 = testPostDtoData(title = "Post 2", body = "Post 2 Body")

        //Act
        composeTestRule.setContent {
            AndroidTDDSampleTheme {
                Surface(color = MaterialTheme.colors.background) {
                    HomeScreen()
                }
            }
        }

        //Assert
        composeTestRule.onNodeWithText("Post 1").assertIsDisplayed()
        composeTestRule.onNodeWithText("Post 1 Body").assertIsDisplayed()

        composeTestRule.onNodeWithText("Post 2").assertIsDisplayed()
        composeTestRule.onNodeWithText("Post 2 Body").assertIsDisplayed()
    }
}

Test will run without any issues but fails as we have not integrated the HomeViewModel yet.

We are going to use KOIN to inject HomeViewModel into HomeScreen.

Start with adding required dependency.

implementation "io.insert-koin:koin-android:3.1.3"// Koin main features for Android
implementation "io.insert-koin:koin-androidx-compose:3.1.3"// Jetpack Compose

Create "Application" class instance to initialize KOIN.

import android.app.Application
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin

class AndroidTDDSampleApplication : Application() {

    override fun onCreate() {
        super.onCreate()

        startKoin {
            androidContext(this@AndroidTDDSampleApplication)
        }
        AppModule.init()
    }

    override fun onTerminate() {
        super.onTerminate()
        stopKoin()
    }
}

Update name space in Manifest file:

<application
    android:name=".AndroidTDDSampleApplication"

Just to keep code clean, we are going to initialize app level koin dependencies in AppModule Class.

Here is AppModule Object:

import com.example.androidtddsample.home.HomeViewModel
import com.example.androidtddsample.home.api.HomeDataSource
import com.example.androidtddsample.home.api.HomeRepository
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.component.KoinComponent
import org.koin.core.context.loadKoinModules
import org.koin.dsl.module

object AppModule : KoinComponent {

    fun init() {

        val homeScreenModules = module {
            single { HomeDataSource(get()) }
            single { HomeRepository(get()) }
            viewModel { HomeViewModel(get()) }
        }

        val moduleList = listOf(
            homeScreenModules,
        )
        loadKoinModules(moduleList)
    }
}

Let's go back to our test case.

To mock the dependencies injected by Koin, we extend the KoinTestclass and override the module with the mocked dependency like below:

import android.app.Application
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.GlobalContext
import org.koin.core.context.loadKoinModules
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.core.module.Module

class KoinTestApp : Application() {

    private val moduleList = mutableListOf<Module>()

    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidLogger()
            androidContext(this@KoinTestApp)
        }
    }

    fun addModule(module: Module) {
        moduleList.add(module)
    }

    internal fun loadModules() {
        loadKoinModules(moduleList)
    }

    internal fun unloadModules() {
        GlobalContext.unloadKoinModules(moduleList)
        stopKoin()
    }
}

Set configuration for HomeScreenTest.

@Config(
    instrumentedPackages = ["androidx.loader.content"],
    application = KoinTestApp::class,
    sdk = [28]
)

Extend test class with KoinTest. To use KoinTest, add the below dependency.

testImplementation "io.insert-koin:koin-test:3.1.3"

Add setup:

private val homeRepository: HomeRepository = mock(HomeRepository::class.java)

private val mockedModule = module {
    single { homeRepository }
    viewModel { HomeViewModel(get()) }
}

private val app: KoinTestApp = ApplicationProvider.getApplicationContext()

@Before
fun setUp() {
    app.addModule(mockedModule)
    app.loadModules()
}

@After
fun tearDown() {
    app.unloadModules()
}

Here, we have created a mocked instance of HomeRepository class. We have overridden the same in mockedModule. Using the app, which is an Overloaded Application instance for Test, we are loading and unloading modules in @Before and @After.

We are mocking HomeRepository to avoid making actual API call and will act to mock and send the data to ViewModel class.

Add mocked data to simulate the API call and here is final test case:

@Test
fun `validate posts data`() = runBlockingTest {
    //Assemble
    val post1 = testPostDtoData(title = "Post 1", body = "Post 1 Body")
    val post2 = testPostDtoData(title = "Post 2", body = "Post 2 Body")

    val subject = Channel<List<PostDto>>()

    Mockito.`when`(homeRepository.getPostsData())
        .thenReturn(subject.consumeAsFlow())

    //Act
    composeTestRule.setContent {
        AndroidTDDSampleTheme {
            Surface(color = MaterialTheme.colors.background) {
                HomeScreen()
            }
        }
    }

    launch {
        subject.send(listOf(post1, post2))
    }

    //Assert
    composeTestRule.onNodeWithText("Post 1").assertIsDisplayed()
    composeTestRule.onNodeWithText("Post 1 Body").assertIsDisplayed()

    composeTestRule.onNodeWithText("Post 2").assertIsDisplayed()
    composeTestRule.onNodeWithText("Post 2 Body").assertIsDisplayed()
}

Run test case:

Test fails for the right reason and it's time to work on UI design.

Here is UX implementation:

import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.androidtddsample.R
import com.example.androidtddsample.home.model.PostDto
import org.koin.androidx.compose.viewModel

@Composable
fun HomeScreen() {

    val homeViewModel: HomeViewModel by viewModel()

    val state: HomeScreenContract.State = homeViewModel.viewState.value

    val scaffoldState: ScaffoldState = rememberScaffoldState()

    Scaffold(
        scaffoldState = scaffoldState,
    ) {
        Column {
            TopAppBar(title = { stringResource(id = R.string.app_name) })
            PostsList(state.postsListData)
        }
    }
}

@Composable
private fun PostsList(postsListData: List<PostDto>) {
    LazyColumn {
        items(postsListData) { item ->
            PostsRow(item)
        }
    }
}

@Composable
private fun PostsRow(item: PostDto) {
    Row(modifier = Modifier
        .animateContentSize()
    ) {
        PostItemRowDetails(item = item)
    }
}

@Composable
fun PostItemRowDetails(item: PostDto) {
    Card(Modifier
        .fillMaxWidth()
        .padding(7.dp),
        elevation = 6.dp
    ) {
        Column {
            Text(
                text = item.title,
                fontSize = 16.sp,
                fontWeight = FontWeight.W600,
                modifier = Modifier.padding(5.dp)
            )
            Text(
                text = item.body,
                fontSize = 14.sp,
                fontWeight = FontWeight.W600,
                modifier = Modifier.padding(5.dp)
            )
        }
    }
}

Test case will pass now:

Let's write another test for error case handling.

@Test
fun `validate error with retry and on retry validate posts data on success`() =
    runBlockingTest {
        //Assemble
        val post1 = testPostDtoData(title = "Post 1", body = "Post 1 Body")
        val post2 = testPostDtoData(title = "Post 2", body = "Post 2 Body")
        val subject = Channel<List<PostDto>>()

        Mockito.`when`(homeRepository.getPostsData())
            .thenAnswer { throw ConnectException("Network Error") }.thenReturn(subject.consumeAsFlow())

        composeTestRule.setContent {
            AndroidTDDSampleTheme {
                Surface(color = MaterialTheme.colors.background) {
                    HomeScreen()
                }
            }
        }

        //Assert
        composeTestRule.onNodeWithText("Network Error").assertIsDisplayed()
        composeTestRule.onNodeWithText("Retry").assertIsDisplayed()

        composeTestRule.onNodeWithText("Retry").performClick()

        launch {
            subject.send(listOf(post1, post2))
        }

        //Assert
        composeTestRule.onNodeWithText("Post 1").assertIsDisplayed()
        composeTestRule.onNodeWithText("Post 1 Body").assertIsDisplayed()

        composeTestRule.onNodeWithText("Post 2").assertIsDisplayed()
        composeTestRule.onNodeWithText("Post 2 Body").assertIsDisplayed()
    }

And here is the integration for HomeScreen:

val LAUNCH_LISTEN_FOR_EFFECTS = "launch-listen-to-effects"

val coroutineScope = rememberCoroutineScope()

if (state.error != null) {
    LaunchedEffect(LAUNCH_LISTEN_FOR_EFFECTS) {
        effectFlow.onEach { effect ->
            when (effect) {
                is HomeScreenContract.Effect.OnError -> {
                    coroutineScope.launch {
                        val snackbarResult = scaffoldState.snackbarHostState.showSnackbar(
                            message = state.error,
                            actionLabel = "Retry",
                            duration = SnackbarDuration.Indefinite,
                        )
                        when (snackbarResult) {
                            SnackbarResult.ActionPerformed -> homeViewModel.fetchPostsData()
                            else -> {}
                        }
                    }
                }
            }
        }.collect()
    }
}

Our both test cases are success.

Final HomeScreen class file:

import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.androidtddsample.R
import com.example.androidtddsample.home.model.PostDto
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.androidx.compose.viewModel

@Composable
fun HomeScreen() {
    val homeViewModel: HomeViewModel by viewModel()

    val state: HomeScreenContract.State = homeViewModel.viewState.value
    val effectFlow: Flow<HomeScreenContract.Effect> = homeViewModel.effect

    val scaffoldState: ScaffoldState = rememberScaffoldState()

    val LAUNCH_LISTEN_FOR_EFFECTS = "launch-listen-to-effects"

    val coroutineScope = rememberCoroutineScope()

    if (state.error != null) {
        LaunchedEffect(LAUNCH_LISTEN_FOR_EFFECTS) {
            effectFlow.onEach { effect ->
                when (effect) {
                    is HomeScreenContract.Effect.OnError -> {
                        coroutineScope.launch {
                            val snackbarResult = scaffoldState.snackbarHostState.showSnackbar(
                                message = state.error,
                                actionLabel = "Retry",
                                duration = SnackbarDuration.Indefinite,
                            )
                            when (snackbarResult) {
                                SnackbarResult.ActionPerformed -> homeViewModel.fetchPostsData()
                                else -> {}
                            }
                        }
                    }
                }
            }.collect()
        }
    }


    Scaffold(
        scaffoldState = scaffoldState,
    ) {
        Column {
            TopAppBar(title = { Text(text = stringResource(id = R.string.app_name)) })
            PostsList(state.postsListData)

            if (state.isLoading)
                LoadingBar()
        }
    }
}

@Composable
private fun PostsList(postsListData: List<PostDto>) {
    LazyColumn {
        items(postsListData) { item ->
            PostsRow(item)
        }
    }
}

@Composable
private fun PostsRow(item: PostDto) {
    Row(modifier = Modifier
        .animateContentSize()
    ) {
        PostItemRowDetails(item = item)
    }
}

@Composable
fun PostItemRowDetails(item: PostDto) {
    Card(Modifier
        .fillMaxWidth()
        .padding(7.dp),
        elevation = 6.dp
    ) {
        Column {
            Text(
                text = item.title,
                fontSize = 16.sp,
                fontWeight = FontWeight.W600,
                modifier = Modifier.padding(5.dp)
            )
            Text(
                text = item.body,
                fontSize = 14.sp,
                color = Color.Gray,
                fontWeight = FontWeight.W600,
                modifier = Modifier.padding(5.dp)
            )
        }
    }
}

@Composable
fun LoadingBar() {
    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier
            .fillMaxSize()
    ) {
        CircularProgressIndicator()
    }
}

Only thing left is integrating or calling HomeScreen composable function from MainActivity and wiring HomeService retrofit service.

Updated App Module file:

import com.example.androidtddsample.home.HomeViewModel
import com.example.androidtddsample.home.api.HomeDataService
import com.example.androidtddsample.home.api.HomeDataSource
import com.example.androidtddsample.home.api.HomeRepository
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.component.KoinComponent
import org.koin.core.context.loadKoinModules
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit

object AppModule : KoinComponent {

    private const val REQUEST_TIMEOUT = 3L

    private var okHttpClient: OkHttpClient? = null

    private val moshi: Moshi by lazy { Moshi.Builder().add(KotlinJsonAdapterFactory()).build() }

    private lateinit var homePostsRetrofit: Retrofit

    fun init() {
        homePostsRetrofit = buildRetrofit(
            okHttpClient = getOkHttpClient(),
            moshi = moshi
        )

        val homeScreenModules = module {
            single { createHomePostsApiService(HomeDataService::class.java) }
            single { HomeDataSource(get()) }
            single { HomeRepository(get()) }
            viewModel { HomeViewModel(get()) }
        }

        val moduleList = listOf(
            homeScreenModules,
        )
        loadKoinModules(moduleList)
    }

    fun <S> createHomePostsApiService(service: Class<S>) = homePostsRetrofit.create(service)

    private fun getOkHttpClient(): OkHttpClient {

        return if (okHttpClient == null) {
            val okHttpClient = OkHttpClient.Builder()
                .readTimeout(REQUEST_TIMEOUT, TimeUnit.SECONDS)
                .connectTimeout(REQUEST_TIMEOUT, TimeUnit.SECONDS)
                .build()
            this.okHttpClient = okHttpClient
            okHttpClient
        } else {
            okHttpClient!!
        }
    }

    private fun buildRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://gorest.co.in/")
            .addConverterFactory(MoshiConverterFactory.create(moshi))
            .client(okHttpClient)
            .build()
    }
}

We have used Moshi for Json parsing. Add below dependency:

implementation "com.squareup.moshi:moshi-kotlin:1.12.0"
implementation "com.squareup.retrofit2:converter-moshi:2.4.0"

And finally call HomeScreen from MainActivity.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AndroidTDDSampleTheme {
                // A surface container using the 'background' color from the theme
                Surface(modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background) {
                    HomeScreen()
                }
            }
        }
    }
}

Run the application and see the magic — the app works as expected and perfectly.

But don't forget to give Internet permission in Manifest file!

<uses-permission android:name="android.permission.INTERNET"/>

Re-run all our test cases and you might notice that HomeScreenTests are failing. This is due to fact that the moment you create composeTestRule with below code MainActivity, it will get initialized and also start HomeScreen; and at this time our mocks won't be setup.

@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()

A quick turnaround for this is to stop the auto loading of HomeScreen. We can detect whether the app is running in test mode or not using:

object InTest {
    var isInTest: Boolean = try {
        Class.forName("androidx.test.espresso.Espresso")
        true
    } catch (e: ClassNotFoundException) {
        false
    } catch (e: ExceptionInInitializerError) {
        false
    }
}

And we can call HomeScreen in MainActivity as:

if (!isInTest) {
    HomeScreen()
}

Final HomeScreen code:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 
import com.example.androidtddsample.InTest.isInTest
import com.example.androidtddsample.home.HomeScreen
import com.example.androidtddsample.ui.theme.AndroidTDDSampleTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AndroidTDDSampleTheme {
                // A surface container using the 'background' color from the theme
                Surface(modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background) {
                    if (!isInTest) {
                        HomeScreen()
                    }
                }
            }
        }
    }
}

Final thoughts

You now know what it means to practice TDD in Android app development, and you can start using TDD on your own.

By including the tests you write with TDD from the start, you'll have more confidence in your code throughout the lifetime of your app, especially when you add new features and release new versions of the app.