每个做移动端测试的团队,早晚都会碰到同一个坎:手工跑一遍回归用例要一个小时起步,发版前还得熬夜点手机。我经历过那种状态,也试过拿纯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.1,Kaspresso版本跟着往上提了一级,测试代码本身没改,执行就恢复正常了。所以看到编译报错,先不要怀疑代码,优先查依赖兼容矩阵。
配置基础的步骤大概是下面这样:
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 -PtestEnv=staging就能切到预发环境。这样同一套测试代码跑在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 -PtestEnv=ci' } } } 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 -PtestEnv=ci 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流水线搭好,却因为用例不稳定,两周后整个自动化测试套件就废在那里没人愿意看。严格执行用例分级、坚持修复偶发失败、认真看每一份报告,这些不起眼的日常维护,反而决定了持续集成方案能不能长期运转。如果你刚准备开始做这件事,我建议先把一个业务链路的用例打磨得足够稳,再铺开到全项目,这个节奏会比一次性接入上百条用例稳妥得多。