Compose / Jetpack / Testing

Compose: List / Detail – Testing part 1

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.

At the end of the previous post, I stated the alignment of the split to the fold was working correctly. My confidence in that was based on test results. Jetpack Compose makes testing much easier than it was using Views. In this post, we’ll explore some testing strategies, and discuss how we can design our composables for testability.

At the time of writing, tests for composables must be done with Android instrumented tests. These run on an Android device or emulator. However, Jose Alcérreca recently tweeted that it will soon be possible to run Compose unit tests using Robolectric. Moreover, with Compose for desktop, it may be possible to generate a compose UI without needing an Android device although, the tooling does not yet exist to do this. Either way, we’ll be able to create unit tests for composables, rather than instrumented tests. These will run much faster, and make running them on CI servers much easier.

Strategies for testability

One of the fundamental principles that can help testability is keeping our composables small and of focused behaviour. Back in the first article in this series I explained that small focused composables were good for maintainability. But this is also true for testability. It’s possible to create separate test suites for each of DynamicLayout, SplitLayout, and TwoPageLayout. They can be tested in isolation because of this.

Another important principle is to inject values that affect the state of the UI emitted by the composable as arguments. Consider the following:

@Composable
fun OrientationComposable() {
    val configuration = LocalConfiguration.current
    if (configuration.orientation == ORIENTATION_LANDSCAPE) {
        // Do something
    } else {
        // Do something else
    }
}

This looks to per a perfectly valid use-case, but will be difficult to test as we cannot easily control the configuration state. However, hoisting the configuration up to the caller rectifies this:

@Composable
fun OrientationComposable(configuration: Configuration) {
    if (configuration.orientation == ORIENTATION_LANDSCAPE) {
        // Do something
    } else {
        // Do something else
    }
}

We can now create different configuration states to test the behaviour. We’ll see this principle in action shortly.

I’m not going to give an in-depth tutorial on how to write tests for Compose. The official documentation does a good job of explaining the basics.

Common Utils

We’ll start by creating some common utility functions which will do much of the heavy lifting. This will make our tests more succinct and easier to follow and understand later on:

internal val list = (1..10).map { "Item $it" }

@Composable
internal fun TestUi(widthDp: Int, foldBounds: Rect? = null) =
    ComposeListDetailTheme {
        ListDetailLayout(
            list = list,
            configuration = Configuration().apply {
                smallestScreenWidthDp = widthDp
                screenWidthDp = widthDp
            },
            windowLayoutInfo = createWindowLayoutInfo(foldBounds)
        ) {
            CreateUi()
        }
    }

@Composable
internal fun TwoPaneScope.CreateUi() {
    List { list, onSelectionChange ->
        LazyColumn {
            for (entry in list) {
                item {
                    Row(
                        Modifier
                            .fillMaxWidth()
                            .clickable { onSelectionChange(entry) }
                            .padding(horizontal = 16.dp, vertical = 8.dp)
                    ) {
                        Text(text = entry)
                    }
                }
            }
        }
    }
    Detail { selection ->
        Text(selection)
    }
}

internal fun createWindowLayoutInfo(foldBounds: Rect?) =
    WindowLayoutInfo.Builder().apply {
        if (foldBounds != null) {
            setDisplayFeatures(
                listOf(
                    FoldingFeature(
                        bounds = foldBounds,
                        type = FoldingFeature.TYPE_FOLD,
                        state = FoldingFeature.STATE_FLAT
                    )
                )
            )
        }
    }.build()

We first create a list of items for our list UI. Then we declare a typealias and some node lookup functions that will help make our test code more fluent.

The TestUI() function will be used throughout our test suite, and creates a UI for a given display width, and (optional) fold position. This will be the workhouse for our tests as we’ll see shortly.

The TwoPaneScope.CreateUi() function is a composable which constructs a basic list / detail UI in to a TwoPaneScope instance. This will be used throughout all of our tests.

The createWindowLayoutInfo() function is a simple factory for a WindowLayoutInfo instance based on optional fold bounds. This will be used for testing where ListDetailLayout positions the split when a fold exists.

ListDetailLayout

ListDetailLayout is responsible for emitting with a TwoPageLayout or SplitLayout depending on the width of the display. ListDetailLayout uses the device Configuration to determine this. By using the technique of hoisting this state out of the composable we can control this in our tests:

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.

1 Comment

  1. I loved how you started with basics in your earlier blogs and finally sharing the testing part. Thanks

Leave a Reply to Jake Clarke Cancel 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.