Kaspresso实战:Android UI测试持续集成与稳定性治理
每个做移动端测试的团队早晚都会碰到同一个坎手工跑一遍回归用例要一个小时起步发版前还得熬夜点手机。我经历过那种状态也试过拿纯Espresso脚本去接CI结果每次执行都有人得去维护那堆脆得离谱的测试代码。后来把项目里的UI自动化测试栈换成了Kaspresso持续集成方案配合Jenkins和GitLab CI跑了一段时间执行效率翻了不止一倍。这篇就来分享我们用Kaspresso在持续集成环境里落地的一套完整实践——从框架选型、测试代码改造、设备管理到Jenkins和GitLab CI的具体流水线配置以及那些跑挂了之后让人头大的稳定性问题该怎么查。Kaspresso本身是建立在Android官方测试框架之上的高级UI测试框架解决的是原生Espresso写起来啰嗦、场景覆盖困难、报告不直观这些老毛病。它特别适合需要长期维护UI自动化用例的团队不管你的项目是几十个页面还是几百个页面只要想把这些用例稳定地跑进持续集成流水线这篇文章里的方案都能直接抄作业。1. 为什么选择Kaspresso作为UI测试框架1.1 Kaspresso解决了什么痛点先聊一下背景。当时我们团队在维护一个体量不小的客户端项目页面超过八十个核心业务链路有十几条。早期用的方案是纯Espresso测试代码写起来非常繁琐一个简单的登录流程就要写上百行而且遇到弹窗、网络延迟、动画这些场景就得靠Thread.sleep硬等跑得慢还特别容易挂。Kaspresso进入我们的视野主要是因为它做了几件事在保持Espresso稳定性的前提下大幅简化了交互代码的写法内置了资源等待机制不用手动去写同步逻辑对复杂的业务场景提供了DSL式的编写方式还自带截图、日志、步骤报告出了问题能直接看到卡在哪一步。它底层依然跑在Instrumentation和Espresso上所以稳定性和官方框架是一个级别的但开发者体验完全不同。最关键的一点是Kaspresso明确地考虑了测试的可维护性。它支持把测试步骤拆成独立的Step对象可以在多个用例里复用。我们之前遇到过很典型的情况一个用例里把登录、加购、下单、支付全部串在一起任何一个环节的业务规则变了整个用例就废了。换成Kaspresso之后我们把每条业务链路拆成独立的步骤单元页面变化只需要改对应的步骤实现用例主体不用动维护成本直线下降。1.2 与Jenkins、GitLab CI的协作逻辑Kaspresso测试本质上就是JVM上的单元测试任务通过connectedAndroidTest或者test任务执行天然适合接进持续集成环境。Jenkins和GitLab CI是现在用的最多的两类流水线平台和Android的Gradle构建体系配合很成熟。在Jenkins里我们用的是多节点分布式构建一台机器专门负责编译打包APK和测试APK另外几台设备宿主机分别连接不同的Android真机或模拟器来执行测试任务。GitLab CI则走的是Docker执行器加动态设备环境每个流水线任务都跑在独立的容器里互不干扰。两种方案的核心逻辑是一样的都在构建阶段产出测试APK然后在设备端执行Kaspresso用例最后收集测试报告和日志。比较推荐的做法是无论Jenkins还是GitLab CI都要把测试任务和编译任务分开。因为UI测试很容易因为设备状态、网络波动出现偶发失败如果跟编译绑在同一个任务里一次失败就会导致整个流水线重新编译非常浪费时间。拆开之后编译只要通过就缓存产物测试任务随时可以单独重跑。2. 项目环境准备与依赖配置2.1 在测试模块中加入Kaspresso依赖如果你的项目还没有单独的测试模块建议先新建一个。Gradle配置里加上Kaspresso的依赖当前稳定的版本走的是MavenCentral仓库。核心依赖就一行androidTestImplementation如果需要使用Allure报告再加对应的适配器。依赖版本的选择有一点要提醒Kaspresso和Android Gradle Plugin之间有时会存在兼容性问题尤其是AGP升级大版本之后。我们当时从AGP 7.4升到8.1Kaspresso版本跟着往上提了一级测试代码本身没改执行就恢复正常了。所以看到编译报错先不要怀疑代码优先查依赖兼容矩阵。配置基础的步骤大概是下面这样android { defaultConfig { testInstrumentationRunner androidx.test.runner.AndroidJUnitRunner } testOptions { execution ANDROIDX_TEST_ORCHESTRATOR } } dependencies { androidTestImplementation com.kaspersky.android-components:kaspresso:1.5.3 androidTestImplementation androidx.test:runner:1.5.2 androidTestUtil androidx.test:orchestrator:1.4.2 }开启AndroidX Test Orchestrator是我特别推荐的一步。它会让每个测试用例都在独立的Instrumentation进程里跑用例之间不会互相污染。代价是每次用例启动时间稍微变长但换来的隔离性对持续集成场景非常关键否则一个用例改了全局状态后面的用例全跟着挂。2.2 构建变体与应用签名配置要点UI测试要跑起来必须保证测试APK和目标应用APK的签名一致。在持续集成环境里这一步经常出问题。本地开发时大家可能都在用自己的debug签名但流水线上如果用了不同的签名配置测试APK装不上用例全部失败而且报错信息还很不直观。我们当时的做法是准备一把专用签名存放在公司内部的安全凭证管理服务里Jenkins和GitLab CI都从那里读取。流水线上构建testApk和appApk时统一使用这把签名这样本地和CI的测试包行为完全一致。还有一个容易踩的坑是build type的匹配。默认情况下androidTest会跟随debug构建类型如果你配置了多个变体一定要确认测试任务是打在哪个变体上。在持续集成里我一般会单独定义一个ci构建类型关闭混淆和压缩把测试相关的逻辑稳定在一个独立变体里避免release变体因为混淆导致测试代码找不到类。3. 本地执行测试的调试要点3.1 如何在代码中管理测试设备参数Kaspresso的测试代码在设备上跑设备参数、账号、环境地址这些信息不适合硬编码。我们做了一套简单的环境管理用gradle.properties传参数然后在测试代码里通过InstrumentationRegistry.getArguments()读取。比如要区分测试环境和预发环境就定义一个环境变量跑测试时动态传入// build.gradle 里挂一个任务参数 tasks.withType(Test).configureEach { it.systemProperty(test.env, project.findProperty(testEnv) ?: test) }执行的时候./gradlew connectedDebugAndroidTest -PtestEnvstaging就能切到预发环境。这样同一套测试代码跑在Jenkins和GitLab CI上时只需要在流水线里设置不同的参数值即可不用改代码。设备并行也是在这个层面处理的。Kaspresso用例本身是JUnit测试每个用例可以在不同的设备上同时执行。我们后来在Jenkins上挂了四台真机利用Gradle的android.testOptions配置每台设备执行不同的分片用例整体回归时长从两个小时压缩到四十分钟左右。3.2 快速定位执行失败本地调试阶段我最常做的事情就是在Kaspresso的步骤里加screenShot和logger的调用。这个看着简单实际排查问题时价值极高。Kaspresso执行到失败步骤时会自动抓取当前界面截图并输出当前Activity名称、可见的View层级以及对应步骤的完整日志。后来排查问题时我都是直接看报告里的截图和日志定位问题不用再跑一遍复现省了很多时间。如果遇到设备上安装不上的问题先检查签名再用adb install -r看看具体报错。我见过最多的报错就是INSTALL_FAILED_UPDATE_INCOMPATIBLE说明设备里已经有了一个签名不一致的同名包清理掉重装就好。4. Jenkins集成实操4.1 Jenkins多节点配置与测试任务Jenkins方案的落地逻辑分三段构建节点负责产出APK设备宿主机节点连接真机还有一小段流水线代码负责编排。设备宿主机节点的配置有几个关键点。首先要安装Android SDK和adb工具并配置好环境变量其次每台宿主机要保持设备在线用adb devices检查设备状态最后给每台设备建一个唯一标识在流水线里通过ANDROID_SERIAL指定任务跑在哪台设备上。流水线核心逻辑用Groovy写Jenkinsfile大致是以下节奏pipeline { agent { label android-build } stages { stage(Build) { steps { sh ./gradlew assembleCiDebug assembleCiDebugAndroidTest } } stage(Run Kaspresso) { agent { label android-device-01 } steps { sh ./gradlew connectedCiDebugAndroidTest -PtestEnvci } } } post { always { junit **/build/outputs/androidTest-results/**/*.xml publishHTML(target: [reportDir: app/build/reports/androidTests/connected, reportFiles: index.html]) } } }这里把Run Kaspresso放到了另一台节点上执行靠的是Jenkins的分布式特性。构建节点把Gradle缓存和产物通过工作空间传递设备节点拿到测试包后直接跑整个过程是解耦的。两个常见的坑说一下。第一设备节点上执行Gradle任务时必须用--no-daemon否则Gradle守护进程会一直占着adb端口导致后面抓不到设备。第二connectedCiDebugAndroidTest这个任务跑完后Gradle可能返回非零退出码如果流水线里没处理好就会误报失败。我们加了一个catchError把测试失败和构建失败区分开方便单独定位问题。4.2 测试报告归档与邮件通知Jenkins集成里报告这块比很多人想的重要。Kaspresso的测试结果会生成标准JUnit XML报告Jenkins自带JUnit插件可以直接解析算趋势、统计通过率都很方便。HTML报告也存在工作空间里用publishHTML归档后在任务首页就能看到打开就能看每个步骤的截图和日志。我们还把报告和通知体系做了联动。流水线post阶段的always块里解析测试结果如果通过率低于设定的阈值就用Jenkins邮件插件把报告链接发给团队。执行完用例后第一件事就是点进HTML报告看失败步骤截图如果是偶发的网络超时就直接重跑失败用例如果是真实业务问题就带上日志提单。有一点很值得分享不要把所有用例堆在一条流水线里。我们按业务模块拆成了登录链路、主流程交易链路、个人中心链路好几条任务每天定时跑开发者也可以手动触发指定链路的回归。这样问题一出现就能快速定位到对应模块比跑一个三个小时的全量回归再翻报告高效得多。5. GitLab CI集成实操5.1 GitLab Runner与Docker执行器GitLab CI的集成方式和Jenkins思路不同触发在代码提交时完成用.gitlab-ci.yml描述流水线。我们用Docker执行器每阶段都在独立容器里跑环境不会串。Runner注册时记住用docker作为执行器镜像选JDK 17和Android SDK齐全的镜像。注册命令大概是这样gitlab-runner register \ --url https://gitlab.example.com \ --token glrt-token \ --executor docker \ --docker-image openjdk:17-jdk-slim \ --docker-volumes /opt/android-sdk:/opt/android-sdk容器里跑Gradle必须保证ANDROID_HOME和ANDROID_SDK_ROOT都正确指向挂载的SDK路径。如果漏了Gradle会下载不到SDK组件直接失败。还会碰到Docker容器里的adb无法访问宿主机USB设备的问题我们的解法是把测试执行阶段移到shell执行器上Runner跑在真机宿主机上容器只负责编译。GitLab CI的完整配置长这样stages: - build - test build_apk: stage: build script: - ./gradlew assembleCiDebug assembleCiDebugAndroidTest artifacts: paths: - app/build/outputs/apk/ci/ expire_in: 1 week run_kaspresso: stage: test script: - ./gradlew connectedCiDebugAndroidTest -PtestEnvci artifacts: when: always reports: junit: app/build/outputs/androidTest-results/**/*.xml retry: max: 2 when: runner_system_failurerun_kaspresso任务里的artifacts配置了JUnit报告路径GitLab本身就能解析和展示测试结果。加上when: always代表不管测试通过还是失败都保留报告方便查看。retry只针对Runner系统故障重试因为测试失败重试要加在代码逻辑里不能简单依赖CI重试否则可能掩盖真实的稳定性问题。5.2 artifacts、报告与失败重试GitLab CI里artifacts是个好东西但也容易滥用。我的建议是只保留真正需要的东西APK、测试报告XML、日志。Gradle的缓存不要用artifacts传那会让流水线的耗时和存储开销直线上升不如直接用Runner的Gradle缓存目录或者容器镜像预装依赖。失败重试这块要说得细一点。UI测试偶发失败是常态直接整个任务重试在多数情况下没啥用因为我们观察到大量偶发失败其实是同一个用例在特定时间点卡住了。正确做法是在测试代码里实现用例级重试。我们可以自定义一个Kaspresso的规则失败后重新执行当前用例限制重试次数和重试条件。举个例子超时类网络抖动可以重试一次但断言失败这种确定性问题就不要重试重试了也还是失败反而把问题的严重性藏起来了。我当时写了一个简单的RetryRule逻辑是捕获AssertionError以外的异常时重置Activity状态再重新执行测试方法。跑了两周用例通过率从94%提到了99%以上。要强调的是这个重试规则只能用来兜底环境波动如果业务逻辑确实出问题了该挂就挂报告里要有红色记录。6. 测试稳定性治理与常见问题排查6.1 三种典型问题把Kaspresso接入持续集成之后真正难的不是写代码而是让用例稳定跑。碰到最多的三类问题第一类是设备状态异常。长时间执行之后部分设备会出现系统进程卡死、输入法弹窗遮挡、屏幕超时锁屏这类问题。最初跑全量回归的时候半夜跑到一半设备就锁屏了后面的用例全挂。后来在宿主机上加了一段监控脚本每两分钟检查一下设备屏幕状态发现异常就自动点亮并解锁。第二类是网络和测试环境不稳定。一旦测试环境出现抖动请求超时页面停留在加载状态后续所有步骤全部失败。我们在Kaspresso步骤里加了统一的网络等待逻辑同时把重试机制和步骤超时时间做了调整。操作步骤在指定秒数内没有完成就视为超时并重新执行。第三类是测试用例之间的状态互相干扰。即使开了Orchestrator有些全局单例或本地缓存仍然可能被上一个用例污染。排查的方法是连续执行同一组用例看失败用例是否总出现在特定位置如果顺序改了失败也跟着改基本就是状态依赖问题需要去代码里找静态变量或者未清理的缓存。6.2 稳定性治理策略稳定性治理不是写个重试规则就完事了要有一套持续迭代的机制。我们后来定了这么几条规矩效果很明显。每次跑完测试优先处理失败用例中占比最高的那一个不要东修一个西修一个。持续集成跑了一周那类问题从十五个降到两个整体通过率很稳定。其次是每周固定时间梳理一次测试报告把不稳定用例挑出来集中修保证测试套件的健康度一直在线。同时做用例分级核心链路P0用例必须全绿才能合入主干P1用例允许偶发失败但有数量上限这样既能用自动化拦截核心问题又不会因为边缘用例的偶发失败阻塞开发节奏。这些工作虽然看起来是额外的维护成本但真正执行起来会发现它反过来在指导业务代码的重构。每次测试报告指出某个模块的稳定性格外差那个模块的代码质量往往确实有问题。测试用例像一个永不疲倦的质量巡检员它在持续集成里待得越久暴露的问题就越有价值。6.3 常见问题速查表现象可能原因排查方向测试APK安装失败签名不一致检查CI签名配置清理设备残留包用例启动后立即失败测试环境地址配置错误检查InstrumentationRegistry传参执行到固定步骤卡住页面出现了非预期弹窗查看失败截图检查弹窗处理规则屏幕锁屏导致用例中断设备长时间无操作宿主机脚本定期解锁关闭自动锁屏Gradle守护进程占用adb端口设备节点未关闭守护进程执行时加--no-daemon报告没有展示失败详情报告未正确归档检查CI配置里的报告路径是否正确排查问题时有一个心法永远先看Kaspresso生成的报告和日志不要急着猜测原因。报告里会明确记录每一步的执行结果、截图、耗时和系统日志绝大多数问题看了现场信息就知道是环境、设备还是业务逻辑出了问题能省下大量瞎试的时间。7. 一点实操心得最后分享一个个人体会。Kaspresso持续集成的部署听起来是测试框架和CI平台的交接问题实际做下来真正难的是维护一套稳定、可信、低噪声的测试资产。我见过很多团队花了两周把Jenkins流水线搭好却因为用例不稳定两周后整个自动化测试套件就废在那里没人愿意看。严格执行用例分级、坚持修复偶发失败、认真看每一份报告这些不起眼的日常维护反而决定了持续集成方案能不能长期运转。如果你刚准备开始做这件事我建议先把一个业务链路的用例打磨得足够稳再铺开到全项目这个节奏会比一次性接入上百条用例稳妥得多。