Author
Gary Chang
Creating Android UI instrumented tests has traditionally been more involved than creating unit tests principally because of all the set up required to provide enough of a stubbed environment to allow a screen to launch successfully with its back-end dependencies satisfied (dependencies such as calling APIs and authentication for example). Testing Jetpack Compose screens continues this tradition as it is another type of instrumentation testing.
This blog gathers what I’ve learnt from years of writing Espresso and Robolectric tests together with the new UI testing framework for Jetpack Compose, going for a deep dive beyond the basics. I hope you’ll find these helpful in ultimately simplifying the creation of your Compose UI tests.
Introduction to Android UI Testing
If you’re just getting started with writing android tests then the following resources are a good way to understand the fundamentals of instrumentation testing (as compared to JVM-based unit testing):
These tests are called instrumented- or UI- or Android- or automation- tests that live in the androidTest folder and I’ll call them UiTests because it’s less typing!
With the fundamentals taken care of, I have found the points below useful in getting Jetpack Compose Android tests up and running efficiently. If you’re building with Compose it’s likely that you’ll be using the rest of the recommended ecosystem including Hilt, Kotlin Flow, ViewModel etc and I assume as much in this blog.
The Compose frameworks are still fairly new so you’ll see @OptIn(Experimental…) scattered throughout the code.
Wanted: simple looking @Tests
Let’s say hypothetically in the app we’re testing, we have a screen that shows a list of offices. Selecting one of those offices allows a second screen to show the details for that office. In this blog we will focus on android tests that checks that these two screens in particular operate as expected. I’m starting with the end result of what we’re aiming for – the tests themselves. Below is OfficesTest.kt Examining the code it looks like that after some setup test boilerplate, we prepare a mock response for the API call required by the screen, then launch our Offices composeable, and finally verify that the list of offices renders correctly. Replace every occurrence of com.example with the package name of your app.
// OfficesTest.kt package com.example.ui.offices import androidx.compose.ui.test.junit4.createAndroidComposeRule import com.example.AndroidTestActivity import com.example.ui.offices. import com.example.test.BaseUiTest import com.example.test.pressButton import com.example.test.pressButtonWithContentDescription import com.example.test.replaceText import com.example.test.sleep import com.example.test.textDoesNotExist import com.example.test.textIsDisplayed import com.example.test.textIsDisplayedAtLeastOnce import com.example.test.textIsNotDisplayed import com.example.test.waitForProgressIndicatorToEnd import com.example.test.waitForText import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Before import org.junit.Rule import org.junit.Test @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @HiltAndroidTest class OfficesTest : BaseUiTest() { @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule() @Before fun setUp() { hiltRule.inject() } @Test fun officesRenderAsExpectedWhenApiIsSuccessful() { addOfficesResponse() with(composeTestRule) { setContent { OfficesScreen() // viewModel is injected by hiltRule } waitForText("Offices") // wait for screen to load because compose doesn't have idling resources textIsDisplayed("Melbourne") } } }
Another test we might need is to be able to select an individual office from the previous list, and verify that we can see all the details for that selected office. This can be implemented as a partial integration test ie. launch OfficeDetails composeable from Offices screen; or as a separate OfficeDetailsTest.kt. We’ll do the former to save on setup for the blog and simply add it as a second test to OfficesTest.kt but in your actual tests consider keeping a one-to-one correspondence between screens and their tests so OfficeDetails.kt would have its own OfficeDetailsTest.kt and a simple integration test that verifies the navigation from Offices to OfficeDetails. Testing navigation does require a bit more setup which will be covered in more detail below.
// Add this below the first @Test in OfficesTest.kt @Test fun navigatesToDetailsWhenAnOfficeIsSelectedFromTheList() { addOfficesResponse() addOfficeDetailResponse() with(composeTestRule) { setContent { OfficesScreen() // viewModel is injected by hiltRule } waitForText("Offices") // wait for screen to load from offices API call textIsDisplayed("Sydney") pressButton("Sydney") waitForText("Sydney office hours") // wait for screen to load from office details API call textIsDisplayed("8:30 - 17:30 Mon-Fri") } }
.. but you may be thinking: the above code looks too simple to work. It doesn’t look anything like the examples from the official documentation eg. shouldn’t each line of compose test code – look more like the following?
composeTestRule.onNodeWithText("Sydney").performClick() composeTestRule.waitUntilAtLeastOneExists(hasText("Sydney"), timeoutMillis = 1000) composeTestRule.onNodeWithText("8:30 - 17:30 Mon-Fri").assertIsDisplayed()
The truth is that the code in the rest of this blog does effectively the same thing as that under the hood. We will now reveal all the helper code that allowed OfficesTest.kt to look so simple. There’s a lot of reusable code moved out to other files under the androidTest folder - just like there’s a lot of paddling done by a swan underwater to make it look graceful above water!
Let’s now go and set up your existing android project so you can write Compose tests.
gradle setup - importing test libraries
These are the testing library dependencies required by the code examples in this blog in version catalog format:
// app/build.gradle.kts androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.junit) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.espresso.core) androidTestImplementation(libs.androidx.test.espresso.intents) androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.compose.ui.test) androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.hilt.android.testing) androidTestImplementation(libs.androidx.navigation.testing) androidTestImplementation(libs.androidx.test.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.testJunit) androidTestImplementation(libs.turbine) androidTestImplementation(libs.mock.webserver) kaptAndroidTest(libs.hilt.compiler)
# libs.versions.toml [versions] # please use the latest versions of these libraries as needed and suggested by Android Studio androidxEspresso = "3.5.1" androidxNavigationTesting = "2.5.3" androidxTest = "1.5.0" androidxTestExtJunit = "1.1.5" hilt="2.45" kotlinCoroutinesTest = "1.7.2" mockWebserver = "4.9.3" testRunner = "1.5.2" turbine = "0.12.1" [libraries] androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } androidx-compose-ui-testManifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-compose-ui-testJunit = { module = "androidx.compose.ui:ui-test-junit4" } androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "androidxNavigationTesting"} androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTest" } androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxEspresso" } androidx-test-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "androidxEspresso" } androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExtJunit" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "testRunner" } androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTest" } hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinCoroutinesTest"} mock-webserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "mockWebserver"} turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
Most libraries listed above are straightforward as to their test purposes but the following are worth a mention:
turbine: a great way to test Kotlin Flows more often seen in unit tests.
mockWebserver: a must-have library for working with Retrofit (or other networking library) that mocks responses in the http layer. Since this is in effect a mini web server that runs inside an app or test app (instrumented test), you can test your Retrofit interceptors in an integrated way if you so choose eg. auth headers added correctly, HTTP unauthorised detection with automatic retry after refreshing the access token.
Dependency injection of mocks with Hilt
Unless you’re writing integration tests against pre-production or production environments, you’ll likely want to mock or stub out the back end services like API calls to the network, authentication or other sources of data. Dependency Injection (DI) and in particular with Dagger-Hilt, simplifies this.
In the ongoing discussion of mock vs stub, I consider these dependency replacements to be more like stubs but the main library we’re using is called MockWebServer rather than StubWebServer hence the fence sitting nomenclature used in this blog.
If you’re not using DI, you can still test composeables in standalone activities due to the standard pattern of injecting ViewModels into the composeables so you could manually inject mock VMs in each test, but I don’t have a solution to switch out mock backends in hybrid (XML layout mixed with Compose) activities ie. testing composeables that are intimately tied to its parent activity.
Thus the rest of this blog assumes you’re using DI.
Create a Dagger module that replaces an actual production module with a test mock / stub version. The example below replaces a production Retrofit networking module with one that points at a local in-app MockWebserver (see section below for more details) so all REST API / GraphQL / web page calls are serviced by the MockWebserver instead of a real back-end http endpoint. In this way we can finely control the back-end responses such as success / failure / other edge cases in order to shake out all the variations of a screen we’re wanting to test.
To avoid confusion, I follow a naming convention where all test DI modules have the “UiTest” prefix of the production names eg. production module is named NetworkModule.kt. Instrumentation test replacement version is named UiTestNetworkModule.kt.
The key to this mock version of the module is the @TestInstallIn annotation with its replaces parameter informing Hilt which real DI module is to be substituted out with our mock module. Note the mock version of the module can provide both real and mock classes. This minimises the amount of mock code you need to implement for test purposes.
// UiTestNetworkModule.kt package com.example.di import com.example.data.network.RetrofitNetworkApi import com.example.data.network.call.ResultCallAdapterFactory import com.example.test.UiTestWebServer import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import dagger.Module import dagger.Provides import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import javax.inject.Singleton @Module @TestInstallIn( components = [SingletonComponent::class], replaces = [NetworkModule::class] // This is the original prod Dagger module we're replacing ) object UiTestNetworkModule { @Provides @Singleton fun provideJson(): Json = Json { ignoreUnknownKeys = true } @Provides @Singleton fun provideOkHttpCallFactory(): OkHttpClient = OkHttpClient.Builder() .addInterceptor( HttpLoggingInterceptor().apply { setLevel(HttpLoggingInterceptor.Level.BODY) // verbose but helps debug UiTests } ) .build() @Provides @Singleton fun provideRetrofitNetworkApi( json: Json, okHttpClient: OkHttpClient, ): RetrofitNetworkApi { return Retrofit.Builder() .baseUrl(BaseUiTest.BASE_URL) // points to MockWebServer started by your UiTest .client(okHttpClient) .addConverterFactory( @OptIn(ExperimentalSerializationApi::class) json.asConverterFactory("application/json".toMediaType()) ) .addCallAdapterFactory(ResultCallAdapterFactory.create(json)) // optional - in case you have any custom marshalling requirements .build() .create(RetrofitNetworkApi::class.java) // your retrofit definitions } }
Create UiTest Dagger modules for every other external (data) dependency you need to mock in order to test your screens — such as mocking authentication for login / logout, databases, content providers etc.
Make the test implementations singleton objects for easy manipulation by the test to dynamically alter the dependency’s behaviour as required by the test. eg. set up an authentication dependency so the screen can start in logged out mode, then log in and test that the screen transitions to a logged in state correctly; whilst a second test in the same suite can start the authentication dependency in a logged in state so you can test the logout functionality. Manipulate this singleton object by having setters / instance variables that are purely for test purposes only.
Example: say that UiTestAuthenticationImpl is a singleton injected by UiTestAuthenticationModule. Then in your test code as part of test setup: UiTestAuthenticationImpl.isLoggedIn = false for the first test above and = true for the second test.
DI injection into the test (as opposed to injection into the app being tested) is also supported, so you can use the injected dependency to alter its behaviour for a test too, however this could be more limited because often you need to adjust some internal state of a dependency that cannot be altered easily through their public interfaces eg. setting the authentication dependency to the logged in state also implies that it will be able to supply mock access and refresh auth tokens which are not normally easily manipulated from an authentication library (with good reason!)
Custom AndroidTest runner
For Hilt to do its magic injecting real and mock dependencies seamlessly into the screen code under test and into your instrumented UiTests, you’ll need to override the default custom android test runner. Create the following file in your androidTest folder eg. at the top of your app’s package name eg. com/example/MyAppAndroidTestRunner.kt
// MyAppAndroidTestRunner.kt package com.example import android.app.Application import android.content.Context import androidx.test.runner.AndroidJUnitRunner import dagger.hilt.android.testing.HiltTestApplication class MyAppAndroidTestRunner : AndroidJUnitRunner() { override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { return super.newApplication(cl, HiltTestApplication::class.java.name, context) } }
and replace the existing runner declaration with this one:
// app/build.gradle.kts android { .. defaultConfig { .. testInstrumentationRunner = "com.example.MyAppAndroidTestRunner" } }
Wrapping test commands into helper functions
I always try to follow the principle of ABMTC (pronounced a-bum-tec 😂)
Always Be Moving Test Code (especially for repeated test code) to other test helper files and base test classes with the aim of keeping the @Test annotated files as simple as possible, ideally readable by a non-dev. This includes moving complicated test data setup out to other files too. The end effect hopefully is that a casual glance of any @Test code should allow a maintenance / quality engineer to quickly assess how good test coverage is for a feature.
Consider that we’re always refactoring production source so that we don’t end up with huge chunks of repeated code, instead moving that common code into a reusable function . Why not do the same for test code too and make it all as beautifully elegant as prod code? 🤔
I prefer to use extension functions where possible and keep code in base test classes for state variables eg. MockWebServer instance as we need it to control the responses that will change for each test we write.
Now we’re ready to reveal all that helper code. By the way that’s the reason for all those imports in OfficesTest.kt – they are what helps keep the @Tests looking nice and simple!
Note: any file that starts with officeXX is sample code just for this blog and can be discarded in your project. All other files in this blog are generic and reusable setup test code in your project.
BaseUiTest.kt contains an instance of the MockWebServer together with automatic startup and shutdown of this server. It also delegates the adding of responses to UiTestRequestDispatcher.
// BaseUiTest.kt package com.example.test import okhttp3.mockwebserver.MockWebServer import org.junit.AfterClass import org.junit.BeforeClass import java.net.HttpURLConnection open class BaseUiTest { companion object { const val MOCK_SERVER_PORT = 47777 val BASE_URL = "http://localhost:$MOCK_SERVER_PORT" protected val dispatcher = UiTestRequestDispatcher(UiTestUtils.testContext) protected var webServer: MockWebServer? = null @BeforeClass @JvmStatic fun startMockServer() { if (webServer == null) { println("Mock Web Server starting") webServer = MockWebServer() webServer!!.start(MOCK_SERVER_PORT) webServer!!.dispatcher = dispatcher } } @AfterClass @JvmStatic fun shutDownServer() { webServer?.shutdown() webServer = null } } fun addResponse( pathPattern: String, filename: String, httpMethod: String = "GET", status: Int = HttpURLConnection.HTTP_OK ) = dispatcher.addResponse(pathPattern, filename, httpMethod, status) fun addResponse( pathPattern: String, requestHandler: MockRequestHandler, httpMethod: String = "GET", ) = dispatcher.addResponse(pathPattern, requestHandler, httpMethod) }
UiTestRequestDispatcher contains the code that provides the generic mock response handling. In this case we only have to handle json responses but there is nothing to stop the MockWebServer dispatcher servicing GraphQL, html or even binary requests. Note that there are two variants of addResponse:
- simple addResponse(pathPattern, filename, httpMethod, status) - for a given incoming request pathPattern regex eg. “offices” and httpMethod eg. “GET” return the contents of json file having the name filename.
- complex addResponse(pathPattern, requestHandler, httpMethod) - for a given incoming request pathPattern eg. “office/.*” regex and httpMethod eg. “GET” return the results generated by the given requestHandler function. This function is given full access to the incoming request so the request’s path eg. “/office/1234”, a possible POST body, and all request headers are available to be interrogated to customise the base response (as read from the file) as this function sees fit, providing great flexibility as opposed to having hundreds of canned json responses with small data variations between them.
// UiTestRequestDispatcher.kt package com.example.test import android.content.Context import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.RecordedRequest import java.net.HttpURLConnection typealias MockRequestHandler = (request: RecordedRequest) -> MockResponse class UiTestRequestDispatcher(private val context: Context) : Dispatcher() { private val simpleResponses = mutableMapOf() private val complexResponses = mutableMapOf() fun addResponse( pathPattern: String, filename: String, httpMethod: String = "GET", status: Int = HttpURLConnection.HTTP_OK ) { val response = mockResponse(UiTestFileUtils.readFile(context, filename), status) val responseKey = "$httpMethod/$pathPattern" // adding the http method into the key allows for a repeated pathPattern // that is used by both GET and POST to behave differently for eg. if (simpleResponses[responseKey] != null) { simpleResponses.replace(responseKey, response) } else { simpleResponses[responseKey] = response } } fun addResponse( pathPattern: String, requestHandler: MockRequestHandler, httpMethod: String = "GET", ) { val responseKey = "$httpMethod/$pathPattern" if (complexResponses[responseKey] != null) { complexResponses.replace(responseKey, requestHandler) } else { complexResponses[responseKey] = requestHandler } } override fun dispatch(request: RecordedRequest): MockResponse { println("Incoming request: $request") Thread.sleep(200) // provide a small delay to better mimic real life network call across a mobile network val responseKey = request.method + request.path var response = findComplexResponse(responseKey, request) if (response == null) { response = findSimpleResponse(responseKey) } if (response == null) { println("no response found for $responseKey") response = errorResponse(responseKey) } return response } private fun findComplexResponse(responseKey: String, request: RecordedRequest): MockResponse? { for (pathPattern in complexResponses.keys) { if (responseKey.matches(Regex(pathPattern))) { val handler = complexResponses[pathPattern] if (handler != null) { return handler(request) } } } return null } private fun findSimpleResponse(responseKey: String): MockResponse? { for (pathPattern in simpleResponses.keys) { if (responseKey.matches(Regex(pathPattern))) { val response = simpleResponses[pathPattern] if (response != null) { return response } } } return null } private fun errorResponse(reason: String): MockResponse { return mockResponse("""{"error":"response not found for "$reason"}""", HttpURLConnection.HTTP_INTERNAL_ERROR) } }
UiTestFileUtils contains the json file reader. Place all stub json files in:
- androidTest/assets folder if you initialise UiTestRequestDispatcher(UiTestUtils.testContext)
- debug/assets folder if you initialise UiTestRequestDispatcher(UiTestUtils.context)
(more explanation of context vs testContext in section below)
package com.example.test import android.content.Context import java.io.BufferedReader object UiTestFileUtils { fun readFile(context: Context, filename: String): String { return try { val bufferedReader = context.assets.open(filename).bufferedReader() bufferedReader.use(BufferedReader::readText) // autocloses to prevent resource leaks } catch (e: Exception) { println("Error reading UiTestFile: $filename: $e") "" } } }
UiTestUtils consists mostly of the stateless helpers - extension functions to ComposeTestRule Note that I have only added extension functions for a few of the available function. You will likely need to add a lot more eg. scroll up and down, etc. Refer to the documentation for ComposeTestRule. It has a very comprehensive set of compose test commands that you’ll want to provide helpers for in this file to keep your @Tests looking simple.
package com.example.test import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.test.platform.app.InstrumentationRegistry import okhttp3.mockwebserver.MockResponse import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import java.net.HttpURLConnection object UiTestUtils { // discussed later val context = InstrumentationRegistry.getInstrumentation().context val testContext = InstrumentationRegistry.getInstrumentation().context } fun mockResponse(responseBody: String, status: Int = HttpURLConnection.HTTP_OK) = MockResponse() .setResponseCode(status) .setBody(responseBody) @OptIn(ExperimentalTestApi::class) fun ComposeTestRule.waitForText( text: String, timeoutMillis: Long = 5000 ) { waitUntilAtLeastOneExists(hasText(text), timeoutMillis = timeoutMillis) } @OptIn(ExperimentalTestApi::class) fun ComposeTestRule.sleep( timeoutMillis: Long ) { @Suppress("SwallowedException") try { waitUntilAtLeastOneExists(hasText("NeverFound!"), timeoutMillis = timeoutMillis) } catch (t: Throwable) { // swallow this exception } } fun ComposeTestRule.textIsDisplayed( text: String, expectedOccurrences: Int = 1 ) { if (expectedOccurrences == 1) { onNodeWithText(text).assertIsDisplayed() } else { assertEquals(onAllNodesWithText(text).fetchSemanticsNodes().size, expectedOccurrences) } } fun ComposeTestRule.textDoesNotExist( text: String ) { onNodeWithText(text).assertDoesNotExist() } fun ComposeTestRule.textIsDisplayedAtLeastOnce( text: String, minOccurences: Int = 1 ) { assertTrue(this.onAllNodesWithText(text).fetchSemanticsNodes().size >= minOccurences) } fun ComposeTestRule.pressButton(text: String) { onNodeWithText(text).performClick() } fun ComposeTestRule.replaceText(inputLabel: String, text: String) { onNodeWithText(inputLabel).performTextClearance() onNodeWithText(inputLabel).performTextInput(text) } // This is just a small sample of the types of extensions // to add as ComposeTestRule contains many operations you // might want to wrap here
offices.json This content will simply be returned unchanged by addOfficesResponse from OfficeTestHelper.kt
{ "meta": { }, "data": [ { "id": "1234", "address": "452 Flinders Street, Melbourne, Victoria 3000, Australia", "postcode": "3000", "country": "Australia", "name": "Melbourne" }, { "id": "5678", "address": "580 George Street, Sydney, New South Wales 2000, Australia", "postcode": "2000", "country": "Australia", "name": "Sydney" }, { "id": "9112", "address": "11 Customs Street West, Commercial Bay, Auckland 1010, New Zealand", "postcode": "0600", "country": "New Zealand", "name": "Auckland" } ], "errors": [] }
officeDetail.json contains templated content eg. <OFFICE_ID> for the currently selected office. See addOfficeDetailResponse from OfficeTestHelper.kt for how this is processed.
{ "meta": { }, "data": [ { "id": "", "capacity": "", "hoursOfOperation": "8:30 - 17:30 Mon-Fri", "contact": "", "officeStartDate": "2010-07-05" } ], "errors": [] }
OfficeUiTestHelper.kt Note the complexity in the processing of the office/.* request.
package com.example.ui.office import com.example.test.BaseUiTest import com.example.test.UiTestFileUtils import com.example.test.UiTestUtils import com.example.test.mockResponse import okhttp3.ResponseBody.Companion.asResponseBody import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.RecordedRequest fun BaseUiTest.addOfficesResponse() { addResponse("offices", "offices.json") } fun BaseUiTest.addOfficeDetailResponse() { addResponse("office/.*", requestHandler = ::officeDetailHandler) } val officeIdRegex = Regex("office/(.*)") // Example of a complex response handler fun officeDetailHandler(request: RecordedRequest): MockResponse { // note that you have full access to the request parameters whether it be: // - the path eg. val path = request.path ?: "" // - POST body eg. val requestBody = request.body.asResponseBody().string() // - request headers eg. val authHeader = request.headers.get("Authorization") val path = request.path ?: "" var responseBody = UiTestFileUtils.readFile( UiTestUtils.textContext, "officeDetail.json" ) // determine which office detail to return by reading the office ID // from the end the path val match = officeIdRegex.find(path) if (match != null) { val officeId = match.value if (officeId = "1234") { responseBody = responseBody.replace("", officeId) responseBody = responseBody.replace("", "150") responseBody = responseBody.replace("", "Pam Beesly") } else { .. substitute template with data for the other offices .. or could we add ChatGPT into handling this response? 😂 } return mockResponse(responseBody) } return mockResponse("""{"error":"couldn't find officeId"}""", HttpURLConnection.HTTP_INTERNAL_ERROR) }
Compose test started via an empty activity
If there’s any hilt injection required by the screen being tested, then your composeTestRule can’t be the simple createComposeRule / createAndroidComposeRule<ComponentActivity> as the injection will fail at runtime. Instead you need to create an empty subclass of ComponentActivity that has the required Hilt annotation so it can inject correctly. (Notice this being used in OfficesTest.kt above)
package com.example import androidx.activity.ComponentActivity import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import dagger.hilt.android.AndroidEntryPoint @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @AndroidEntryPoint class AndroidTestActivity : ComponentActivity()
Compose test started via its containing activity
If you want to test a hybrid activity eg. MainActivity that transitions from a traditional XML layout world into a compose screen, then you want to start that activity itself rather than an empty boilerplate activity. You may also have an activity that mixes composeables with XML layout. This is all supported but there are some differences in the way you launch the activity to be tested. As detailed here you can mix Compose Test and Espresso Test code, depending on what framework was used to layout the screen
// MainActivityTest.kt @OptIn(ExperimentalTestApi::class) @HiltAndroidTest class MainActivityTest : BaseUiTest() { @get:Rule var hiltRule = HiltAndroidRule(this) @get:Rule var activityScenarioRule = lazyActivityScenarioRule(launchActivity = false) @Suppress("UNCHECKED_CAST") @get:Rule val composeTestRule = createEmptyComposeRule() as AndroidComposeTestRule @get:Rule val intentsTestRule = IntentsRule() @Test fun testMyAppLaunch() { // prepare mock server responses here and any other dependency // like mock authentication before launch activityScenarioRule.launch() // launches the app hiltRule.inject() // this is the reason why we need the delayed launch // Note that with the empty compose rule, you don't set content // as it assumes that content was created in some other manner // in this case via the activity. with(composeTestRule) { waitForText("Welcome to Offices") pressButton("Show help") // this button goes to an XML layout } // do some Espresso testing here }
Testing navigation
When testing Compose navigation we need a TestNavHostController, so you would add into your test file (in my example OfficesTest.kt) the following code:
// add to imports import androidx.navigation.NavHostController import androidx.navigation.compose.ComposeNavigator import androidx.navigation.compose.NavHost import androidx.navigation.testing.TestNavHostController import androidx.test.core.app.ApplicationProvider // above setUp() var navController: TestNavHostController // add to setUp() navController = TestNavHostController(ApplicationProvider.getApplicationContext()) navController.navigatorProvider.addNavigator(ComposeNavigator()) // inside your @Test with(composeTestRule) { setContent { NavHost( navController = navController, startDestination = "offices", ) { // the composeable you want to test that has navigation // in our case OfficesNavigation which declares // navigation destinations OfficesScreen and OfficesDetailScreen } } // continue with your test as before but now including // actions like pressing a button to initiate // navigation to the other destinations within your // navigation graph }
Delayed launching of an activity
In MainActivityTest.kt above I used lazyActivityScenarioRule from here (thanks to Piotr Zawadzki!) which allows an activity’s launch to be delayed. This delay is needed because we need to inject with Mock DI modules — a non-delayed launch would have the production modules injected before the @Test could start. We also need to delay the launch until the MockWebServer has been prepared for all the stub responses and been started.
Also check out Piotr’s blog about ActivityScenario.
Getting access to both contexts
In the UITestDispatcher.kt code above, we need access to the test context in order to read files from the AndroidTest/assets folder for the json responses. There may also be a need to peek at the actual app under test eg. sneakily verify or tweak some value in the app (naughty but sometimes you just need that implementation to be able to get a test to work). Luckily both are easily available via the InstrumentationRegistry:
// from UiTestUtils.kt object UiTestUtils { val context = InstrumentationRegistry.getInstrumentation().targetContext val testContext = InstrumentationRegistry.getInstrumentation().context }
I put both into the UiTestUtils object so it’s clear in the test code what you’re referring to.
protected var dispatcher = UiTestDispatcher(UiTestUtils.testContext)
I also swapped the names around so context still means the real app to be tested and testContext is the test app containing the @Tests and access to any assets under androidTest/assets folder.
Thread.sleep freezes the screen!
With Espresso tests, we could simply use the “dirty” workaround of Thread.sleep() when waiting for idling resources wasn’t sufficient eg. there was a custom animation that didn’t honour the dev settings to disable animations. This workaround was also super useful during development of a test, as you could simply sleep(100000) — ie. for a very long time — in order to pause the test at any point to determine what’s going on before adding more test conditions. This sleep would still allow the screen to complete (re-)rendering including any custom animations or transitions eg. a row in a RecyclerView appearing.
With Compose tests, Thread.sleep() now freezes the screen because by default ComposeTestRule is operates synchronously. Only commands executed on ComposeTestRule would allow the screen to continue changing, or manually advancing the clock which I didn’t want to do either. Lastly, whilst idling resources are available for Compose, there isn’t one yet for Retrofit and I found that the provided waitUntilXX test functions sufficient for reliable and quicker testing than Espresso’s idling resources.
In short I couldn’t live without my sleep in a Compose test world, so the workaround to provide similar functionality is:
composeTestRule.waitUntilAtLeastOneExists(hasText("NeverFound!"), timeoutMillis = 5000)
Now I can continue with a non-frozen-screen sleep in a post-Espresso world simply by wrapping this in helper functions in UiTestUtils.kt
// from UiTestUtils.kt @OptIn(ExperimentalTestApi::class) fun ComposeTestRule.sleep( timeoutMillis: Long ) { @Suppress("SwallowedException") try { waitUntilAtLeastOneExists(hasText("NeverFound!"), timeoutMillis = timeoutMillis) } catch (t: Throwable) { // swallow this exception } }
This draws to a conclusion the details I have for you to consider in order to get instrumented tests working with Compose. Importantly they look clean and simple.
The next section discusses an in-app stub server to be used when manually interacting with your app and not running inside a test.
Stub server environment
Consider your app running against an in-app stub server so that it is fully self contained yet it is able to visit every screen including error screens with no external dependencies?
You’ve now done all the hard work of capturing canned json responses for all your API calls for your UI tests. Why not put them to good re-use and create a stub server environment using MockWebServer and a Dispatcher that combines all of the stub responses required by the whole app? It’s a relatively small extra bit of work compared to what has been described above. The idea is that a development version of the app you’re building can have several API environments to select from eg:
- In-app stub server
- Non-prod environment
- Production environment
The complex response handling based on incoming request data as well as internal stub server state is key to what allows a fairly simple in-app stub to behave in an almost intelligent way for the purposes of allowing every screen of the app to be visited.
In order to achieve this you’ll also need DI modules that can read the selected environment at run time from a shared preference or content provider and be able to switch out the implementation classes / object of the interface providers in addition to launching the MockWebBrowser very early in app startup — before the first http request and before the first check to see whether the app is in a logged in / logged out state.
I’ve added a stub server to almost every client project I’ve worked on and there are multiple benefits:
- a manual tester / developer can see how the app behaves under hard to reproduce corner cases like API errors in-the-real-world that are beyond simple scenarios like turning on airplane mode eg. you have a sequence of API calls and you have a UI design that is different depending on which API call failed. That’s easy to reproduce with stubs.
- the app team doesn’t have to wait for an API to be built and deployed. With an agreed API contract the app can be built independently using stub data allowing for the possibility of quicker overall implementation time for a feature across app / API teams.
- training of internal staff earlier using stubbed data before the APIs are completed and tested.
- stakeholders getting to interact with an app’s design earlier using stubbed data so they can provide feedback and further refinement as the app is effectively an interactive prototype.
A refinement on the stub server concept is integrating mock authentication with the stub server responses. Here you could carry the stub user’s login into mock access tokens, then the stub server would obtain the user via the auth header eg. scanning for Authorization: Bearer StubUser2 and using StubUser2 to control which type of responses to send back. In this way, on some client projects we were able to write instrumented tests without having to add any responses in the UiTest code as the app would start with the built in stub server, and we controlled what we wanted to test just by setting the stub user to the appropriate value (via mock login UI or mock authentication dependency singleton state injection as detailed above).
Conclusion
With the above tips and tricks I hope you can enjoy creating Compose tests quickly and increasing the ongoing quality of your app in an automated way!