Making TDD a Habit in Android Development (Part 1)

MEGA
13 min readAug 1, 2023

--

By Kevin Gozali — Senior Android Engineer @MEGA

Introduction

Test-Driven Development, or TDD for short, is defined as a software development process that relies on requirements being laid out as test cases before a software is developed.
This might sound magical but in fact it’s a very practical and relatively easy thing to do if you do it right.
There are many ways to incorporate TDD but Red, Green, Refactor is considered the easiest, fastest, and most reliable method and guideline to use.
TDD is platform agnostic, which means that you can incorporate this technique or habit no matter what platform you are developing on or what language you are writing it with; as long as you can write tests, then the details don’t matter.
This article is intended for the Android platform, but the concepts will be the same for any platform you are planning to incorporate this into.

Setup & Overview

I’m using the ContactsFragment.kt below as an example. This class is pretty basic stuff with all the view and business logic mixed up inside the Fragment, but it is ideal for demonstration purposes. Our plan is to refactor this class into Jetpack Compose and separate the business logic in compliance with CLEAN Architecture with the help of TDD along the way. Keep in mind that the goal is not to emphasize the best practice refactoring of this simple screen from the perspective of CLEAN architecture, but rather I will demonstrate to you how you can incorporate TDD as a tool to help you refactor.

@AndroidEntryPoint
class ContactsFragment : Fragment() {
// Let's pretend this is a class responsible to retrieve data from API
@Inject
lateinit var megaApi: MegaApi

private var _binding: FragmentContactsBinding? = null
private val binding get() = _binding!!

private var mAdapter: ContactsAdapter? = null

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentContactsBinding.inflate(layoutInflater)
binding.recyclerView.apply {
addItemDecoration(SimpleDividerItemDecoration(requireContext()))
layoutManager = LinearLayoutManager(requireContext())
itemAnimator = DefaultItemAnimator()
}
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
fetchContacts()
}

override fun onDestroyView() {
_binding = null
super.onDestroyView()
}

private fun fetchContacts() = lifecycleScope.launch(Dispatchers.Main) {
binding.loadingLayout.isVisible = true
val contacts = try {
withContext(Dispatchers.IO) {
megaApi.getContacts()
}
} catch (e: Exception) {
showErrorLayout()
return
}
updateContactsUI(contacts)
}

private fun updateContactsUI(contacts: List<Contact>?) {
when {
contacts.isNullOrEmpty() -> showEmptyLayout()
else -> {
if (mAdapter == null) {
mAdapter = ContactsAdapter(
requireActivity(),
this,
contacts
)
}

binding.recyclerView.adapter = mAdapter
binding.recyclerView.isVisible = true
binding.loadingLayout.isVisible = false
binding.emptyLayout.isVisible = false
binding.errorLayout.isVisible = false
}
}
}

private fun showEmptyLayout() {
binding.emptyLayout.isVisible = true
binding.recyclerView.isVisible = false
binding.errorLayout.isVisible = false
binding.loadingLayout.isVisible = false
}

private fun showErrorLayout() {
binding.errorLayout.isVisible = true
binding.recyclerView.isVisible = false
binding.emptyLayout.isVisible = false
binding.loadingLayout.isVisible = false
}
}

In this demonstration, I will refactor the class above into several classes based on their separation of concerns, I will end up creating:

Classes:

Contact.kt — Data class for each contact in the contacts list

ContactsViewModel.kt — The View Model for the View

ContactsRoute.kt — The Route, Screen, and View in Jetpack Compose

ContactsRepository.kt — Repository to retrieve the data. Not going to dive deep into this topic, but you can learn more about Repository Patterns here.

UIState.kt — The UI State that will hold the ContactsScreen View state

Test Classes:

ContactsViewModelTest.kt — The test class for ContactsViewModel

ContactsScreenTest.kt — The test class for ContactsScreen

Testing Libraries

These libraries might be optional for you; it doesn’t matter if the libraries you are using are different as long as we are moving towards the same goal. For this demonstration, I will use:

  1. Truth — to help with asserting a value
  2. Turbine — to help with asserting value received in Flow.
  3. Mockito — to help with mocks
  4. JUnit4 — Java unit testing framework (Default for Android Development)
  5. JUnit5 — Java unit testing framework

Setup

You will need to add your dependencies to your app’s build.gradle(app) file. The libraries listed may differ from what your project needs; adjust accordingly. Replace versions with the latest version or with your preferred version.

// Jetpack Compose (BOM)
implementation("androidx.compose:compose-bom:${compose_bom_version}")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
// Jetpack Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:${compose_vm_version}")
// View Model
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:{lifecycle_version}")
// Hilt
implementation("androidx.hilt:hilt-work:${hilt_version}")
implementation("androidx.hilt:hilt-compiler:${hilt_version}")
// UI Tests
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:${coroutines_version}")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${coroutines_version}")
// AndroidX Test
testImplementation("androidx.arch.core:core-testing:${androidx_arch_version}")
// Coroutines Test
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${coroutines_version}")
// Truth
testImplementation("com.google.truth:truth:${truth_version}")
// Turbine
testImplementation("app.cash.turbine:turbine:${turbine_version}")
// Mockito
testImplementation ("org.mockito:mockito-core:${mockito_version}")
// JUnit4
testImplementation("junit:junit:${junit4_version}")
// JUnit5
testImplementation("org.junit.jupiter:junit-jupiter-api:${junit5_version}")
// JUnit4 Support for JUnit5
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${junit5_version}")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${junit5_version}")

Enable Jetpack Compose in your app build.gradle(app) by adding these lines inside the android block

buildFeatures {
compose = true
}

composeOptions {
// Replace {compose_version} with your version
kotlinCompilerExtensionVersion = {compose_version}
}

Read more about the Jetpack Compose setup here.

Step 1 — Create the ViewModel test class

Because we are incorporating TDD, we must start by creating the test class.

class ContactsViewModelTest {}

You should then continue to write the functionality of this screen into sentences in human language, because codes are a great way to explain intents to machines but not to humans. Let’s start with something obvious:

fun `test that loading indicator visibility state is true when ViewModel first load`() {}

The following obvious scenarios are, we want to know when this loading indicator state would be set to falsebecause it’s unlikely that the screen would load forever, so in this case, it will be set to false when the API call finishes, with or without errors.

fun `test that loading indicator visibility state is false when fetch contacts finished successful`() {}
fun `test that loading indicator visibility state is false when fetch contacts returns error`() {}

We should also verify that the data will be updated once the API call finishes, while an error layout should be shown to the user when/if the API call catches an error.

fun `test that contacts data should be updated when fetch contacts successful`() {}
fun `test that error layout visibility is true when fetch contacts throws error`() {}

Step 2 — AAA (Arrange, Action, Assert)

Split each test into 3 sections:

@Test
fun `test that loading indicator visibility state is true when ViewModel first load`() {
// Arrange
// Act
// Assert
}

Then if you haven’t already created a class to hold the UI State, you should create one.

data class UIState(
val isLoading: Boolean = false,
val isError: Boolean = false,
val contacts: List<Contact> = emptyList()
)

Also, create your repository class. I’m also going to inject our API class dependency into the repository’s class constructor via Dagger 2. This is for demonstration purposes only; your repository implementation might be different from this.

class ContactsRepository @Inject constructor(
private val megaApi: MegaApiAndroid
) {
suspend fun getContacts(): List<Contact> = withContext(Dispatchers.IO) {
megaApi.getContacts()
}
}

For this demonstration, I will also create a data class called Contact.kt to hold the contact data returned from the API.

data class Contact(
val name: String,
val email: String,
val phone: String
)

Step 2.1 — Assert

You should start on theAssert part of the equation by asking, “What is the result of this test scenario?”. The result is always the most important, and it’s literally the end goal, so it’s better to start with that. Always focus on the end goal when testing.

Step 2.2 — Act

Next, you should continue with Act. It’s basically the action(s) that are required to achieve your result. You should call your method here or in this example; every test case happens to only require ViewModel init, so initTestClass() will be the Act for all of the test cases, we leave it blank for the moment.

Step 2.3 — Arrange

Then, you should continue with Arrange , which is where you define your prerequisites to run each test, such as declaring variables or mocking variables. Some test cases in this case do not require any Arrange because it only requires ViewModel init so we will leave them blank.

@Test
fun `test that loading indicator visibility state is true when ViewModel first load`() {
// Arrange

// Act
initTestClass()

// Assert
Truth.assertThat(state.isLoading).isTrue()
}

@Test
fun `test that loading indicator visibility state is false when fetch contacts successful`() {
// Arrange
val contacts: List<Contact> = mock()
whenever(contactsRepository.getContacts()).thenReturn(contacts)

// Act
initTestClass()

// Assert
Truth.assertThat(state.isLoading).isFalse()
}

@Test
fun `test that loading indicator visibility state is false when fetch contacts returns error`() {
// Arrange
whenever(contactsRepository.getCurrengetContactstUserContacts()).thenThrow(RuntimeException())
// Act
initTestClass()

// Assert
Truth.assertThat(state.isLoading).isFalse()
}

@Test
fun `test that contacts data should be updated when fetch contacts successful`() {
// Arrange
val contacts: List<Contact> = mock()
whenever(contactsRepository.getContacts()).thenReturn(contacts)
// Act
initTestClass()

// Assert
verify(contactsRepository).getContacts()
Truth.assertThat(state.isLoading).isFalse()
Truth.assertThat(state.contacts).isEqualTo(contacts)
}

@Test
fun `test that error layout visibility is true when fetch contacts throws error`() = runTest {
// Arrange
whenever(contactsRepository.getContacts()).thenThrow(RuntimeException())
// Act
initTestClass()

// Assert
Truth.assertThat(state.isError).isTrue()
}

Now we can see that our test class is coming together quite nicely, but you can’t compile this test class quite yet because you haven’t created the actual ViewModel yet.

Step 3 — Creating ViewModel

When we start working on the ViewModel, implement each test case that we have defined in the previous step and apply it to the ViewModel. Make sure that all test cases are defined in the code, and be sure to trace the result. We know that the ViewModel will fetch the contacts when the screen first loads, so let’s add that method and leave the implementation empty.

@HiltViewModel
class ContactsViewModel @Inject constructor(
private val contactsRepository: ContactsRepository
) {
private val _uiState = MutableStateFlow(UIState())
val uiState = _uiState.asStateFlow()

init {
fetchContacts()
}

private fun fetchContacts() = viewModelScope.launch {
// TODO
}
}

Now, go back to your test class and make sure that it can compile, but also make sure that every test failed. This is important, as this is what’s usually referred to as the Red in Red, Green, Refactor. I’m using JUnit5 here, but you can use JUnit4. Because we are using Coroutines in our ViewModel, we should go ahead and enable Coroutines in our test as well. In all of our tests, we are calling initTestClass() to create the ViewModel instance, so we should also go ahead and add that. Last but not least, we should modify our Assert because we are using Turbine.

We should end up with something like this:

@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ContactsViewModelTest {
private val contactsRepository: ContactsRepository = mock()
private lateinit var underTest: ContactsViewModel

@BeforeAll
fun init() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}

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

@BeforeEach
fun setup() {
reset(contactsRepository)
}

private fun initTestClass() {
underTest = ContactsViewModel(contactsRepository)
}

@Test
fun `test that loading indicator visibility state is true when ViewModel first load`() = runTest {
// Arrange

// Act
initTestClass()

// Assert
underTest.uiState.test {
val state = awaitItem()
Truth.assertThat(state.isLoading).isTrue()
}
}

@Test
fun `test that loading indicator visibility state is false when fetch contacts successful`() = runTest {
// Arrange
val contacts: List<Contact> = mock()
whenever(contactsRepository.getContacts()).thenReturn(contacts)

// Act
initTestClass()

// Assert
underTest.uiState.test {
awaitItem()
val state = awaitItem()
Truth.assertThat(state.isLoading).isFalse()
}
}

@Test
fun `test that loading indicator visibility state is false when fetch contacts returns error`() = runTest {
// Arrange
whenever(contactsRepository.getContacts()).thenThrow(RuntimeException())

// Act
initTestClass()

// Assert
underTest.uiState.test {
awaitItem()
val state = awaitItem()
Truth.assertThat(state.isLoading).isFalse()
}
}

@Test
fun `test that contacts data should be updated when fetch contacts successful`() = runTest {
// Arrange
val contacts: List<Contact> = mock()
whenever(contactsRepository.getContacts()).thenReturn(contacts)

// Act
initTestClass()

// Assert
verify(contactsRepository).getContacts()
underTest.uiState.test {
awaitItem()
val state = awaitItem()
Truth.assertThat(state.isLoading).isFalse()
Truth.assertThat(state.contacts).isEqualTo(contacts)
}
}

@Test
fun `test that error layout visibility is true when fetch contacts throws error`() = runTest {
// Arrange
whenever(contactsRepository.getContacts()).thenThrow(RuntimeException())

// Act
initTestClass()

// Assert
underTest.uiState.test {
awaitItem()
val state = awaitItem()
Truth.assertThat(state.isError).isTrue()
}
}
}

Then go ahead and run all the tests. All tests should fail, but at this point, they should be able to compile. One of the most important things to know when writing tests is to make sure that your tests can in fact fail, for the right reasons of course. What is the point of writing tests when everything passes regardless? So please at this step make sure that everything fails, because we haven’t written any code yet in our ViewModel so it wouldn’t make any sense to have anything pass at this point.

Step 4 — Ensure all tests fail, but are compilable (RED)

After making sure everything fails in the last step, now it’s time to make sure everything passes. Add the minimum line of code to your ViewModel for each test case to make it pass. This is what’s referred to as Green in the Red, Green, Refactor. It’s crucial that you do this step gradually for each test, you should make sure that each test case passes before moving on to the next test case.

Starting with the first test case, because the loading indicator visibility should be true when the ViewModel first loads, we should go ahead and set it to true on the init block of the ViewModel.

@HiltViewModel
class ContactsViewModel @Inject constructor(
private val contactsRepository: ContactsRepository
) {
private val _uiState = MutableStateFlow(UIState())
val uiState = _uiState.asStateFlow()

init {
// test that loading indicator visibility state is true when ViewModel first load
_uiState.update { it.copy(isLoading = true) }
fetchContacts()
}

private fun fetchContacts() = viewModelScope.launch {
// TODO
}
}
}

Next, go back to the test class and make sure the test passes.

@Test
fun `test that loading indicator visibility state is true when ViewModel first load`() = runTest {
// Arrange

// Act
initTestClass()

// Assert
underTest.uiState.test {
val state = awaitItem()
Truth.assertThat(state.isLoading).isTrue()
}
}

Moving on to the next test, the loading indicator visibility should be set to false when the API finishes successfully.

private fun fetchContacts() = viewModelScope.launch {
runCatching {
// fetch data from repository
contactsRepository.getContacts()
}.onSuccess {
// test that loading indicator visibility state is false when fetch contacts successful
_uiState.update {
it.copy(isLoading = false)
}
}
}

Then, as usual, make sure that the test passed.

@Test
fun `test that loading indicator visibility state is true when ViewModel first load`() = runTest {
...
}

@Test
fun `test that loading indicator visibility state is false when fetch contacts successful`() = runTest {
// Arrange
val contacts: List<Contact> = mock()
whenever(contactsRepository.getContacts()).thenReturn(contacts)

// Act
initTestClass()

// Assert
underTest.uiState.test {
awaitItem()
val state = awaitItem()
Truth.assertThat(state.isLoading).isFalse()
}
}

We should make sure that the loading state is set to false when/if the API call throws an error.

private fun fetchContacts() = viewModelScope.launch {
runCatching {
// fetch data from repository
contactsRepository.getContacts()
}.onSuccess {
// test that loading indicator visibility state is false when fetch contacts successful
...
}.onFailure {
// test that loading indicator visibility state is false when fetch contacts returns error
_uiState.update { it.copy(isLoading = false) }
}
}

Also, make sure that the test is passed at this point.

@Test
fun `test that loading indicator visibility state is true when ViewModel first load`() = runTest {
...
}

@Test
fun `test that loading indicator visibility state is false when fetch contacts successful`() = runTest {
...
}

@Test
fun `test that loading indicator visibility state is false when fetch contacts returns error`() = runTest {
// Arrange
whenever(contactsRepository.getContacts()).thenThrow(RuntimeException())
// Act
initTestClass()

// Assert
underTest.uiState.test {
awaitItem()
val state = awaitItem()
Truth.assertThat(state.isLoading).isFalse()
}
}

Next, when the API finishes successfully, it will update the contact data state.

private fun fetchContacts() = viewModelScope.launch {
runCatching {
// fetch data from repository
contactsRepository.getContacts()
}.onSuccess {
// test that loading indicator visibility state is false when fetch contacts successful
// test that contacts data should be updated when fetch contacts successful
_uiState.update { it.copy(isLoading = false, contacts = currentContacts) }
}.onFailure {
// test that loading indicator visibility state is false when fetch contacts returns error
_uiState.update { it.copy(isLoading = false) }
}
}

Make sure the test case passes.

@Test
fun `test that loading indicator visibility state is true when ViewModel first load`() = runTest {
...
}

@Test
fun `test that loading indicator visibility state is false when fetch contacts successful`() = runTest {
...
}

@Test
fun `test that loading indicator visibility state is false when fetch contacts returns error`() = runTest {
...
}

@Test
fun `test that contacts data should be updated when fetch contacts successful`() = runTest {
// Arrange
val contacts: List<Contact> = mock()
whenever(contactsRepository.getContacts()).thenReturn(contacts)
// Act
initTestClass()

// Assert
verify(contactsRepository).getContacts()
underTest.uiState.test {
awaitItem()
val state = awaitItem()
Truth.assertThat(state.isLoading).isFalse()
Truth.assertThat(state.contacts).isEqualTo(contacts)
}
}

Last but not least, the error layout visibility should be true when the API call throws an error, so repeat the previous steps for the next test case.

@HiltViewModel
class ContactsViewModel @Inject constructor(
private val contactsRepository: ContactsRepository
) {
private val _uiState = MutableStateFlow(UIState())
val uiState = _uiState.asStateFlow()

init {
// test that loading indicator visibility state is true when ViewModel first load
_uiState.update { it.copy(isLoading = true) }
fetchContacts()
}

private fun fetchContacts() = viewModelScope.launch {
runCatching {
// fetch data from repository
contactsRepository.getContacts()
}.onSuccess {
// test that loading indicator visibility state is false when fetch contacts successful
// test that contacts data should be updated when fetch contacts successful
_uiState.update { it.copy(isLoading = false, contacts = currentContacts) }
}.onFailure {
// test that loading indicator visibility state is false when fetch contacts returns error
// test that error layout visibility is true when fetch contacts throws error
_uiState.update { it.copy(isLoading = false, isError = true) }
}
}
}

And make sure the test case is passed.

@Test
fun `test that loading indicator visibility state is true when ViewModel first load`() = runTest {
...
}

@Test
fun `test that loading indicator visibility state is false when fetch contacts successful`() = runTest {
...
}

@Test
fun `test that loading indicator visibility state is false when fetch contacts returns error`() = runTest {
...
}

@Test
fun `test that contacts data should be updated when fetch contacts successful`() = runTest {
...
}

@Test
fun `test that error layout visibility is true when fetch contacts throws error`() = runTest {
// Arrange
whenever(contactsRepository.getContacts()).thenThrow(RuntimeException())
// Act
initTestClass()
// Assert
underTest.uiState.test {
awaitItem()
val state = awaitItem()
Truth.assertThat(state.isError).isTrue()
}
}

At this point, all test cases should be passed. If any of them failed, you shouldn’t, for any reason, move to the next step. It is crucial that everything is Green.

Step 5 — Refactor and cleanup (REFACTOR)

Great! Now that all test cases passed, you can continue to refactor or make changes inside ContactsViewModel.kt without fear because you know if you change any behavior, test(s) will fail. Because we don’t have anything to refactor at this point in the ViewModel, we should go ahead and skip this step. This is what’s referred to as the Refactor part of the Red, Green, Refactor.

Great, that’s it for the Refactor to ViewModel part of this article. In the next step, we are going to continue on refactoring to Jetpack Compose.

Next Article: Making TDD a Habit in Android Development (Part 2)

References

  • Beck, Kent. Test Driven Development: By Example. 1st ed., Addison-Wesley Professional, 2000.
  • Martin, Robert C. Clean Architecture: A Craftsman’s Guide to Software Structure and Design. 1st ed., Pearson, 2017.

Related Articles

--

--

MEGA

Our vision is to be the leading global cloud storage and collaboration platform, providing the highest levels of data privacy and security. Visit us at MEGA.NZ