This is the first of three articles about Jetpack Compose UI Testing that I will be publishing in the next few weeks. Within these articles, I will explain some of the available UI Testing options to apply in production projects. I will begin each article with an introduction into some basic/general aspects, from the most practical perspective.
First of all, let me present a very concise overview without being too repetitive (there is a lot of information on the main Android site, if you wish to check all of the general details).
- Jetpack Compose uses semantics to give meaning to a piece of UI, apart from that, it’s primarily used for accessibility.
- Compose is, at its core, a general-purpose tool for managing a tree of nodes of any type.The nodes don’t have to be UI related at all. The tree could exist at the presenter layer as view state objects, at the data layer as model objects, or simply be a value tree of pure data.
- To place a node in the composition tree, it uses ComposeNode function, a @Composable function.
- Compose is declarative and as such the only way to update it is by calling the same composable with new arguments. These arguments are representations of the UI state. Any time a state is updated a recomposition takes place.
Compose UI Tests using Testing in Isolation 🔬
One of the biggest advantages of using Jetpack Compose is that it allows you to start an activity displaying any composable: your full application, a single screen, or a small element.
- It allows you to check that composables are correctly encapsulated and they work independently, allowing for easier and more focused UI testing.
- Abstracting small chunks of UI and test them under any edge case. It gives a high level of trust and a sign of reusability across different screens.
You might have defined, in your EntryPointActivity, something like this:
setContent { | |
SpaceXTheme { | |
val bottomNavigationItems = listOf( | |
BottomNavigationScreens.Dashboard, | |
BottomNavigationScreens.Launches | |
) | |
val navController = rememberNavController() | |
Scaffold( | |
bottomBar = { | |
BottomNavigation(navController, bottomNavigationItems) | |
} | |
) { | |
NavHost(navController, startDestination = BottomNavigationScreens.Dashboard.route) { | |
composable(BottomNavigationScreens.Dashboard.route) { | |
InitDashboardScreen() | |
} | |
composable(BottomNavigationScreens.Launches.route) { | |
InitLaunchesScreen() | |
} | |
} | |
} | |
} |
As you can see, there’s a BottomNavigation (with two navigation items) and therefore two tabs: DashboardScreen and LaunchesScreen. But you may not want to test the whole UI structure defined in the Activity. Instead, you can simply test one of the tabs directly (specific screen):
@Test | |
@InternalCoroutinesApi | |
fun elementsVisibilityAfterTwoItemsRetrieved() { | |
composeTestRule.apply { | |
setContent { | |
SpaceXTheme { | |
LaunchesScreen( | |
state = LaunchesContract.State( | |
listOf( | |
LaunchUiModel( | |
"Mission1", "08-12-2021", true, "0", RocketUiModel( | |
"Rocket1", "Rocket Type1" | |
), LinksUiModel("", "", "Youtube Link") | |
), | |
LaunchUiModel( | |
"Mission2", "09-12-2021", false, "0", RocketUiModel( | |
"Rocket2", "Rocket Type2" | |
), LinksUiModel("", "WikiPedia Link", "Youtube Link") | |
) | |
), isLoading = false, isError = false | |
), | |
effectFlow = flow { emit(LaunchesContract.Effect.ClickableLink.None) } ) | |
} | |
} | |
onNodeWithText("Mission1", useUnmergedTree = true) | |
.assertIsDisplayed() | |
onNodeWithText("Mission2", useUnmergedTree = true) | |
.assertIsDisplayed() | |
onAllNodesWithContentDescription( | |
"Item", | |
substring = true, | |
useUnmergedTree = true | |
).assertCountEquals(numItemsShowed) | |
} | |
} |
The test is focused on one screen,
rather than in the whole Activity content
LaunchesScreen is the one set in the content (by setContent) and the only UI State specified is the one for that screen too.
Progressively it’s possible to specify smaller parts of the UI and test all of them in isolation to check any specific case.
Compose UI Tests using MockWebServer ⚡️
MockWebServer is a library from Square that allows you to specify which responses to return and then verify that requests were made as expected.
Advantages
- Tests using MockWebServer can coexist with the ones using Testing in Isolation.
- It’s easy to configure and handful to allocate the expected responses in a separate folder in the project. Therefore the tests keep concise and clean.
- Easy to emulate error responses without making any changes in the server.
- It’s possible to simulate a slow network.
Configuration
- Add the next dependency into the build.gradle (app level)
testImplementation("com.squareup.okhttp3:mockwebserver:4.9.3")
2. Define a MockTestRunner and link it through your build.gradle (app)
android { | |
defaultConfig { | |
applicationId = "prieto.fernando.sample" | |
testInstrumentationRunner = "prieto.fernando.sample.webmock.MockTestRunner" | |
} | |
buildFeatures { | |
compose = true | |
viewBinding = true | |
} | |
// rest of configuration | |
} |
MockTestRunner needs to use a TestApplication class, which is conveniently provided by Dagger Hilt:
class MockTestRunner : AndroidJUnitRunner() { | |
override fun onCreate(arguments: Bundle?) { | |
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().permitAll().build()) | |
super.onCreate(arguments) | |
} | |
override fun newApplication( | |
cl: ClassLoader?, | |
className: String?, | |
context: Context? | |
): Application { | |
return super.newApplication(cl, HiltTestApplication::class.java.name, context) | |
} | |
} |
Job Offers
3. Create a FakeNetworkModule to replace the original one (NetworkModule) in the tests configuration.
Let’s define our baseUrl in a way that can be overridden in the fake module:
@InstallIn(SingletonComponent::class) | |
@Module | |
open class NetworkModule { | |
open fun getBaseUrl () ="https://api.spacexdata.com/v3/" | |
@Provides | |
@BaseUrl | |
fun provideBaseUrl() = getBaseUrl () | |
// The rest of Builders, Factories, etc. are omitted for simplicity | |
} |
Hilt provides a very intuitive and simple solution to replace modules, for testing purposes. By simply pointing to the target module that’s being overridden and selecting the local host that MockWebServer needs to point to, it’s possible to get this network setup done. The Port can be 8080 defined in the build.gradle file.
@Module | |
@TestInstallIn( | |
components = [SingletonComponent::class], | |
replaces = [NetworkModule::class] | |
) | |
class FakeNetworkModule : NetworkModule() { | |
override fun getBaseUrl() = "http://127.0.0.1:${BuildConfig.PORT}" | |
} |
4. Define your own Dispatchers.
By default MockWebServer uses a queue to specify a series of responses. Use a Dispatcher to handle requests using another policy. One natural policy is to dispatch on the request path. You can, for example, filter the request instead of using server.enqueue()
.
A common practice is to define a SuccessDispatcher and an ErrorDispatcher. Those two classes will deliver the specified responses in each case (success & error) and will be obtained from the path indicated.
Once the path to the expected responses is specified (JSON files), MockWebServer will be able to parse the content and offer it automatically in your tests.
Responses files defined within the project(debug directory, same level as androidTest)
5. Initialise MockWebServer in your tests.
val mockWebServer by lazy { MockWebServer() } | |
@Before | |
fun setUp() { | |
mockWebServer.start(BuildConfig.PORT) | |
} | |
@After | |
fun teardown() { | |
mockWebServer.shutdown() | |
} |
6. Start testing using MockWebServer.
@ExperimentalMaterialApi | |
@RunWith(AndroidJUnit4::class) | |
@HiltAndroidTest | |
class LaunchesScreenKtTest : BaseScreenTest() { | |
@Test | |
@InternalCoroutinesApi | |
fun visibleItemsCountAfterOpeningTheScreen() { | |
mockWebServer.dispatcher = SuccessDispatcher() | |
setMainContent() | |
composeTestRule.apply{ | |
onNodeWithText("Launches").assertIsDisplayed().performClick() | |
mainClock.advanceTimeBy(2000) | |
onAllNodesWithContentDescription( | |
"Item", | |
substring = true, | |
useUnmergedTree = true | |
).assertCountEquals(6) | |
} | |
} | |
} |
If you need to check more details and implement the whole configuration I have put in place, make sure you check my Github example SpaceX prepare for Clean Architecture liftoff🚀.