Making TDD a Habit in Android Development (Part 2)

MEGA
10 min readAug 2, 2023

--

By Kevin Gozali — Senior Android Engineer @MEGA

Previous Article: Making TDD a Habit in Android Development (Part 1)

Step 6 — Create the UI Test Class (RED)

We can now create the test class for the UI., JUnit5is not yet available for writing UI tests, so I’m going to use JUnit4 instead. Because we already know that we are going to use Jetpack Compose, we should go ahead and add the createComposeRule(). setComposeContent will temporarily be marked TODO because we haven’t created the actual Jetpack Compose view.

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class ContactsScreenTest {
@get:Rule
val composeTestRule = createComposeRule()

private fun setComposeContent(uiState: UIState) {
composeTestRule.setContent {
// TODO
}
}
}

As always, we should start by creating empty functions describing all of the intents you have mapped and start with the Assert part of each test. You should end up with this:

fun `test that loading layout should be the only thing visible when screen state is loading`() {}
fun `test that error layout should be the only thing visible when screen state is error`() {}
fun `test that screen should display list of contacts when available`() {}
fun `test that loading layout should be the only thing visible when screen state is loading`() {
// Arrange

// Act

// Assert
composeTestRule.onNodeWithTag("loading_layout").assertIsDisplayed()
composeTestRule.onNodeWithTag("error_layout").assertDoesNotExist()
composeTestRule.onNodeWithTag("contact_list").assertDoesNotExist()
}

fun `test that error layout should be the only thing visible when screen state is error`() {
// Arrange

// Act
// Assert
composeTestRule.onNodeWithTag("error_layout").assertIsDisplayed()
composeTestRule.onNodeWithTag("loading_layout").assertDoesNotExist()
composeTestRule.onNodeWithTag("contact_list").assertDoesNotExist()
}

fun `test that screen should display list of contacts when available`() {
// Arrange

// Act
// Assert
// Because contacts is a list, let's verify that each contact is displayed
// you should be careful in this step, because not every contact can be displayed at once
// on the screen, depends on the phone screen sizes, the number of contacts displayed
// might differ. For this example, assume we have 2 contacts that will be displayed in the screen
contacts.forEachIndexed { index, contact ->
composeTestRule.onNodeWithTag("contact_name:$index")
.assertIsDisplayed()
.assert(hasText(contact.name))
}
composeTestRule.onNodeWithTag("error_layout").assertDoesNotExist()
composeTestRule.onNodeWithTag("loading_layout").assertDoesNotExist()
}

Now let’s fill in the Act & Arrange part of the equation. You should end up with something in the ballpark of this:

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class ContactsScreenTest {
@get:Rule
val composeTestRule = createComposeRule()

private fun setComposeContent(uiState: UIState) {
composeTestRule.setContent {
// TODO
}
}

@Test
fun `test that loading layout should be visible when screen state is loading`() {
// Arrange
val uiState = UIState(
isError = false,
isLoading = true
)

// Act
setComposeContent(uiState)

// Assert
composeTestRule.onNodeWithTag("loading_layout").assertIsDisplayed()
composeTestRule.onNodeWithTag("error_layout").assertDoesNotExist()
composeTestRule.onNodeWithTag("contact_list").assertDoesNotExist()
}

@Test
fun `test that error layout should be visible when screen state is error`() {
// Arrange
val uiState = UIState(
isError = true,
isLoading = false
)

// Act
setComposeContent(uiState)

// Assert
composeTestRule.onNodeWithTag("error_layout").assertIsDisplayed()
composeTestRule.onNodeWithTag("loading_layout").assertDoesNotExist()
composeTestRule.onNodeWithTag("contact_list").assertDoesNotExist()
}

@Test
fun `test that screen should display list of contacts when available`() {
// Arrange
val uiState = UIState(
isError = false,
isLoading = false,
contacts = listOf(
Contacts(
name = "Qwerty",
email = "Qwerty@uiop.com",
phone = "12345678910"
),
Contacts(
name = "Qwerty2",
email = "Qwerty2@uiop.com",
phone = "71237128123"
),
)
)


// Act
setComposeContent(uiState)

// Assert
contacts.forEachIndexed { index, contact ->
composeTestRule.onNodeWithTag("contact_name:$index")
.assertIsDisplayed()
.assert(hasText(contact.name))
}
composeTestRule.onNodeWithTag("error_layout").assertDoesNotExist()
composeTestRule.onNodeWithTag("loading_layout").assertDoesNotExist()
}
}

The tests above will compile, at the absolute minimum, but all tests will fail at this point because you haven’t set the view in setComposeContent.

The next step is to add the minimum implementation to make all tests fail for the right reason. Remember, we don’t want our tests to fail for the wrong kind of reasons. Not setting the view inside setComposeContent is considered as failing for the wrong reason. You should end up with something similar to this:

// ContactsRoute.kt
@Composable
fun ContactsRoute(viewModel: ContactsViewModel = hiltViewModel()) {
val uiState by viewModel.state.collectAsStateWithLifecycle()

ContactsView(
modifier = Modifier.fillMaxSize(),
uiState = uiState
)
}

@Composable
fun ContactsView(
modifier: Modifier = Modifier,
uiState: UIState
) {
// TODO
}
// ContactsScreenTest.kt
private fun setComposeContent(uiState: UIState) {
composeTestRule.setContent {
ContactsView(
modifier = Modifier.fillMaxSize(),
uiState = uiState
)
}
}

Now, if you run your tests again, everything should fail because you haven’t implemented anything at this point. This is what’s referred to as the Red part of the Red, Green, Refactor cycle.

Image 1: Failed tests for the right reasons
Image 2: Failed assertion

Step 7 — Screen implementation in Jetpack Compose (GREEN)

Now, let’s start implementing our code to make each of our test cases passes. Let’s start with the first test case:

// ContactsRoute.kt
@Composable
fun ContactsView(
modifier: Modifier = Modifier,
uiState: UIState
) {
Box(modifier = modifier) {
// test that loading layout should be visible when screen state is loading
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier
.size(44.dp)
.align(Alignment.Center)
.testTag("loading_layout"),
color = teal_300,
)
}
}
}

Run the first test and make sure it passes.

@Test
fun `test that loading layout should be visible when screen state is loading`() {
// Arrange
val uiState = UIState(
isError = false,
isLoading = true
)

// Act
setComposeContent(uiState)

// Assert
composeTestRule.onNodeWithTag("loading_layout").assertIsDisplayed()
composeTestRule.onNodeWithTag("error_layout").assertDoesNotExist()
composeTestRule.onNodeWithTag("contact_list").assertDoesNotExist()
}

Continue with the next test cases and make sure that every tests is green at this point. You should do this, as demonstrated above, one by one and make sure each test case passes before moving on to the next test case.

To shorten the code snippet, I’m writing this all together, although, behind the scenes, I’m doing it gradually one-by-one

// ContactsRoute.kt
@Composable
fun ContactsView(
modifier: Modifier = Modifier,
uiState: UIState
) {
Box(modifier = modifier) {
// test that loading layout should be visible when screen state is loading
...

// NOTE: Add 1-by-1 for each test case
// test that error layout should be visible when screen state is error
if (uiState.isError) {
Text(
modifier = Modifier
.wrapContentSize()
.align(Alignment.Center)
.testTag("error_layout"),
text = "Error",
style = MaterialTheme.typography.h1
)
}

// NOTE: Add 1-by-1 for each test case
// test that screen should display list of contacts when available
if (uiState.contacts.isNotEmpty()) {
LazyColumn(
modifier = modifier
.padding(padding)
.fillMaxSize()
) {
items(
key = { it },
count = uiState.contacts.size,
) { index ->
Text(
modifier = Modifier
.testTag("contact_name:$index"),
text = uiState.contacts[index].name,
style = MaterialTheme.typography.subtitle2
)
}
}
}
}
}
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class ContactsScreenTest {
@get:Rule
val composeTestRule = createComposeRule()

private fun setComposeContent(uiState: UIState) {
composeTestRule.setContent {
ContactsView(
modifier = Modifier.fillMaxSize(),
uiState = uiState
)
}
}

@Test
fun `test that loading layout should be visible when screen state is loading`() {
...
}

// NOTE: Add 1-by-1 for each implementation
@Test
fun `test that error layout should be visible when screen state is error`() {
// Arrange
val uiState = UIState(
isError = true,
isLoading = false
)

// Act
setComposeContent(uiState)

// Assert
composeTestRule.onNodeWithTag("error_layout").assertIsDisplayed()
composeTestRule.onNodeWithTag("loading_layout").assertDoesNotExist()
composeTestRule.onNodeWithTag("contact_list").assertDoesNotExist()
}

// NOTE: Add 1-by-1 for each implementation
@Test
fun `test that screen should display list of contacts when available`() {
// Arrange
val uiState = UIState(
isError = false,
isLoading = false,
contacts = listOf(
Contacts(
name = "Qwerty",
email = "Qwerty@uiop.com",
phone = "12345678910"
),
Contacts(
name = "Qwerty2",
email = "Qwerty2@uiop.com",
phone = "71237128123"
),
)
)


// Act
setComposeContent(uiState)

// Assert
contacts.forEachIndexed { index, contact ->
composeTestRule.onNodeWithTag("contact_name:$index")
.assertIsDisplayed()
.assert(hasText(contact.name))
}
composeTestRule.onNodeWithTag("error_layout").assertDoesNotExist()
composeTestRule.onNodeWithTag("loading_layout").assertDoesNotExist()
}
}

The same concept still applies, 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 8 — Refactor & clean up (REFACTOR)

At this point in time, all tests are supposed to be Green. We can make it cleaner at this point. Let’s start by combining the logic into a when statement because the 3 cases above are mutually exclusive and should not intersect with each other. This is what’s referred to as the Refactor part of the Red, Green, Refactor cycle.

// ContactsRoute.kt
@Composable
fun ContactsView(
modifier: Modifier = Modifier,
uiState: UIState
) {
Box(modifier = modifier) {
when {
// test that loading layout should be visible when screen state is loading
uiState.isLoading -> {
CircularProgressIndicator(
modifier = Modifier
.size(44.dp)
.align(Alignment.Center)
.testTag("loading_layout"),
color = teal_300,
)
}
// test that error layout should be visible when screen state is error
uiState.isError -> {
Text(
modifier = Modifier
.wrapContentSize()
.align(Alignment.Center)
.testTag("error_layout"),
text = "Error",
style = MaterialTheme.typography.h1
)
}
// test that screen should display list of contacts when available
else -> {
LazyColumn(
modifier = modifier
.padding(padding)
.fillMaxSize()
) {
items(
key = { it },
count = uiState.contacts.size,
) { index ->
Text(
modifier = Modifier
.testTag("contact_name:$index"),
text = uiState.contacts[index].name,
style = MaterialTheme.typography.subtitle2
)
}
}
}
}
}
}

At this point after the refactor, you should run your tests again and make sure it’s all Green. If any of these tests failed after your refactor, then you know that there are some behavior changes caused by your refactor that need to be addressed. This is the whole point of creating and laying out the tests beforehand, to make sure that expected behaviors are always preserved.

Image 3: All tests passes

Step 9 — Assembly

Finally, all steps of refactoring are done, and now it’s time to assemble it. You should call ContactsRoute from your NavHost, Activity, or Fragment

class ContactsFragment() : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MaterialTheme {
ContactsRoute()
}
}
}
}

That’s it. Now if you navigate to your current Fragment you should see your newly refactored screen.

Step 10 — Adding functionalities at a later stage

In the real world, requirements might change, and sometimes behaviors might also change on the fly; how should we deal with this?

Let’s pretend that a new requirement needs to show a “Refresh” button when the screen state is an error.

fun `test that refresh button is visible when screen state is error`() {
// Arrange
val uiState = UIState(
isError = true,
isLoading = false
)

// Act
setComposeContent(uiState)

// Assert
composeTestRule.onNodeWithTag("refresh_button").assertIsDisplayed()
}

Start with Assert , then continue with Act & Arrange . At this point, when you run the test, make sure it fails. Always remember that Red, Green, Refactor is at the core.

Image 4: Making sure the test failed

Next, as usual, add the minimum amount of code to make it pass

uiState.isError -> {
Text(
modifier = Modifier
.wrapContentSize()
.align(Alignment.Center)
.testTag("error_layout"),
text = "Error",
style = MaterialTheme.typography.h1
)
Button(
modifier = Modifier.testTag("refresh_button"),
onClick = {}
) {
Text(text = "Refresh")
}
}

Run the tests again and make sure it’s green.

Image 5: Making sure all tests pass

At this point, you are safe to refactor and make changes to it if you deem it necessary. Pretty straightforward going from here.

Key Takeaways

By doing this refactor, hopefully, you can now see that there are a plethora of benefits to refactoring this legacy class by incorporating TDD, but here are the top 5:

  1. Strong and solid foundation — By mapping the test cases at the beginning, you ensure that every line of code you write should behave exactly how you intended it to be.
  2. Clear, organized, and structured — Every behavior is mapped out from the beginning, and the specifications of the feature are listed as test cases.
  3. Very high test coverage — Higher test coverage is the side effect of your well-tested code. A low test coverage might suggest that you are testing incorrectly, or worse, you are not testing at all.
  4. Reliable tests — High test coverage is not enough; reliable tests are also an important piece of the puzzle, one simply cannot exist without the other. Knowing that your test can fail is the essential part; that’s the whole point of Red, Green, Refactor. You can, in fact, make any test pass. Making sure your tests are reliable in the first place is the way to go.
  5. Gradual changes — Gradual changes in your code mean more control. In software development, control is everything. If you are adding a behavior at a time while making sure each test case at a time passes; the chances of you making mistakes are slim to none. In the worst-case scenario, you are making one mistake in a given time. During a typical refactor without TDD, you would just refactor everything into this one big chunk of code with tens to hundreds of behaviors, which increases the odds of you making mistakes by tens to hundreds of times.

Conclusion

Incorporating TDD into your daily development routines is a great way to not only improve your productivity but also the reliability and quality of every line of code you publish. Red, Green, Refactoris a pretty easy step to follow, and it’s the main focus and method of incorporating TDD into your daily routines. You could in fact get away with writing your tests at the very end or - even worse - not writing tests at all, but is it worth the risk to do so?

There are possibly a lot more benefits than what is listed above, but I can assure you that the 5 benefits mentioned above should be enough to convince you that TDD is worth incorporating into your daily development routine. Laying out tests after refactoring is not an ideal way to incorporate them as a habit; for starters, requirements and behaviors might get lost during the refactoring and there will be no behavioral comparison to the prior implementation to compare it to. Granted, you could compare it with the previous implementation, but wouldn’t it be easier just to lay out the specifications initially?

Incorporating a new habit is difficult, especially if you have been doing this for quite a while. You don’t have to start with classes that are a thousand lines long; start small by growing the habit of writing the tests before writing the actual implementation. You can start by refactoring a relatively small class just to get a grasp of TDD and gradually go from there. Writing reliable tests will make your life exponentially easier.

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