Compose / Jetpack / Testing

Compose: List / Detail – Testing part 2

In a recent post on Styling Android we looked SlidingPanelLayout. This can simplify the implementation of a List / Detail UI. It handles the logic of whether to show a side-by-side layout or a two-page layout depending on the screen size. Currently, there is no equivalent for this in Jetpack Compose. In this article, we’ll see how Compose makes this relatively easy.

Previously we looked at how we could create tests that ListDetailLayout was emitting the correct UI based on given device display capabilities. However, we have not yet created tests for the behaviours of the two distinct UI patterns.

Creating SplitLayout and TwoPageLayout as discrete components means that we can test them in isolation. This is easier still because we kept them stateless. The state is in the arguments for each composable function. We can. therefore, create different states.

Common Utils

The first thing we’ll do is add some more utility functions to TestUtils:





SplitLayout

SplitLayout emits a side-by-side list / detail UI. We can test this behaviour in isolation without having to use its parent ListDetailLayout. This demonstrates the value of small, focused components.

class SplitLayoutTest {
    @get:Rule
    val composeTestRule = createAndroidComposeRule()

    @Test
    fun givenASplitLayout_whenWeClickEachItem_thenOnlyThatItemDetailIsDisplayed() {
        composeTestRule.setContent {
            TestUi(widthDp = 600)
        }

        val clickableItems = list.toItemsMap(hasClickAction())

        for ((name, action) in clickableItems) {
            action.performClick()
            val detailItems = list.toItemsMap(hasNoClickAction())
            detailItems.isOnlyItem(name)
        }
    }

    private fun List.toItemsMap(matcher: SemanticsMatcher) =
        map {
            it to composeTestRule.onNode(hasText(it) and matcher)
        }.toMap()

    private fun Map.isOnlyItem(text: String) {
        for (item in this) {
            if (item.key == text) {
                item.value.assertExists()
            } else {
                item.value.assertDoesNotExist()
            }
        }
    }
}

This clicks on each item in the list and asserts that the relevant detail is displayed for each. Once again we make use of the utility functions that we created earlier.

Both the list item and its detail have identical text. Despite this, we can determine which is which by checking its click action. If it has a click action it much be a list item. If it doesn’t it must be a detail item.

This is essentially running a separate test for each item in the list.

There is a valid argument that this test is not well designed. It actually performs 10 separate click actions, and 100 assertions in a single test function. If there is a failure it will make it harder to understand the exact circumstances of the failure. However, it does provide comprehensive testing of SplitLayout. Breaking this down into individual tests would result in a much larger test suite. I will leave it to the reader to decide whether this strategy is good for them.

Personally, I can accept this test because I feel that it does verify the expected behaviour quite comprehensively. Although, it lacks fluency. The following tests are much more fluent, I think.

TwoPageLayout

Finally, we have TwoPageLayout which has separate screens for the list and detail UIs.

class TwoPageLayoutTest {

    private val width = 300

    @get:Rule
    val composeTestRule = createAndroidComposeRule()


    @Test
    fun givenATwoPageLayout_whenItIsInitiallyDisplayed_thenItExists() {
        composeTestRule.setContent {
            TestUi(width)
        }

        composeTestRule.onList().assertExists()
    }

    @Test
    fun givenATwoPageLayout_whenItIsInitiallyDisplayed_thenItContainsTenChildren() {
        composeTestRule.setContent {
            TestUi(width)
        }

        composeTestRule.onList().onChildren().assertCountEquals(list.size)
    }

    @Test
    fun givenATwoPageLayout_whenAnItemIsClicked_thenItNoLongerExists() {
        composeTestRule.setContent {
            TestUi(width)
        }

        composeTestRule.onClickableTextItem("Item 1").performClick()

        composeTestRule.onList().assertDoesNotExist()
    }

    @Test
    fun givenATwoPageLayout_whenAnItemIsClicked_thenOnlyTheCorrectItemIsDisplayed() {
        composeTestRule.setContent {
            TestUi(width)
        }

        val listItem = list[3]
        val otherListItem = list[0]

        composeTestRule.onClickableTextItem(listItem).performClick()

        composeTestRule.onStaticTextItem(listItem).assertExists()
        composeTestRule.onStaticTextItem(otherListItem).assertDoesNotExist()
    }

    @ExperimentalTestApi
    @Test
    fun givenATwoPageLayout_whenAnItemIsClickedAndBackPressed_thenTheListExists() {
        composeTestRule.setContent {
            TestUi(width)
        }

        val listItem = list[3]

        composeTestRule.onList().assertExists()
        composeTestRule.onList().onChildren().assertCountEquals(10)

        composeTestRule.onClickableTextItem(listItem).performClick()
        Espresso.pressBack()

        composeTestRule.onList().assertExists()
    }
}

There are various tests here. As before, we make use of the utility functions to construct the UI under test. Each test is for a different aspect of the expected behaviour. I have added some simple node lookup methods which make the individual tests more readable. Being able to read and understand test code means that the expected behaviour of the component under test is clear.

The first verifies that the list shows initially. The second verifies that the list is no longer showing after an item is clicked. The third verifies that the correct detail is displayed. Finally, we have a check that hitting back on a detail UI returns to the list UI.

The final test is quite interesting because it shows that we can mix and match Compose testing with Espresso. Here we use Espresso to trigger a “Back” event.

Conclusion

Testing composables is relatively easy, but we still need to design them for testability. Keeping them stateless through state hoisting helps this enormously. As does keeping them small and focused.

As mentioned in the previous article, the back behaviour test has also been added to ListDetalLayoutTest to verify the navigation logic that it implements internally.

Testing will improve further when we can run our Compose tests as unit tests. I have tended to omit tests from my sample code project in the past because doing them for Views is time consuming. Also getting them running on my CI server is also a problem. But that’s changing, so you can expect to see my Compose sample code backed by UI tests.

The source code for this article is available here.

© 2021, Mark Allison. All rights reserved.

Copyright © 2021 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

Leave a Reply

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.