Tests for compose functions in Android

Imagine a world where every time you make a change to your application code, you are confident that nothing is broken. Where errors are detected before users even have time to notice them. Where your code not only works, but is also documented automatically, improving the project architecture with every test. Sound like a dream? This is actually a reality if you use tests correctly. In this article, we'll dive into the world of Android app testing using Jetpack Compose, look at different types of tests, and learn how to set up and write instrumented tests for your Compose functions.

Why are tests needed at all?

  • Ensuring Code Quality

  • Test edge cases that the developer may not consider.

  • Regression Tests

  • Code documentation itself

  • Improving the code architecture

Main types of tests

Unit tests

Mockito for making mocks – objects of real classes with changed behavior

Robolectric – we need it in the case when we want to test code that depends on android components or is related to the context.

Integration tests

We test how different system components work with each other. For example, a database with an application.

Instrumental tests

We can test the ui itself. For example. Check that text is displayed when you click the button.

Screenshot tests

Verifies the match between screenshots and code.

Instrumental tests

I suggest using instrumented tests for our compose functions.

They are included in the core compose framework. Connect via gradle.

toml file:
compose-bom = "2024.08.00"
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }


Gradle app:
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)

androidTestImplementation(libs.compose.ui.test.junit4)

Practice

After they connect, a folder will be automatically created androidTest. Our tests will be created inside it.

I decided to write a simple test for one of the screens of my application. Here you can enter your card details.

Screen for entering card data

Screen for entering card data

What do these lines mean?

@RunWith(AndroidJUnit4::class)
class AddPaymentInstrumentedTest {

@get:Rule
val composeTestRule = createComposeRule()
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext

@Test
fun testAddPaymentScreen() {
	composeTestRule.setContent {
		//тут будут наши тесты
	}
}
}

In short, with the help of them we create an environment in which we will call our compose functions. A rule can be a set of functions before or after. But it is more convenient to implement it into tests and use it.

In detail:

AndroidJUnit4 – This is an annotation that specifies that tests in a given class should be executed using the AndroidJUnit4 test runner.

createComposeRule is a function that creates a rule for testing Jetpack Compose UI components.

The ComposeTestRule, created using createComposeRule, provides a set of methods for testing Compose UI. It allows you to set Compose content, interact with UI elements, and check their state.

And often we want to limit the entry of characters into map fields.

For example, in the card number field we want to enter only 16 characters, and they can only be numbers.

Let's write a test for this.

First we need to find a node – this is what the testing framework calls an element in the ui tree.

Implementation details

Search can be done either by tag or by text. For convenience, the tag can be added to the modifier field. It is called testTag(). Not to be confused with the tag that we had in XML. This can only be used for tests. And after that we can check what information is in the field. The system of matchers and assertions helps us with this. These are classes into which you can pass a necessary condition and check whether it is satisfied. For example assertTestEquals(). Or assertIsDispayed(). There are quite a lot of matches, so I attached linkso as not to get confused.

assertTestEquals

As for the assertTestEquals() matcher, it checks the identity of the text. Moreover, both the hint and the entered text. To do this, we pass the hint and edit text parameters. This may be confusing at first, but that's how it works.

What cases are we testing?

So we wrote tests for all our cases:

We set the state with which the function is created

val cardNumber = "1234"
val hint = context.getString(R.string.card_number_label)
var state by mutableStateOf(
    AddPaymentState(cardNumber = cardNumber)
)

composeTestRule.setContent {
    AddPaymentScreen({
        when (it) {
            is AddPaymentAction.CardNumberEntered -> {
                state = state.copy(cardNumber = it.cardNumber)
            }

            else -> {}
        }
    }, state)
}

Initial state of the field

//check current cardNumber state
onNodeWithTag(CARD_NUMBER_TEST_TAG)
    .assertIsDisplayed()
onNodeWithTag(CARD_NUMBER_TEST_TAG)
    .assertTextEquals(hint, cardNumber, includeEditableText = true)

Status after entering NOT numbers

//strings are not allowed
onNodeWithTag(CARD_NUMBER_TEST_TAG).performTextInput("test")
onNodeWithTag(CARD_NUMBER_TEST_TAG)
    .assertTextEquals(hint, cardNumber, includeEditableText = true)

Status after entering numbers

//digits are allowed
val digitInput = "567855657787"
val digitWithSpacesInput = "5678 5565 7787"
onNodeWithTag(CARD_NUMBER_TEST_TAG).performTextInput(digitInput)
onNodeWithTag(CARD_NUMBER_TEST_TAG)
    .assertTextEquals(hint, "$cardNumber $digitWithSpacesInput", includeEditableText = true)

Status after input is greater than the maximum number of characters limit

//no more then 16 digits allowed
val moreInput = "5678"
onNodeWithTag(CARD_NUMBER_TEST_TAG).performTextInput(moreInput)
onNodeWithTag(CARD_NUMBER_TEST_TAG)
    .assertTextEquals(hint, "$cardNumber $digitWithSpacesInput", includeEditableText = true)

For convenience, different tests can be divided into separate functions, then they will not be completed after one fails.

What does an error look like in tests?

java.lang.AssertionError: Failed to assert the following: (Text + EditableText = [Номер карты,12341])
Semantics of the node:
Node #14 at (l=44.0, t=242.0, r=1036.0, b=462.0)px, Tag: 'cardNumber'
EditableText="1234 1"
TextSelectionRange="TextRange(0, 0)"
ImeAction = 'Default'
Focused = 'false'
Text="[Номер карты]"
Actions = [GetTextLayoutResult, SetText, InsertTextAtCursor, SetSelection, PerformImeAction, OnClick, OnLongClick, PasteText, RequestFocus, SetTextSubstitution, ShowTextSubstitution, ClearTextSubstitution]
MergeDescendants="true"
Has 7 siblings
Selector used: (TestTag = 'cardNumber')

As you can see, a fairly detailed description is given of where the test failed and what went wrong. And also the node itself that broke the tests.

What else can you test? Frequent cases

  • Clicking a button – we can both simulate a click and check whether it was made

  • Enabled/disabled state. For example, button state

  • Visibility – element visibility

  • and much more

Bottom line

That's it. As a result, we learned how to run instrumental tests for compose functions. It remains to add that this is a rather labor-intensive operation. Runs on the device. And running them with every ci build can be expensive. Therefore, you can set up a service that will run such tests, for example, once a day – at night. For example, a cloud service like AWS. Well, locally, if something goes wrong.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *