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 View
s. 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:
ConclusionThe key principle here is that we test only that
ListDetailLayout
emits the correct number of child components for a given screen width. We don’t test the behaviours specific toSplitLayout
andTwoPageLayout
on the UI emitted fromListDetailLayout
. We test those behaviours in isolation. That keeps our test easier to follow, but also documents the required behaviours of those two components.However, these tests do not test the navigation logic that is embedded in
ListDetailLayout
. The reason for this is that this logic varies slightly between the different UI patterns emitted. Therefore it makes sense to test this logic along with the separate layout types.In the concluding article in this series, we’ll look at how we can test the individual behaviours of
SplitLayout
andTwoPageLayout
.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.
I loved how you started with basics in your earlier blogs and finally sharing the testing part. Thanks