Jetpack Compose

Jetpack Compose: Testing animations

Designers and developers pay more attention to animations. This article will explore how to test animation created with Jetpack Compose.
Alex Zhukovich 6 min read
Jetpack Compose: Testing animations
Table of Contents

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.

Timeline of the 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 Views. As a result, we cannot use a framework that can analyze a ViewTree and verify/interact with Views. 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.

Demo of the "SubscriptionContainer" component

You can find the implementation of SubscriptionContainer here.

Let's create a test case that emulates this scenario:

  1. Enter an email address
  2. Click on the "SUBSCRIBE" button
  3. 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 a ComposeContentTestRule. We can use this rule to test composable functions in isolation. In addition to the createComposeRule function, we have a few other options:
  • The createAndroidComposeRule creates an AndroidComposeTestRule for a specific Activity.
  • The createEmptyComposeRule creates an AndroidComposeTestRule 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 on SemanticMatcher. The onNode(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 the IdlingResource 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.

Timeline of the 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.

Demo of the "SubscriptionContainer" component

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:

Timeline of the animation

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:

Test 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.


Mobile development with Alex

A blog about Android development & testing, Best Practices, Tips and Tricks

Share
More from Mobile development with Alex
Jetpack Compose: Divider
Jetpack Compose

Jetpack Compose: Divider

This article covers using and customizing the “Dividers” components from the "Material 2" and "Material 3" libraries in the Jetpack Compose. In addition to that, we will explore the difference between implementation of the Divider, HorizontalDivider and VerticalDivider.
Alex Zhukovich 4 min read

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Mobile development with Alex.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.