This section is equivalent to the one with "Login UI Test" from robolectric.instructionset.md.
Make sure to read that one first as this one only focuses on the differences that Compose brings.
We will write the same tests from AuthActivityInstrumentedTest
so that we see clearly the differences between the two.
We will apply the same Robot Pattern, since the concept applies exactly the same. The only thing that changes is the implementation details of the Robot class.
Here is a list of actions we want to do:
- we want to be able to type in the username
- we want to be able to type in the password
- we want to be able the username or password is indeed shows on the UI
- we want to be able to click on signin
- we want to be able verify if we are loading or not
- we want to verify if an error is shown or not
- we want to check if we navigated to Main or not
class ComposeLoginRobot(
semanticsNodeInteractionsProvider: SemanticsNodeInteractionsProvider,
) : SemanticsNodeInteractionsProvider by semanticsNodeInteractionsProvider {
fun setUsername(username: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.UsernameInput).performTextInput(username)
}
fun setPassword(password: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.PasswordInput).performTextInput(password)
}
fun assertPassword(password: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.PasswordVisibilityToggle).performClick()
onNodeWithTag(AuthScreenTag.PasswordInput).assertTextContains(password)
}
fun assertUsername(username: String): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.UsernameInput).assertTextContains(username)
}
fun clickOnLogin(): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoginButton).performClick()
}
fun assertLoading(): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoadingIndicator).assertIsDisplayed()
}
fun assertNotLoading(): ComposeLoginRobot = apply {
onAllNodesWithTag(AuthScreenTag.LoadingIndicator).assertCountEquals(0)
}
fun assertErrorIsShown(stringId: Int): ComposeLoginRobot = apply {
onNodeWithTag(AuthScreenTag.LoginError)
.assertTextContains(ApplicationProvider.getApplicationContext<Context>().resources.getString(stringId))
}
}
While in the View system we're using Espresso to interact with views,
in Compose we need a reference to the SemanticsNodeInteractionsProvider
that contains our UI,
which we will pass as a constructor parameter to the robot.
SemanticsNodeInteractionsProvider gives access to
onNode
actions. ComposeTestRule extends it.
To create a ComposeTestRule
you simply need to:
@get:Rule
val composeTestRule = createComposeRule()
Note: You need to add a debug dependency for the rule:
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")
Since we don't have view ids in Compose we need to search composables by tags, using for example onNodeWithTag
finder.
To add a tag to a composable use the testTag
modifier in your UI, for example:
Modifier.testTag(AuthScreenTag.UsernameInput)
Once we have a node we can take actions such as performClick()
or check assertions such as assertTextContains
.
For a list of finder, actions and assertions see the docs: https://developer.android.com/jetpack/compose/testing#testing-apis
If the navigation is also in compose we don't have an intent to check if we navigated. So instead, we're simply searching for regular composables that represent our destinations.
This means that we could write a robot for our navigation which will simply check whether the root Composable for destination exists:
fun assertHomeScreen(): ComposeNavigationRobot = apply {
composeTestRule.onNodeWithTag(AppNavigationTag.HomeScreen).assertExists()
}
fun assertAuthScreen(): ComposeNavigationRobot = apply {
composeTestRule.onNodeWithTag(AppNavigationTag.AuthScreen).assertExists()
}
Since everything in Compose is a composable, our Snackbar doesn't have anything special. Put a tag on it and use the same finders and assertions.
The setup is the mostly the same as for View so for the sake of simplicity let's focus on the differences.
We don't need an activity scenario. We will use instead createComposeRule()
which will handle the host activity.
If you need a specific activity, use createAndroidComposeRule<YourActivity>()
.
@get:Rule
val composeTestRule = createComposeRule()
@Before
fun setup() {
composeTestRule.setContent {
AppNavigation(isUserLogeInUseCase = IsUserLoggedInUseCase(FakeUserDataLocalStorage()))
}
// ...
}
In setContent
we can have any composable no matter how "small" or "big", it could be a single button or the whole app.
Here we are setting AppNavigation as the content, since the tests will be integration tests which will check navigation events.
Notice that we are injecting a fake local storage to control the logged in state.
For the robot we will use the compose implementation of it.
private lateinit var robot: ComposeLoginRobot
private lateinit var navigationRobot: ComposeNavigationRobot
@Before
fun setup() {
// ...
robot = ComposeLoginRobot(composeTestRule)
navigationRobot = ComposeNavigationRobot(composeTestRule)
}
Network synchronization and mocking is the same as for View.
private val mockServerScenarioSetupTestRule = MockServerScenarioSetupResetingTestRule(
networkSynchronizationTestRule = ComposeNetworkSynchronizationTestRule(composeTestRule)
)
private val mockServerScenarioSetup get() = mockServerScenarioSetupTestRule.mockServerScenarioSetup
ComposeNetworkSynchronizationTestRule is an equivalent to NetworkSynchronizationTestRule just registering the IdlingResource to ComposeTestRule instead of Espresso
Coroutine setup is the same, except for Dispatchers.setMain(dispatcher)
, which we don't need.
private val dispatcherTestRule = DatabaseDispatcherTestRule()
Setting the rules:
@Rule
@JvmField
val ruleOrder: RuleChain = RuleChain.outerRule(mockServerScenarioSetupTestRule)
.around(dispatcherTestRule)
With this setup our test should be pretty simple.
First we mock our request:
mockServerScenarioSetup.setScenario(
AuthScenario.Success(password = "alma", username = "banan")
)
Then we wait a bit, more precisely we wait for the app to navigate us correctly to AuthScreen since we're not logged in:
composeTestRule.mainClock.advanceTimeBy(600L)
We assert that we are indeed on the correct screen
navigationRobot.assertAuthScreen()
We insert the credentials into the input field:
robot.setPassword("alma")
.setUsername("banan")
.assertUsername("banan")
.assertPassword("alma")
Now thing are getting a little tricky. We want to click on login and assert that loading is displayed before navigating away. The problem is that, by the time the robot will look for the loading indicator, the app would have already be at the home screen. To slow things down we will disable clock autoAdvancing:
composeTestRule.mainClock.autoAdvance = false // Stop the clock
robot.clickOnLogin() // Click the button
composeTestRule.mainClock.advanceTimeByFrame() // Advance the clock by one frame
robot.assertLoading() // Assert the loading
composeTestRule.mainClock.autoAdvance = true // Let clock auto advance again
Lastly we check the navigation was correct, meaning we should be on the home screen:
navigationRobot.assertHomeScreen()
Note: Any node interactions call waitForIdle which waits for the Coroutine then the Network Call to finish. The Network call is running on OkHttps's own thread, so we use IdlingResources to synchronize with it. This is done in the ComposeNetworkSynchronizationTestRule. In ComposeNetworkSynchronizationTestRuleBasically since we have OkHttpIdlingResource as an EspressoIdlingResource we adapt that to Compose's IdlingResource class and register it with the ComposeTestRule and unregister it at the end.
Next up we verify what happens if the user doesn't set their password. We don't need a request in this case.
First we check that we are in the write place:
composeTestRule.mainClock.advanceTimeBy(600L)
navigationRobot.assertAuthScreen()
Then we set the username:
robot.setUsername("banan")
.assertUsername("banan")
.clickOnLogin()
Now, we will let the coroutine go and await the network call:
composeTestRule.mainClock.autoAdvance = false
composeTestRule.waitForIdle()
composeTestRule.mainClock.autoAdvance = true
This may seem a bit odd, but what we want is to be sure that while waiting for the request or any idling resource we do not advance the Compose Clock. That's because Snackbar has a LaunchedEffect to dismiss, if we let the clock run, it could sometime dismiss the Snackbar which could result in a Flaky test.
autoAdvance=off, waitForIdle(), autoAdvance=on pattern can be used to await external resources without affecting Compose Side Effects.
Finally we verify the error is shown and we have not navigated:
robot.assertErrorIsShown(R.string.password_is_invalid)
.assertNotLoading()
navigationRobot.assertAuthScreen()
This will be really similar as the previous test, so try to do it on your own. The error is R.string.username_is_invalid
Still, here is the complete code:
composeTestRule.mainClock.advanceTimeBy(600L)
navigationRobot.assertAuthScreen()
robot
.setPassword("banan")
.assertPassword("banan")
.clickOnLogin()
composeTestRule.mainClock.autoAdvance = false
composeTestRule.waitForIdle()
composeTestRule.mainClock.autoAdvance = true
robot.assertErrorIsShown(R.string.username_is_invalid)
.assertNotLoading()
navigationRobot.assertAuthScreen()
Now we verify network errors. First let's setup the response:
mockServerScenarioSetup.setScenario(
AuthScenario.InvalidCredentials(username = "alma", password = "banan")
)
Now input the credentials and fire the event:
composeTestRule.mainClock.advanceTimeBy(600L)
navigationRobot.assertAuthScreen()
robot.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
composeTestRule.mainClock.autoAdvance = false
robot.clickOnLogin()
composeTestRule.mainClock.advanceTimeByFrame()
robot.assertLoading()
composeTestRule.mainClock.autoAdvance = true
Note:
robot.assertLoading
since it is a node interaction already calls waitForIdle. We only advance the time by one frame to be able to verify the Loading, otherwise it would already disappear.
Now at the end verify the error is shown properly:
robot.assertErrorIsShown(R.string.credentials_invalid)
.assertNotLoading()
navigationRobot.assertAuthScreen()
Finally we verify the AuthScenario.GenericError
. This will be really similar as the previous, except the error will be R.string.something_went_wrong
.
You should try to do this on your own.
Here is the code for verification:
mockServerScenarioSetup.setScenario(
AuthScenario.GenericError(username = "alma", password = "banan")
)
composeTestRule.mainClock.advanceTimeBy(600L)
navigationRobot.assertAuthScreen()
robot.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
composeTestRule.mainClock.autoAdvance = false
robot.clickOnLogin()
composeTestRule.mainClock.advanceTimeByFrame()
robot.assertLoading()
composeTestRule.mainClock.autoAdvance = true
robot.assertErrorIsShown(R.string.something_went_wrong)
.assertNotLoading()
navigationRobot.assertAuthScreen()
Since we're writing apps for Android, we must handle state restoration so let's write a test for it.
For simulating the recreation of the UI, we first need a StateRestorationTester
:
private val stateRestorationTester = StateRestorationTester(composeTestRule)
Then in setup()
, we need to setContent
on stateRestorationTester
instead of on composeTestRule
.
Now for the actual test, we first setup the content then we trigger restoration by calling stateRestorationTester.emulateSavedInstanceStateRestore()
, afterwards we can verify that the content is recreated in the correct way:
Note: We also add the time advancement, to ensure no time based effect messes up anything!
composeTestRule.mainClock.advanceTimeBy(600L)
navigationRobot.assertAuthScreen()
robot.setUsername("alma")
.setPassword("banan")
.assertUsername("alma")
.assertPassword("banan")
stateRestorationTester.emulateSavedInstanceStateRestore()
composeTestRule.mainClock.advanceTimeBy(600L)
navigationRobot.assertAuthScreen()
robot.assertUsername("alma")
.assertPassword("banan")