8、手势测试:点击、长按、滑动、拖拽测试

手势测试,说白了就是模拟用户的手指操作。你想想看,用户打开你的 App,第一件事就是点、滑、拖。这些操作要是出了问题,那体验可就全毁了。我在项目中见过太多因为手势没测好导致的线上事故——比如列表滑动卡顿、长按菜单弹不出来、拖拽排序乱掉。嗯,今天我们就来把这些坑一个个填上。

8.1 手势测试的核心思路

Compose 的 UI 测试框架提供了 SemanticsMatcherSemanticsNodeInteraction 来定位元素,然后通过 performGesture 系列方法模拟手势。我个人习惯把手势测试分成两类:

  • 简单手势:点击、长按、双击。这些操作通常触发一个明确的回调。
  • 复杂手势:滑动、拖拽、缩放。这些操作涉及坐标变化和连续事件流。

不管哪一类,核心都是「模拟 → 断言」的循环。先模拟用户操作,再检查 UI 状态或业务逻辑是否正确。

关键点:手势测试不是测「手势本身」,而是测「手势触发的业务逻辑」。比如点击按钮后是否跳转、滑动后列表是否加载更多数据。

8.2 点击测试:最基础也最容易翻车

点击测试看起来简单,但我在项目中踩过不少坑。比如按钮被遮挡、点击区域太小、或者点击事件被父布局拦截了。来看一个标准示例:

@Test
fun testButtonClick() {
    composeTestRule.setContent {
        var count by remember { mutableStateOf(0) }
        Button(onClick = { count++ }) {
            Text("点击次数: $count")
        }
    }

    // 定位按钮并点击
    composeTestRule
        .onNodeWithText("点击次数: 0")
        .performClick()

    // 断言点击后文本更新
    composeTestRule
        .onNodeWithText("点击次数: 1")
        .assertExists()
}

这里有个细节:performClick() 默认会等待动画和布局稳定。如果你的按钮有点击延迟或异步操作,记得加上 composeTestRule.waitForIdle()

我曾经踩过的坑:用 performClick() 点击一个被 Modifier.alpha(0f) 隐藏的按钮,测试居然通过了!后来才发现,performClick() 只检查语义树中的节点是否存在,不检查可见性。所以一定要配合 .assertIsDisplayed() 使用。

8.3 长按测试:别忘了持续时间

长按测试和点击类似,但多了一个「持续时间」参数。默认的长按时间是 500 毫秒,但你可以自定义。来看一个长按弹出菜单的例子:

@Test
fun testLongPress() {
    composeTestRule.setContent {
        var showMenu by remember { mutableStateOf(false) }
        Box(
            modifier = Modifier
                .size(100.dp)
                .background(Color.Blue)
                .pointerInput(Unit) {
                    detectTapGestures(
                        onLongClick = { showMenu = true }
                    )
                }
        ) {
            if (showMenu) {
                Text("菜单已弹出")
            }
        }
    }

    // 执行长按
    composeTestRule
        .onNodeWithTag("long_press_box")
        .performLongClick()

    // 断言菜单出现
    composeTestRule
        .onNodeWithText("菜单已弹出")
        .assertExists()
}

嗯,这里要注意:performLongClick() 默认使用 500ms 的长按时间。如果你的自定义手势要求更长的长按(比如 1000ms),可以用 performGesture { longClick(duration = 1000) }

小技巧:长按测试最好配合 advanceTimeBy() 来模拟时间流逝,尤其是当你的长按逻辑依赖协程延迟时。

8.4 滑动测试:坐标与方向的艺术

滑动测试稍微复杂一点,因为你需要指定起始坐标和结束坐标。Compose 提供了 performTouchInput { swipe(...) } 方法。来看一个列表滑动的例子:

@Test
fun testSwipeToRefresh() {
    composeTestRule.setContent {
        val listState = rememberLazyListState()
        LazyColumn(state = listState) {
            items(100) { index ->
                Text("Item $index", modifier = Modifier.testTag("item_$index"))
            }
        }
    }

    // 从屏幕中间向上滑动
    composeTestRule
        .onNodeWithTag("item_0")
        .performTouchInput {
            swipeUp()
        }

    // 断言第一个可见项不再是 item_0
    composeTestRule
        .onNodeWithTag("item_0")
        .assertDoesNotExist()
}

这里有个坑:swipeUp() 默认从节点中心向上滑动。如果你的节点很小,滑动距离可能不够。我建议用 swipe(startX, startY, endX, endY) 指定绝对坐标,或者用 swipeWithVelocity 控制速度。

避坑指南:滑动测试时,记得考虑屏幕密度。不同设备上同样的 dp 值对应的像素不同。最好用 composeTestRule.density 做转换。

8.5 拖拽测试:从 A 点到 B 点

拖拽测试是手势测试里最复杂的。它涉及按下、移动、释放三个步骤。Compose 提供了 performTouchInput { drag(...) } 方法。来看一个拖拽排序的例子:

@Test
fun testDragAndDrop() {
    composeTestRule.setContent {
        var items by remember { mutableStateOf(listOf("A", "B", "C")) }
        LazyColumn {
            items(items, key = { it }) { item ->
                Text(
                    text = item,
                    modifier = Modifier
                        .testTag("drag_$item")
                        .pointerInput(Unit) {
                            detectDragGestures { change, _ ->
                                change.consume()
                            }
                        }
                )
            }
        }
    }

    // 拖拽 "A" 到 "C" 的位置
    composeTestRule
        .onNodeWithTag("drag_A")
        .performTouchInput {
            // 从 A 的中心开始,向下拖拽 200 像素
            drag(
                start = center,
                end = Offset(center.x, center.y + 200f)
            )
        }

    // 断言拖拽后的顺序
    // 注意:这里需要根据你的业务逻辑做具体断言
}

我个人习惯在拖拽测试里加上 waitForIdle(),因为拖拽过程中可能会有动画或状态更新。另外,如果你的拖拽逻辑依赖 LazyColumn 的滚动,记得先滚动到目标位置再拖拽。

我曾经踩过的坑:拖拽测试时,如果目标节点被滚动出屏幕,拖拽会失败。一定要先确保两个节点都在可见区域内。可以用 scrollToNode()scrollToIndex() 提前滚动。

8.6 手势测试的进阶技巧

除了基本的手势,还有一些进阶场景需要处理:

  • 多点触控:用 performTouchInput { pinch(...) } 模拟双指缩放。
  • 手势冲突:当多个手势同时存在时,测试优先级和拦截逻辑。
  • 边界条件:比如在屏幕边缘滑动、快速连续点击、超长拖拽等。

来看一个多点触控的示例:

@Test
fun testPinchToZoom() {
    composeTestRule.setContent {
        var scale by remember { mutableStateOf(1f) }
        Box(
            modifier = Modifier
                .graphicsLayer(scaleX = scale, scaleY = scale)
                .pointerInput(Unit) {
                    detectTransformGestures { _, _, zoom, _ ->
                        scale *= zoom
                    }
                }
        ) {
            Text("缩放测试")
        }
    }

    // 模拟双指缩放
    composeTestRule
        .onNodeWithText("缩放测试")
        .performTouchInput {
            pinch(
                start = Offset(100f, 100f),
                end = Offset(200f, 200f),
                start2 = Offset(300f, 300f),
                end2 = Offset(200f, 200f)
            )
        }

    // 断言缩放比例变化
    // 注意:这里需要根据你的缩放逻辑做具体断言
}

8.7 知识体系总览

下面这张图总结了手势测试的核心知识点和它们之间的关系:

手势测试知识体系 手势测试 简单手势 点击 (performClick) 长按 (performLongClick) 双击 (performClick x2) 复杂手势 滑动 (swipeUp/Down/Left/Right) 拖拽 (drag) 缩放 (pinch) 进阶技巧 多点触控模拟 手势冲突测试 边界条件覆盖

8.8 测试策略与最佳实践

最后,分享几个我在项目中总结的手势测试策略:

手势类型 测试重点 常见陷阱
点击 回调触发、状态更新 节点不可见但可点击
长按 持续时间、菜单弹出 时间精度不足
滑动 方向、距离、速度 屏幕密度差异
拖拽 起始/结束位置、顺序 节点滚动出屏幕
多点触控 缩放比例、旋转角度 手势冲突

我的建议:不要试图覆盖所有手势组合。优先测试用户最常用的操作路径,比如「点击按钮 → 滑动列表 → 长按删除」。把 80% 的精力放在 20% 的核心手势上,剩下的边界情况用手动测试补充。

好了,手势测试的内容就到这里。记住,测试不是目的,质量才是。每次写测试前,先问问自己:「这个手势操作如果出问题,用户会骂娘吗?」如果是,那就一定要测。


公众号:蓝海资料掘金营,微信deep3321