Introducing touch-robot, for writing interactive screenshot tests in Compose UI

  • Shipped an expandable header that broke mid-scroll. Went unnoticed for a week.
  • Broke highlighting of hyperlinks in text for the third time.
  • Fixed a button’s layout on one screen, and silently broke its on-pressed shape on another.

These are some examples of real issues that were happening at Block. Each of them was frustrating because we were regularly shipping broken experiences to customers despite having sufficient test coverage. We had screenshot tests using Paparazzi, but they were mostly static snapshots that verified layout correctness. We had connected device tests, but they ran with animations disabled and only verified functionality. Anything that lived between a layout and a tap went untested.

We realized we were trying to solve a third kind of problem, so we built touch-robot: a tool that generates fake touch events (clicks, swipes, drawing, pinch) for writing interactive screenshot tests. We use it with paparazzi.gif() for taking animated snapshots so that we can verify how our UI interacts with clicks, swipes, drawings, and more:

@Test fun test() {
  paparazzi.gif {
    Column {
      repeat(5) { index ->
        Carousel(Modifier.testTag("page_$index"))
      }
    }

    val touchRobot = rememberTouchRobot()
    LaunchedEffect(Unit) {
      touchRobot.onNode(hasTestTag("page_2")).performGesture {
        repeat(3) {
          swipe(start = center, stop = centerLeft)
          delay(300)
        }
      }
    }
  }
}

For those unaware, think of paparazzi.gif() as recording a video of your UI, except the output is an APNG (Animated PNG) instead. Under the hood, it takes one snapshot per frame and stitches them together. And because APNGs are lossless, each frame can be deterministically compared to a golden value, so an animated test is still a real regression test.

Here’s my favorite use case. Cash App lets customers customize their debit cards with their own drawing. For the longest time, the drawing tool was only manually tested. Not anymore:

class DebitCardTest {
  @get:Rule val paparazzi = Paparazzi()
  
  @Test fun test() {
    paparazzi.gif(end = 3_000) {
      DebitCard(
        Modifier.testTag("card")
      )

      val touchRobot = rememberTouchRobot()
      LaunchedEffect(Unit) {
        touchRobot.onNode(hasTestTag("card")).performGesture {
          draw(
            path = createAndroidHeadPath(…),
            duration = 3.seconds,
          )
        }
      }
    }
  }
}

When someone accidentally introduces a regression, you get this nice diff from Paparazzi. Because APNGs are supported by all web browsers, your PR reviewers can also see the changes visually.

Diff generated by the verifyPaparazzi gradle task. Left: golden snapshot, right: new changes.

touch-robot has become a standard part of our UI testing toolkit at Block. I hope you all find it as useful as we have! Try it out: github.com/saket/touch-robot.

PS: touch-robot was designed with paparazzi in mind because we are biased towards it, but you can use it with any screenshot testing tool of your choice.