Level Up Your Career by Adding UI Tests to Your Jetpack Compose App

Chase
8 min readMay 15, 2024

--

Adding tests to your app is a great way to grow yourself and your career. In this tutorial, we will go over how to add User Interface — sometimes called end-to-end tests — to your app.

An image of the testing pyramid with UI highlighted

If you want to know how to do the same thing in SwiftUI, feel free to check out this article and if you want more side by side comparison on dual native development, check out this article: https://medium.com/@jpmtech/a-rosetta-stone-for-dual-native-development-5e948752dc17.

Before we get started, please take a couple of seconds to follow me and 👏 clap for the article so that we can help more people learn about this useful content.

First, why should we write UI tests

Talking to many developers or testing engineers, you may learn about the testing pyramid (as seen in the image above). The theory they may tell you is that we want a lot of test coverage for unit tests, a little less for integration tests, and still fewer for UI tests. They may also say that UI tests are the most expensive tests we write, which is why we want fewer of them. I however, have a different opinion.

While those statements may be true for more senior developers or more mature products, if you are like most developers you may not have written many (or any) tests. Don’t worry, UI tests are an easy way to get started, plus being able to add testing to your résumé can help you get interviews in higher level developer roles.

Most developers will go through their code and manually make sure everything is working as expected. UI tests do the same things you do to test your app (by clicking buttons, filling out fields, and seeing what works and what doesn’t), it just does it faster since it’s a computer. They only require a minimal change to your code (as simple as adding a modifier to a view) and they don’t require any major changes to your app. This makes UI tests the perfect place to start adding tests to your app.

Initial Setup

Before we start writing any tests, we will need something to test. In this tutorial we will be using the following simple bill splitter app. I have added all the views and logic that we will need to make this code work. We want the calculate button to be disabled if the number is negative and we want to be made aware if we accidentally remove or change the disabled modifier on the calculate button (but don’t worry, we will cover these options with our tests).

// MainActivity.kt
import android.annotation.SuppressLint
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp

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

@OptIn(ExperimentalMaterial3Api::class) // for the TopAppBar
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") // for the scaffold content
@Composable
fun MyApp() {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = "Bill Splitter")
}
)
}
) {
BillSplitterForm()
}
}

@Composable
fun BillSplitterForm() {
val amount = remember {
mutableStateOf("0.00")
}
val numberOfPeople = remember {
mutableStateOf("1")
}
val totalPerPerson = remember {
mutableStateOf("0.00")
}

fun calculateTotalPerPerson() {
if (numberOfPeople.value.toBigDecimalOrNull() == null) {
totalPerPerson.value = "0.00"
} else {
totalPerPerson.value = (amount.value.toBigDecimal() / numberOfPeople.value.toBigDecimal()).toString()
}
}

Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(all = 12.dp)
.testTag("billSplitterForm")
) {
Spacer(Modifier.weight(1f))

Column(
modifier = Modifier
.padding(bottom = 12.dp)
) {
Text(text = "Bill Total")
OutlinedTextField(
value = amount.value,
onValueChange = {
amount.value = it
},
modifier = Modifier.testTag("total")
)
}

Picker(
label = "Number of People",
selection = numberOfPeople,
items = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "10"),
modifier = Modifier.testTag("numberOfPeoplePicker")
)

Spacer(Modifier.weight(1f))

Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Total per person")
Text(
text = totalPerPerson.value,
modifier = Modifier.testTag("totalPerPerson")
)
}

Spacer(Modifier.weight(1f))

Button(
onClick = {
calculateTotalPerPerson()
},
enabled = amount.value.toBigDecimalOrNull() != null && amount.value.toBigDecimal() > 0.toBigDecimal(),
modifier = Modifier.testTag("calculate")
) {
Text(text = "Calculate")
}

Spacer(Modifier.weight(1f))
}
}

@Composable
fun Picker(
label: String,
selection: MutableState<String>,
items: List<String>,
modifier: Modifier = Modifier
) {
val isExpanded = remember {
mutableStateOf(false)
}

Column {
OutlinedButton(
onClick = {
isExpanded.value = true
},
modifier = modifier
) {
Text("$label: ${selection.value}")
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
}

DropdownMenu(
expanded = isExpanded.value,
onDismissRequest = {
isExpanded.value = false
}
) {
items.forEachIndexed { itemIndex, itemText ->
DropdownMenuItem(
text = {
Text(text = itemText)
},
onClick = {
selection.value = items[itemIndex]
isExpanded.value = false
},
modifier = Modifier.testTag("numberOfPeople$itemText")
)
}
}
}
}

@Preview(showBackground = true)
@Composable
fun BillSplitterFormPreview() {
UITestingExampleTheme {
BillSplitterForm()
}
}

When we run this code the app works, and our screen looks like the following image:

A screenshot of the app we are testing in this tutorial

Basics of UI Tests

For this project we will need to add the following dependencies to the build.gradle app file to get testing added into our project, after you have added these lines be sure to click the maven sync button at the top right of the window.

dependencies {
// ...

// Add these dependencies
// NOTE: The 1.6.6 at the end of these imports
// is the current version of Compose that I am using as of this writing
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.6.6")
debugImplementation("androidx.compose.ui:ui-test-manifest:1.6.6")
}

Once the sync has completed, right click on the test folder and add a new file that is a Kotlin class and we will name it “CalculatorTests” (as you can see in the image below).

A screenshot of the filepath for the test file that we are creating

Before we get to writing any test, I will call out the small “testTag” modifier that we have added to each of the views we want to either click on or inspect. This modifier is the key to making our UI tests easy to maintain.

Even though the testTag modifier changes the semantic tree, you don’t have to worry about this negatively affecting the accessibility features of your app. This identifier is only used by the system to target specific views in our UI. It isn’t read by screen readers and isn’t made available to any kind of voice controls.

Since we want to interact with the form, total field, values of our picker, calculate button, and make sure the total per person is correct, we have already added the testTag modifier to these fields in the code above. The values we place inside this modifier (like any other ID) should be unique. Placing a non unique ID in these modifiers will possibly result in a failed test. Notice that even with these tags added, none of our view code has changed, and we didn’t have to modify any functions or do any major architecture changes. All we did was add a simple modifier to a view.

Compose Tests also have one benefit over the UI Testing that we do in the SwiftUI world. Compose tests can run against any composable function. Meaning that if you have a single view component that you want to write a UI test for, you can easily call that component and test only that one component. In SwiftUI we have to test the entire app at the same time.

Writing our own UI Tests

Now that we know how to access our views in our tests using the testTag, we are ready to write the first test. We will start out writing a simple test to make sure we are on the correct screen. The ensureWeAreOnTheCorrectScreen test below makes sure that we can see the title text of “Bill Splitter” on our screen.

Another simple test is to make sure that the form is displayed on the page. This is handled by the ensureFormIsOnThePage test.

Now that we have some very easy test out of the way, let’s check the basic operation of our view and make sure that the base functionality works as expected when we have valid inputs (this is referred to as Happy Path Testing). The enteringAValidAmountAndClickingCalculate_displaysTheExpectedTotalPerPerson test covers how we expect the app should work under perfect conditions (the happy path), but what about under not so perfect conditions (the sad path)? What if the user tries to enter letters in the total field or negative numbers (since we have prevented that case in our calculate function)? We could write a couple tests to cover those scenarios to ensure our app works as expected, and that is exactly what our last two tests cover.

import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import org.junit.Rule
import org.junit.Test

class CalculatorTest {
@get:Rule
val rule = createComposeRule()

// the list of items we want to check or tap on
private val billSplitterScreen = hasText("Bill Splitter")
private val billSplitterForm = hasTestTag("billSplitterForm")
private val numberOfPeoplePicker = hasTestTag("numberOfPeoplePicker")
private val total = hasTestTag("total")
private val totalPerPerson = hasTestTag("totalPerPerson")
private val numberOfPeople2 = hasTestTag("numberOfPeople2")
private val calculateButton = hasTestTag("calculate")

@Test
fun ensureWeAreOnTheCorrectScreen() {
// Replacing MyApp() with any other @Composable function
// allows us to test any composable instead of the parent composable
// for our app like we are doing in these tests
rule.setContent { MyApp() }

rule.onNode(billSplitterScreen).assertExists()
}

@Test
fun ensureTheFormIsOnThePage() {
rule.setContent { MyApp() }

rule.onNode(billSplitterForm).assertExists()
}

@Test
fun enteringAValidAmountAndClickingCalculate_displaysTheExpectedTotalPerPerson() {
rule.setContent { MyApp() }

rule.onNode(total).performTextClearance()
rule.onNode(total).performTextInput("100")
rule.onNode(numberOfPeoplePicker).performClick()
rule.onNode(numberOfPeople2).performClick()
rule.onNode(calculateButton).performClick()

rule.onNode(totalPerPerson).assertTextContains("50")
}

@Test
fun enteringANegativeAmountAndClickingCalculate_keepsTheSplitAtZeroAndCalcButtonIsDisabled() {
rule.setContent { MyApp() }

rule.onNode(total).performTextClearance()
rule.onNode(total).performTextInput("-100")
rule.onNode(numberOfPeoplePicker).performClick()
rule.onNode(numberOfPeople2).performClick()
rule.onNode(calculateButton).performClick()

rule.onNode(totalPerPerson).assertTextEquals("0.00")
rule.onNode(calculateButton).assertIsNotEnabled()
}

@Test
fun enteringALetterInTheAmountAndClickingCalculate_keepsTheSplitAtZeroAndCalcButtonIsDisabled() {
rule.setContent { MyApp() }

rule.onNode(total).performTextClearance()
rule.onNode(total).performTextInput("a")
rule.onNode(numberOfPeoplePicker).performClick()
rule.onNode(numberOfPeople2).performClick()
rule.onNode(calculateButton).performClick()

rule.onNode(totalPerPerson).assertTextEquals("0.00")
rule.onNode(calculateButton).assertIsNotEnabled()
}
}

Now we can rest easy knowing that our app does what we expect it to, we can check that everything works by running the tests, other developers can more easily see what our app is expected to do, we can add testing to the list of skills on our resumes, and we did it all with almost no changes to our the way our app existed previously.

Learning more about testing in Android

Google has a great cheatsheet that has all of the testing keywords that you would possibly need to write any test that you would need for your app here: https://developer.android.com/static/develop/ui/compose/images/compose-testing-cheatsheet.png

Google also has some tutorials on various testing for your app, you can learn more about that here: https://developer.android.com/develop/ui/compose/testing

If you got value from this article, please consider following me, 👏 clapping for this article, or sharing it to help others more easily find it.

If you have any questions on the topic or know of another way to accomplish the same task, feel free to respond to the post or share it with a friend and get their opinion on it. If you want to learn more about native mobile development, you can check out the other articles I have written here: https://medium.com/@jpmtech. If you want to see apps that have been built with native mobile development, you can check out my apps here: https://jpmtech.io/apps. Thank you for taking the time to check out my work!

--

--