Writing UI Tests for Pager Layouts in Jetpack Compose

Writing UI tests for pager layouts can be problematic in Compose. In this post, I'll share my experience and solutions when dealing with such screens.

Recently, I was tasked with creating a Pager screen using Compose. Because the screen had a lot of edge cases in it, I also wanted to write UI tests alongside the screen to ensure all features are covered. In this post, I'll share my experience on testing such screens.

All the working code samples can be found here on the GitHub repository testing-compose-pager.

Here's a brief overview on what will be touched in this article:

  • The semantic node tree for pager layouts
  • Swiping between screens
  • Selecting nodes only from a specific pager layout page
  • Selecting off-screen tabs

The app under test

The main screen just allows navigating to the two pager screens (It will not be tested)

The static pager screen has 3 predefined pages, the summary page has two buttons that allow changing the selected page. The other two pages just contain a simple text.

The dynamic pager screen allows for increasing and decreasing the pager layout page count. Every page has more or less the same content.

The Pager library used for this article comes from the Accompanist Utils library for Jetpack Compose.

The Static Pager Screen

Here's a simplified structure of the static pager screen composable, the full source code is available on the GitHub repository.

enum class StaticPagerScreenPage {
    Summary,
    Info,
    Details,
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun StaticPagerScreen() {
    val pages = remember {
        listOf(
            StaticPagerScreenPage.Summary,
            StaticPagerScreenPage.Info,
            StaticPagerScreenPage.Details,
        )
    }
    Column {
        TabRow() {
            ...
        }
        HorizontalPager() { index ->
            val page = pages[index]
            Box() {
                ....
            }
        }
    }
}

The Static Pager Tests

The static pager screen is easier to test because every page has unique content. Meaning that the testing framework won't be confused when selecting nodes (The next and previous pages don't interfere).

Set up

@Before
fun setUp() {
    composeTestRule.setContent {
        TestingComposePagerTheme {
            StaticPagerScreen()
        }
    }
}

Pager semantic node tree

Before showing the tests, I want to explain how compose interprets the pager content in its semantic node tree.

By default, the pager works by "placing" the current, previous and next page in the tree. This means that at the same time, three pages can exist in the semantic node tree. Here's a simplified tree when opening the static pager screen:

|-Node #5 at (l=0.0, t=303.0, r=1080.0, b=2148.0)px
   CollectionInfo = 'androidx.compose.ui.semantics.CollectionInfo@bf668a0'
   Actions = [IndexForKey, ScrollBy, ScrollToIndex]
    |-Node #18 at (l=0.0, t=303.0, r=1080.0, b=2148.0)px
    |  |-Node #19 at (l=241.0, t=675.0, r=840.0, b=768.0)px
    |  | Text = '[The Summary page]'
    |  | Actions = [GetTextLayoutResult]
    |-Node #26 at (l=0.0, t=303.0, r=1080.0, b=2148.0)px
       |-Node #27 at (l=0.0, t=303.0, r=662.0, b=396.0)px
         Text = '[The Information page]'
         Actions = [GetTextLayoutResult]

The Node #18 and #26 represent the content of the first and second page. In tests, both of these pages are accessible, but only the first one is displayed:

@Test
fun theSecondPageIsInTheNodeTree() {
    composeTestRule.onNodeWithText("The Summary page").assertIsDisplayed()
    composeTestRule.onNodeWithText("The Information page").assertIsNotDisplayed()
}

The third page however does not exist in the node tree:

@Test
fun theThirdPageDoesNotExistInTheNodeTree() {
    composeTestRule.onNodeWithText("The Details page").assertDoesNotExist()
}

This behavior of rendering sibling pages can be problematic when dealing with pages that have content which is not unique between pages. The dynamic pager screen section goes into details on how to differentiate between these types of pages in tests.

Swiping between pages

Swiping horizontally between content is one of the most recognizable features of pagers. Fortunately, with compose, swiping gestures are really easy to perform:

@Test
fun swipingLeftOnRootTwoTimesOpensTheDetailsPage() {
    composeTestRule.onRoot().performTouchInput {
        swipeLeft()
        swipeLeft()
    }

    composeTestRule.onNodeWithText("Details").assertIsSelected()
    composeTestRule.onNodeWithText("The Details page").assertIsDisplayed()
}

The test verification ensures that the Details tab is selected, and that the details page content is displayed after swiping two times to the left.

Performing swiping onRoot works because swipes are performed in the middle of the node (which, in this case, the whole screen):

val height: Int get() = visibleSize.height

val centerY: Float get() = height / 2f

fun TouchInjectionScope.swipeLeft(
    startX: Float = right,
    endX: Float = left,
    durationMillis: Long = 200
) {
    ...
    val start = Offset(startX, centerY)
    val end = Offset(endX, centerY)
    swipe(start, end, durationMillis)
}

Changing pages by tapping the tab

Another popular way of changing pages is by tapping their corresponding tab.

@Test
fun clickingInfoTabOpensTheInfoPage() {
    composeTestRule.onNodeWithText("Info").performClick()

    composeTestRule.onNodeWithText("Info").assertIsSelected()
    composeTestRule.onNodeWithText("The Information page").assertIsDisplayed()
}

Changing pages by tapping the summary page button

There might be also additional ways of changing pages.

@Test
fun clickingGoToInfoOpensTheInfoPage() {
    composeTestRule.onNodeWithText("Go to info").performClick()

    composeTestRule.onNodeWithText("Info").assertIsSelected()
    composeTestRule.onNodeWithText("The Information page").assertIsDisplayed()
}

Static pager summary

Because all static pages have unique content, all nodes be accessed like in normal composable screens i.e. using onNodeWithText, onNodeWithTag, onNodeWithContentDescription.

The full static test source code is available on GitHub and here are the tests in action:

The Dynamic Pager Screen

This screen is more involved because the number of pages can change, and their content is not unique (The increase and decrease buttons).

Here's a simplified structure of the dynamic pager screen composable, the full source code is available on the GitHub repository.

@OptIn(ExperimentalPagerApi::class)
@Composable
fun DynamicPagerScreen() {
    var pageCount by remember { mutableStateOf(1) }
    val pages = remember(pageCount) { List(pageCount) { "Page $it" } }
    Column {
        ScrollableTabRow() {
           ...
        }
        HorizontalPager(
            count = pages.size,
            state = pagerState,
        ) { index ->
            val page = pages[index]
            Box() {
                ...
            }
        }
    }
}

In order to make the tests easier to write, some test tags can be added (a content description can also be used instead):

Column {
    ScrollableTabRow(modifier = Modifier.testTag("dynamic-pager-tab-row")) { ... }
    HorizontalPager() { index ->
        val page = pages[index]
        Box(modifier = Modifier.testTag("dynamic-pager-$index")) { ... }
    }
}

The tags can also be extracted to a companion object, to make maintenance easier:

object TestTagsDynamicPagerScreen {

    private const val pager = "dynamic-pager"
    const val tabRow = "dynamic-pager-tab-row"

    fun getPageTag(index: Int) = "$pager-$index"
}

...

Column {
    ScrollableTabRow(modifier = Modifier.testTag(TestTagsDynamicPagerScreen.tabRow)) { ... }
    HorizontalPager() { index ->
        val page = pages[index]
        Box(modifier = Modifier.testTag(TestTagsDynamicPagerScreen.getPageTag(index))) { ... }
    }
}

The Dynamic Pager Tests

Set up

@Before
fun setUp() {
    composeTestRule.setContent {
        TestingComposePagerTheme {
            DynamicPagerScreen()
        }
    }
}

Dealing with repeating page content

Because every page has the same two buttons, the tests will need to specify which page needs to be used.

For example, on entering, the dynamic pager screen only has one page available. This is the reason is why the following snipped of code works:

@Test
fun worksIfOnlyOnePageExists() {
    composeTestRule.onNodeWithText("Increase").performClick()
}

But when there are two pages (both have the Increase button) the testing framework gets confused:

@Test
fun crashesOnSecondClick() {
    composeTestRule.onNodeWithText("Increase").performClick()
    composeTestRule.onNodeWithText("Increase").performClick()
}
Reason: Expected exactly '1' node but found '2' nodes that satisfy: (Text + EditableText contains 'Increase' (ignoreCase: false))

To get around this, the selector needs to know on which screen should the button be clicked:

@Test
fun doesNotCrash() {
    composeTestRule.onPagerButton(pageIndex = 0, text = "Increase").performClick()
    composeTestRule.onPagerButton(pageIndex = 0, text = "Increase").performClick()
}

private fun ComposeContentTestRule.onPagerButton(
    pageIndex: Int,
    text: String
): SemanticsNodeInteraction {
    val isOnTheCorrectPage =
        hasAnyAncestor(hasTestTag(TestTagsDynamicPagerScreen.getPageTag(pageIndex)))
    return onAllNodesWithText(text)
        .filterToOne(isOnTheCorrectPage)
}

The basic idea is, that all Increase buttons are selected (using onAllNodesWithText) and then they are filtered using a matcher. The matcher works as follows: "give me a node that has an ancestor that has a test tag with the requested page index."

In the example above, the Increase button is clicked only on the first page (index == 0).

Checking if tabs are created

The main feature of the dynamic screen is that the number of tabs can be changed on demand.

@Test
fun increaseClickedOnceTimesThenTabCountIncreases() {
    composeTestRule.onPagerButton(pageIndex = 0, text = "Increase").performClick()

    composeTestRule.onNodeWithText("Page 0").assertIsDisplayed()
    composeTestRule.onNodeWithText("Page 1").assertIsDisplayed()
}

Checking off-screen tabs

When there are too many tabs, they will be added off-screen. In order to access them, the tab row will need to be scrolled horizontally.

@Test
fun increaseClickedMultipleTimesThenTabCountIncreases() {
    composeTestRule.onPagerButton(pageIndex = 0, text = "Increase").performClick()
    composeTestRule.onPagerButton(pageIndex = 0, text = "Increase").performClick()
    composeTestRule.onPagerButton(pageIndex = 0, text = "Increase").performClick()

    composeTestRule.onNodeWithTag(TestTagsDynamicPagerScreen.tabRow)
        .performTouchInput {
            swipeLeft()
        }
    composeTestRule.onNodeWithText("Page 1").assertIsDisplayed()
    composeTestRule.onNodeWithText("Page 2").assertIsDisplayed()
    composeTestRule.onNodeWithText("Page 3").assertIsDisplayed()
}

Clicking increase on a different page

The increase and decrease buttons are available on every page, and they should behave in the same way regardless of the page.

@Test
fun increaseOnSecondTabClickedThenTabCountIncreases() {
    composeTestRule.onPagerButton(pageIndex = 0, text = "Increase").performClick()
    composeTestRule.onNodeWithText("Page 1").performClick()

    composeTestRule.onPagerButton(pageIndex = 1, text = "Increase").performClick()

    composeTestRule.onNodeWithText("Page 0").assertIsDisplayed()
    composeTestRule.onNodeWithText("Page 1").assertIsDisplayed()
    composeTestRule.onNodeWithText("Page 2").assertIsDisplayed()
}

Swiping on page content

Instead of performing the swiping onRoot it can also be performed on one particular page.

@Test
fun swipingLeftOnPageOpensTheCorrectPage() {
    composeTestRule.onPagerButton(pageIndex = 0, text = "Increase").performClick()
    composeTestRule.onPagerButton(pageIndex = 0, text = "Increase").performClick()

    composeTestRule.swipeOnPagerScreen(pageIndex = 0)
    composeTestRule.swipeOnPagerScreen(pageIndex = 1)

    composeTestRule.onNodeWithText("Page 2").assertIsSelected()
    composeTestRule.onNodeWithText("On page: 2").assertIsDisplayed()
}

private fun ComposeContentTestRule.swipeOnPagerScreen(
    pageIndex: Int
) {
    onNodeWithTag(TestTagsDynamicPagerScreen.getPageTag(pageIndex))
        .performTouchInput {
            swipeLeft()
        }
}

Dynamic pager summary

Repeating content on pages can be problematic, but compose offers a some-what elegant way of selecting content only on one particular page.

The full dynamic test source code is available on GitHub and below you can see the tests in action:

Testing screens with network or database data

This article dived deep into the Compose framework using simple examples, but what about real use cases where we need to fetch something from the API, or when we need some database rows?

These exact topics are covered in some of my other articles:

Using Ktor Client MockEngine for Integration and UI Tests
MockEngine replaces real network calls with mocked ones that use pre-defined data and status codes. The engine can be shared between Integration and UI tests.
Kotlin Multiplatform In-Memory SQLDelight Database for Integration and UI Testing on iOS and Android
Databases are an integral part of mobile application development, so it’s important that these features are properly tested.
Using Apollo Kotlin Data Builders for Testing
Data Builders provide an easy way for creating GraphQL response classes. In this post I’ll show how they can be used to improve your tests.

Side-note: Normally when writing UI Tests it's good to use some form of pattern, like the Robot pattern:

Testing Robots – Jake Wharton
Libraries like Espresso allow UI tests to have stable interactions with your app, but without discipline these tests can become hard to manage and require frequent updating. This talk will cover how the so-called robot pattern allows you to create stable, readable, and maintainable tests with the ai…

If you have a different way of testing pager screens in compose, feel free to share them below in the comment section.

You've successfully subscribed to AKJAW
Great! Now you have full access to all members content.
Error! Could not sign up. invalid link.
Welcome back! You've successfully signed in.
Error! Could not sign in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.