Blog

Capturing Dialog Screenshots in Jetpack Compose UI Testing

One of the most imports parts of the software development lifecycle at mx51 is testing. We have unit tests, UI tests and integration tests, all working together to make sure our Android app (known as the MXA – Merchant eXperience App) is robust.

Not only do all these tests remove some of the tedius checks that a QA engineer might need to perform often, but they also give confidence to the Android developers when refactoring code, that post-refactoring no regressions have been introduced.

Our MXA is written in 100% Kotlin code and uses Jetpack Compose UI for all the screens, and with our UI testing we’ve long been capturing screenshots so that we can see the state of a test at different points of the test execution. However, the code we were using would fail to take a screenshot if there was an AlertDialog being shown.

The Old Code

Our screenshots are named for each UI test they’re being run for, each invocation of this method within a test will capture a screenshot and number it accordingly, so that in the final report they’re all grouped together for easy evaluation.

private val screenshotCounter = mutableMapOf<String, Int>()

fun ComposeTestRule.takeScreenshot(testName: String) {
    val screenshotName = testName.lowercase()
    val count = screenshotCounter[screenshotName] ?: 1

    try {
        onRoot()
            .captureToImage()
            .asAndroidBitmap()
            .writeToTestStorage("$screenshotName-$count")
    } catch (e: AssertionError) {}

    screenshotCounter[screenshotName] = count + 1
}

However, the onRoot() extension function fails with an AssertionError if it detects more than one root node, which is the case when an AlertDialog is being shown. When this occurs we fail to capture a screenshot, and so in a recent sprint we decided to investigate and fix this issue.

A Sample UI Test

Here is an example of a simple UI test and how we capture screenshots at different points during its execution. We do this so that if the test fails, we get a better understanding of what was being shown on screen at the time of failure.

@get:Rule
val composeTestRule = createComposeRule()

@get:Rule
val nameRule = TestName()

@Test
fun `SplitEvenlyScreen_cannot_enter_less_than_2_splits`(): Unit =
    with(composeTestRule) {
        setContent {
            SplitEvenlyScreen(
                purchaseTotal = 1000,
                onNextClicked = { },
                onBackClicked = { },
                onCloseClicked = { },
                onTimeout = { }
            )
        }
        onNodeWithTag("splits_text_field").performTextReplacement("1")
        takeScreenshot(nameRule.methodName)

        onNodeWithTag("next_button").performClick()
        takeScreenshot(nameRule.methodName)
        onNodeWithText("Split number must be 2 or greater").assertIsDisplayed()
    }

With the old code above, we only saw one screenshot being captured for this test. We know this UI test passes, but without the second screenshot we can’t validate that assertion.

Test Screenshot 1
A UI test screenshot

The New Code

Here is the updated code, we capture the root node of the compose hierarchy and any potential dialogs beings shown. If there are any dialogs then we merge the root node bitmap with the dialog bitmap to recreate the screen as it appeared on the emulator.

private val screenshotCounter = mutableMapOf<String, Int>()

fun ComposeTestRule.takeScreenshot(testName: String) {
    val screenshotName = testName.lowercase()
    val count = screenshotCounter[screenshotName] ?: 1

    val rootNodes = onAllNodes(isRoot())
    val dialogNodes = onAllNodes(isDialog())

    mergeNodesIntoBitmap(
        rootNodes,
        dialogNodes
    ).writeToTestStorage("$screenshotName-$count")

    screenshotCounter[screenshotName] = count + 1
}

private fun mergeNodesIntoBitmap(
    rootNodes: SemanticsNodeInteractionCollection,
    dialogNodes: SemanticsNodeInteractionCollection
): Bitmap {
    // There will always be a root node.
    val rootBitmap = rootNodes[0].captureToImage().asAndroidBitmap()

    return try {
        // If there is a dialog being shown then grab the bitmap.
        val dialogBitmap = dialogNodes[0].captureToImage().asAndroidBitmap()

        // If no AssertionError was thrown above, then merge the dialog and root bitmaps.
        Bitmap.createBitmap(
            rootBitmap.width,
            rootBitmap.height,
            Bitmap.Config.ARGB_8888
        ).apply {
            Canvas(this).apply {
                // Draw main root of hierarchy.
                drawBitmap(rootBitmap, 0f, 0f, null)

                // Draw scrim behind the dialog.
                drawARGB(125, 0, 0, 0)

                // Make sure we draw dialog in correct position.
                val left = (rootBitmap.width - dialogBitmap.width) / 2f
                val top = (rootBitmap.height - dialogBitmap.height) / 2f
                drawBitmap(dialogBitmap, left, top, null)
            }
        }
    } catch (ae: AssertionError) {
        // There are no dialogs, so just return the root node.
        rootBitmap
    }
}

Unfortunately, the SemanticsNodeInteractionCollection class doesn’t have a size method or length attribute, meaning that we can’t query ahead of time the size of the collection of dialogs. We, therefore, have to rely on an AssertionError being thrown if a dialog doesn’t exist. This isn’t ideal, but works well enough in practice.

Epilogue

The results speak for themselves, we’re now able to see the captured dialog in our test reports, making it a lot easier to validate our test cases.

Test Screenshot 1 Test Screenshot2
A UI test screenshot A UI test screenshot showing an AlertDialog

This code change has improved the quality of our UI test diagnosis when things go wrong, we hope it can help you too. If you’ve solved this in a different way or have improvements you think we can make, we’d love to hear from you.