Introduction
Nowadays, design is an essential aspect of many applications. We have a competitive market, and design is an important criterion for a User. This is why more and more companies/developers pay enormous attention to animations.
Usually, developers don't test animation because verifying the behavior is more critical for them or because of the limitation of testing tools. For example, one of the requirements for testing with Espresso is "disabling animations".
The "Compose UI Test" framework allows us to test animations in composable functions using screenshot testing because the "Compose UI Test" framework uses a virtual clock in the tests. If animations are an important part of our application and you want to ensure that some animations always work correctly, you can test them with screenshot tests.
Screenshot Testing is a technique that captures a screenshot of a UI element or whole screen and compares the result with an expected image.
In this article, we will explore:
- the possibilities of the "Compose UI Test" framework in terms of testing animations
- using the "Shot" framework for testing animations in composable functions
- the possibilities of the "virtual clock" mechanism of the framework
- testing an animation with screenshot testing.
For simplicity, we will create an animation for a "favourite" component and test this animation.
The "Compose UI Test" Framework
The "Compose UI Test" framework allows us to test "composable functions". Jetpack Compose is a technology created from scratch and doesn't use the View
s. As a result, we cannot use a framework that can analyze a ViewTree
and verify/interact with View
s. Instead of the ViewTree
, we can use a NodeTree
. The "Compose UI Test" framework knows how to interact with composable functions.
If you are already familiar with the basics of the "Compose UI Test" framework, feel free to skip the "First test case" section and move to the "Synchronization".
First Test Case
Let's create a first test case before speaking about "synchronization" and "virtual clock" from the "Compose UI Test" framework.
Let's imagine that we have a "subscription container" where we can enter an email. When a user enters an email and presses the "SUBSCRIBE" button, the "You successfully subscribed" message is displayed.
You can find the implementation of SubscriptionContainer
here.
Let's create a test case that emulates this scenario:
- Enter an email address
- Click on the "SUBSCRIBE" button
- Verify that the "You successfully subscribed" message is displayed
class SubscriptionContainerTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun shouldDisplaySnackbar_whenEmailIsEnteredAndSubscribeButtonClicked() {
composeTestRule.apply {
setContent { SubscriptionContainer() }
onNode(hasTestTag("subscribe_email"))
.performTextInput("test@test.com")
onNode(hasText("SUBSCRIBE"))
.performClick()
onNode(hasText("You successfully subscribed"))
.assertIsDisplayed()
}
}
}
- The
createComposeRule
is a function that creates aComposeContentTestRule
. We can use this rule to test composable functions in isolation. In addition to thecreateComposeRule
function, we have a few other options: - The
createAndroidComposeRule
creates anAndroidComposeTestRule
for a specificActivity
. - The
createEmptyComposeRule
creates anAndroidComposeTestRule
without a compose host. It can be helpful if you want to create your own compose host for the test case(s). - The
hasTestTag("subscribe_email")
allows us to find an element for interaction/verification with the specific "testTag". It's not the best way to find an element because, with this approach, we will have additional code in production, which is needed only for testing. I'm working on an article describing the best ways to find elements. However, you can find some examples here. - The
onNode(...)
find a node based onSemanticMatcher
. TheonNode(hasText("SUBSCRIBE"))
finds a node that has "SUBSCRIBE" text.
You can read more about this framework here.
Synchronization
The "synchronization" in the UI tests context means that verification or performing any operation will happen when UI is finished rendering. Without synchronization, we should wait until the UI component is "ready" for testing, when the application finishes opening another screen, dialog, etc.
The "Compose UI Test" framework allows us to enable and disable synchronization.
// Enable synchronization
composeTestRule.mainClock.autoAdvance = true
// Disable synchronization
composeTestRule.mainClock.autoAdvance = false
When you perform an asynchronous operation (fetching data from the back-end, working with files, etc.), the synchronization won't wait out of the box because it's an asynchronous task.
We can use theIdlingResource
mechanism to wait until we perform an asynchronous operation.
The "Compose UI Test" framework uses a virtual clock, and we can move to a specific point in time inside the test case. It helps us test animation with screenshot testing because we can always do screenshots at the right time.
To advance a virtual clock, we can use one of the following functions:
- The
advanceTimeBy(milliseconds: Long, ignoreFrameDuration: Boolean = false)
advances the clock by the given duration in milliseconds. - The
advanceTimeUntil(timeoutMillis: Long = 1_000, condition: () -> Boolean)
advances the clock until the given condition is satisfied.
In one of the following sections, we will explore how to test the following animation.
The "Shot" Library
The "Shot" library allows us to run screenshot tests on Android. We can take screenshots of the View
, Fragment
, Activity
, or composable function. All test cases are executed on devices or emulators. We should remember to always use the same device because of screen ratio, screen resolution, and API version. However, you can easily migrate from one device to another by re-generating screenshots on another device.
Let's create a test case that compares screenshots for the "SubscriptionContainer" composable function.
You can find the implementation of SubscriptionContainer
here.
class SubscribeBoxTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun shouldDisplaySnackbar_whenEmailIsEnteredAndSubscribeClicked() {
composeTestRule.apply {
setContent { SubscribeBox() }
onNode(hasTestTag("subscribe_email"))
.performTextInput("test@test.com")
onNode(hasText("SUBSCRIBE"))
.performClick()
onNode(hasText("You successfully subscribed"))
.assertIsDisplayed()
}
}
}
After test case execution, we have report in the "module > build > reports > shot" folder.
The expected screenshots are stored in the "module > screenshots" folder.
The ScreenshotTest
interface has multiple compareScreenshot
functions, and we can find which suits better for us.
interface ScreenshotTest {
fun compareScreenshot(
activity: Activity,
heightInPx: Int? = null,
widthInPx: Int? = null,
name: String? = null,
backgroundColor: Int = android.R.color.white
) { ... }
fun compareScreenshot(
fragment: Fragment,
heightInPx: Int? = null,
widthInPx: Int? = null,
name: String? = null
) { ... }
fun compareScreenshot(
dialog: Dialog,
heightInPx: Int? = null,
widthInPx: Int? = null,
name: String? = null
) { ... }
fun compareScreenshot(
holder: RecyclerView.ViewHolder,
heightInPx: Int,
widthInPx: Int? = null,
name: String? = null
) { ... }
fun compareScreenshot(
view: View,
heightInPx: Int? = null,
widthInPx: Int? = null,
name: String? = null
) { ... }
@RequiresApi(Build.VERSION_CODES.O)
fun compareScreenshot(
rule: ComposeTestRule,
name: String? = null
) { ... }
fun compareScreenshot(
bitmap: Bitmap,
name: String? = null
) { ... }
@RequiresApi(Build.VERSION_CODES.O)
fun compareScreenshot(
node: SemanticsNodeInteraction,
name: String? = null
) { ... }
...
}
Testing Animations
Let's imagine that we have a "Favourite" composable function that supports animations.
To test it, we need to use the following approach:
We need to do screenshots:
- 1st screenshot at the beginning of tests (0 ms)
- 2nd screenshot, after 100ms
- 3rd third screenshot, after 150ms
class FavouriteTest : ScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun animationShouldBeRenderedCorrectly() {
composeTestRule.apply {
setContent {
mainClock.autoAdvance = false
val state = remember { mutableStateOf(false) }
LaunchedEffect("LaunchAnimation") {
state.value = true
}
Favourite(
state = state,
modifier = Modifier.size(150.dp),
onValueChanged = { },
tint = Color.Red
)
}
compareScreenshot(composeTestRule, "state-0")
mainClock.advanceTimeBy(100)
compareScreenshot(composeTestRule, "state-1")
mainClock.advanceTimeBy(150)
compareScreenshot(composeTestRule, "state-2")
}
}
}
Here you can find a source code of the Favourite composable function.
If we change the animation in Favourite#Crossfade
from the default value (animationSpec = tween()
) to the animationSpec = spring()
, our test cases will fail and we can see the following report:
Summary
The "Compose UI Test" framework has mechanisms for enable/disable synchronization and advance virtual clock by a specific time in tests. We can use them for testing animations in Jetpack Compose UI components with screenshot tests.
In this article, we created a test case for animation, and as a result, we got a set of screenshots for our animation.
The composeTestRule.mainClock.advanceTimeBy(MILLISECONDS)
help us with setting up the value for a virtual clock before doing screenshot.
When we use the screenshot testing technique, we should remember to run such test cases on a similar device or emulator every time because every device has its screen ratio and resolution.
Do not hesitate to ping me on Twitter if you have any questions.