Paparazzi screenshot tests are great, but they don't catch broken UI interactions before your users do. Anything that lives between a layout and a tap (animations, gestures, ripples, scroll-driven UI) goes untested. Touch Robot fixes that by generating fake touch events to exercise your UI.
implementation("me.saket.touchrobot:touchrobot-paparazzi:0.1.0")class InteractionTest {
@get:Rule val paparazzi = Paparazzi()
@Test fun test() {
paparazzi.gif(end = 2_500) {
Box {
Text("foo")
}
val touchRobot = rememberTouchRobot()
LaunchedEffect(Unit) {
touchRobot.onRoot().performGesture {
click()
longClick()
}
touchRobot.onNode(hasText("foo")).performGesture {
swipe(…)
draw(…)
}
}
}
}
}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.
Show taps
By default, Touch Robot renders the pointer location so that you can visually see where the touch events are landing. This is similar to the Show taps option in Android's developer settings. You can disable it if needed:
rememberTouchRobot(showTaps = false)Don't use Paparazzi?
While Touch Robot was designed with Paparazzi in mind, its core can also be used with any other screenshot testing library of your choice:
- implementation("me.saket.touchrobot:touchrobot-paparazzi:0.1.0")
+ implementation("me.saket.touchrobot:touchrobot-core:0.1.0")You'll likely need to copy TouchRobot.paparazzi.kt into your project and adapt it for your chosen library.
paparazzi.gif(end = 2_500) {
Column(…) {
repeat(5) { index ->
Carousel(Modifier.testTag("page$index"))
}
}
val touchRobot = rememberTouchRobot()
LaunchedEffect(Unit) {
touchRobot.onNode(hasTestTag("page2")).performGesture {
repeat(3) {
swipe(
start = center,
stop = centerLeft,
duration = 300.milliseconds,
)
delay(300)
}
}
}
}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,
)
}
}
}
/** A path drawing the Android head. */
fun createAndroidHeadPath(bounds: Rect): Path = TODO()paparazzi.gif(end = 1000) {
Row {
Button {
Text("Left")
}
Button {
Text("Center")
}
}
val touchRobot = rememberTouchRobot()
LaunchedEffect(Unit) {
touchRobot.onNode(hasText("Left")).performGesture {
click()
}
delay(500)
touchRobot.onNode(hasText("Center")).performGesture {
longClick()
}
}
}paparazzi.gif(end = 2000) {
ZoomableAsyncImage(
modifier = Modifier.testTag("image"),
model = "https://dog.ceo/image.jpg",
contentDescription = null,
)
val touchRobot = rememberTouchRobot()
LaunchedEffect(Unit) {
touchRobot.onNode(hasTestTag("image")).performGesture {
val startOffset = IntOffset(100, 100)
val endOffset = IntOffset(300, 300)
pinch(
start0 = center - startOffset,
start1 = center + startOffset,
end0 = center - endOffset,
end1 = center + endOffset,
duration = 1.seconds,
)
}
}
}


