基于 Jenkins 的 Android 自动化测试

目标

  1. 每次 push 自动触发测试
  2. 执行 Detekt 代码风格检测
  3. 执行 Java 单元测试
  4. 执行 Android 单元测试
  5. jenkins 构建结果页显示测试报告
  6. 构建结果通过飞书 Bot 通知到工作群

环境

  • Mac OS (Apple M1)
  • Jenkins (homebrew)
  • self-hosted gitlab (ominus)

功能实现

创建新的构建任务,类型选择 Pipeline

设置 push 时触发构建

安装 jenkins 插件: gitlab

生成 webhook secrets (jenkins 默认不允许不安全的 webhooks 请求)

记录 webhook url 及 secret,回到对应的 gitlab 仓库,创建 webhook:

  1. 填写 URL 及 Secret token
  2. 勾选 Trigger 触发事件,如 Push events
  3. 勾选 Enable SSL verification
  4. 点击 Add webhook

执行 gradle 任务

Detekt 代码风格检测

1
sh './gradlew clean detekt'

Java 单元测试

1
sh "./gradlew testDebugUnitTest"

Android 单元测试

Android 单元测试需要依托 Android 设备,这里使用 Android 模拟器,方便后续维护。

启动 Android 模拟器:

1
sh "emulator -avd $emulatorName"

执行 Android 单元测试:

1
sh "./gradlew connectedDebugAndroidTest"

关闭 Android 模拟器:

1
sh "adb emu kill"

在实际工作环境中,上述命令会导致系统报错,解决方法参考 模拟器关闭时系统报错

记录测试报告

安装 jenkins 插件: publisher

Detekt 报告

detetk 建议配置 Merging reports 合并检测报告,合并后的报告文件路径:

1
$projectDir/reports/detekt/merge.xml

将合并后的报告展示在构建结果页。

1
2
3
4
5
6
7
publishHTML (target : [allowMissing: false,
alwaysLinkToLastBuild: true,
keepAll: true,
reportDir: "build/reports/detekt",
reportFiles: 'merge.xml',
reportName: "report - detekt",
reportTitles: "report - detekt"])

Java 单元测试报告

Java 单元测试报告位置(moduleDir 替换为对应 module 的路径):

1
$moduleDir/build/reports/tests/testDebugUnitTest/

将测试报告展示在构建结果页。

1
2
3
4
5
6
7
publishHTML (target : [allowMissing: false,
alwaysLinkToLastBuild: true,
keepAll: true,
reportDir: "$moduleDir/build/reports/tests/testDebugUnitTest/",
reportFiles: 'index.html',
reportName: "JavaTest - $moduleName",
reportTitles: "JavaTest - $moduleName"])

Android 单元测试报告

Android 单元测试报告位置(moduleDir 替换为对应 module 的路径):

1
$moduleDir/build/reports/androidTests/connected/

将测试报告展示在构建结果页。

1
2
3
4
5
6
7
publishHTML (target : [allowMissing: false,
alwaysLinkToLastBuild: true,
keepAll: true,
reportDir: "$moduleDir/build/reports/androidTests/connected/",
reportFiles: 'index.html',
reportName: "AndroidTest - $moduleName",
reportTitles: "AndroidTest - $moduleName"])

飞书通知

在飞书群组中,添加自定义机器人,获取 webhookUrl。开启方式参考飞书官方文档

向 webhookUrl 发起网络请求,通知构建结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
def notifyFeishuBot(String webhookUrl, boolean success) {
def msg
if (success) {
msg = "CI Success: $BUILD_URL"
} else {
msg = "CI Failed: $BUILD_URL"
}
sh """
curl -X POST -H "Content-Type: application/json" \
-d '{"msg_type":"text","content": {"text": "$msg"}}' \
$webhookUrl
"""
}

常见问题及解决方案

模拟器关闭时系统报错

具体原因暂时未知,猜测和模拟器缓存机制有关。

暂时的解决方案是直接 kill 模拟器进程,具体方式如下:

1
2
3
4
5
6
7
8
9
10
11
// 获取模拟器进程 id
def emulatorPid = sh(
script: """
emulator -avd $emulatorName -no-boot-anim -noaudio -wipe-data -no-window > /dev/null 2>&1 &
echo \$!
""",
returnStdout: true
).trim()

// 结束模拟器进程
sh "kill -9 $emulatorPid"

执行 Android 测试时模拟器尚未启动

监听模拟器 sys.boot_completed 属性,从而判断模拟器是否启动完成。

1
2
3
4
5
6
// 这里设置最多等待 120 秒,可根据电脑性能酌情增减
timeout(time: 120, unit: 'SECONDS') {
sh "adb wait-for-device shell 'while [[ -z \$(getprop sys.boot_completed) ]]; do sleep 1; done;'"
}
// 模拟器已启动,执行 Android 单元测试
sh "./gradlew connectedDebugAndroidTest"

如果上述方法无效,也可以试试监听 init.svc.bootanim 属性,具体参考: stackoverflow

执行 Android 单元测试报错 INSTALL_FAILED_INSUFFICIENT_STORAGE

相关解释参考: stackoverflow

比较简单有效的解决方案是模拟器启动时添加 -wipe-data 参数,确保模拟器没有遗留数据。

单元测试报告样式错乱

Jenkins 默认禁止了结果页的 js/css 的加载,在 Manage Jenkins -> Script Console 输入下述命令,点击运行即可:

1
System.setProperty("hudson.model.DirectoryBrowserSupport.CSP", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' 'unsafe-inline' data:;")

上述方法在 Jenkins 重启后即失效,需要重新配置。更建议的做法是直接修改 Jenkins 启动配置项。

具体参考: Managing the Content Security Policy on Jenkins

测试报告太多,如何区分哪一个测试失败

由于 Java/Android 的单元测试报告在每个 module 下独立生成,对于 module 较多的项目,可能会生成较多测试报告。在单元测试失败时,从构建结果页只能看到 Java/Android 单元测试失败,但具体是哪一个 module 下的需要一个一个报告点进去看,这个成本无疑是无法接受的。

两个解决思路:

  1. 读取每一份测试报告的用例 failures 数量,对于 failures 大于 1 的,在构建结果页特殊标记,如在测试报告标题上添加 (error) 标注。
  2. 对每个 module 下的每个测试任务,动态创建构建 stage,从而一眼区分出构建失败的具体任务。

这里我们对展示思路 1 的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 读取 module 列表
def moduleList = sh(
script: """
./gradlew projects | ggrep -Poi ".+Project ':\\K[\\w:]+'" | sort -u | rev | cut -c 2- | rev | tr '\\n' ','
""",
returnStdout: true
).split(",")

// 遍历读取每个 module 下的测试报告
moduleList.each { moduleName ->
def modulePath = "./${moduleName.replace(':', '/')}"
// 判断测试报告文件是否存在
def reportDir
if (isAndroid) {
reportDir = "$modulePath/build/reports/androidTests/connected"
def htmlFileCount = sh(script: "ls -l $reportDir 2>/dev/null | grep .html | wc -l", returnStdout: true).trim().toInteger()
if (htmlFileCount <= 1) {
return
}
} else {
reportDir = "$modulePath/build/reports/tests/testDebugUnitTest"
def testDirExists = sh(script: "(ls $reportDir >> /dev/null 2>&1 && echo true) || echo false", returnStdout: true).trim().toBoolean()
if (!testDirExists) {
return
}
}

// 通过 xpath 读取测试报告中的 failures 数量
// 特别的,Android 单元测试报告不符合 xmllint xpath 可识别规范,这里手动在测试报告末尾添加 </html> 以确保 xpath 正常识别 failures 数量
if (isAndroid) {
sh "echo '</html>' >> $reportDir/index.html"
}
def failCount = sh(script: "xmllint --xpath '//*[@id=\"failures\"]/div/text()' $reportDir/index.html", returnStdout: true).trim().toInteger()
// 对于 failures 的报告,在构建结果页的报告标题上,添加 (error) 标记
if (failCount > 0) {
reportName = "(error) $reportName"
}
}

上一步构建失败,导致整个构建任务中止

Jenkins pipeline Job 默认会在出错时中止整个构建任务,但对于我们的任务来说,哪怕 Detekt 检查未通过,我们也希望后续的单元测试能够执行。

Jenkins 提供了两个捕获错误的机制: try/catchcatchError。两者的区别是,通过 try/catch 捕获的错误不会导致当前 stage 失败,从构建结果页来看,捕获到错误的 stage 构建状态是成功。而通过 catchError 捕获错误,则可以让我们自行决定当前 stage 是否要标记为失败。

回到我们的场景,我们希望每个 stage 的构建成功与否如实显示,只是不希望整个构建因为某一个 stage 的失败而中止,因此 catchError 更符合我们的需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
stage('detekt') {
catchError(buildResult: 'FAILURE', stageResult: 'FAILURE', message: 'detekt fail') {
sh 'rm -rf local.properties'
sh './gradlew clean detekt'
}
if (currentBuild.currentResult == "FAILURE") {
archiveDetektReport()
}
}

stage('java tests') {
catchError(buildResult: 'FAILURE', stageResult: 'FAILURE', message: 'java tests fail') {
sh "./gradlew testDebugUnitTest"
}
archiveTestReport()
}

// ...后续省略

完整示例

源码: https://gist.github.com/yueban/c9d1ab1167eb842648e31511f5543e46