做移动端测试的同事,大概率都有过这种经历:Android那边跑完一大堆用例,CI上只有一堆JUnit XML,点开看全是密密麻麻的标签;iOS这边更直接,给你一个.xcresult包,不装Xcode根本不知道里面发生了什么。想给开发、产品、领导展示一下质量情况,光是整理数据就能耗掉半天。后来我把报告体系整体切到Allure2,Android和iOS两端都接入了,一份统一、好看、能筛能看的测试报告,几分钟就能生成出来。这篇文章把这套完整指南从头到尾写清楚,适合刚准备折腾Allure的测试开发、正在选报告组件的QA、以及想统一移动端测试产物的工程效能同学。
我尽量不堆术语,命令和细节都按真实落地场景来写,你照着敲,应该能在一个下午内跑通两端报告。先讲清楚Allure的运作原理,因为你理解了这个模型,后面Android和iOS的各种配置对你来说就不是背命令,而是顺水推舟。
1. 先搞懂Allure的报告模型,才知道为什么两端都能用
1.1 Allure不是"报告模板",而是"测试结果的中转站"
很多人第一次接触Allure,以为它就是一个长得好看的HTML模板,把JUnit或者XCTest的结果塞进去就能出图。这个理解不对,会让你在接入时四处碰壁。Allure真正的核心是一个结构化结果目录和一条渲染线。
测试在执行过程中,通过适配器(Android上是allure-android,Java测试上是allure-junit5,Python那边还有allure-pytest)把每个用例的名字、状态、步骤、截图、日志、标签,写成一个个JSON文件,落到一个叫allure-results的目录里。这个目录就是“中转站”。然后你再调用allure generate命令,把allure-results里这一堆JSON渲染成allure-report静态站点。
这里有个关键点:Allure根本不关心你的测试是用什么框架跑的,它只关心allure-results目录里的JSON符不符合规范。打个比方,测试框架是后厨,案板上切好的菜是JSON,Allure的命令行是把菜摆盘端上桌的服务员。只要端上来的“菜”是Allure认识的形态,不管你这道菜是川菜师傅炒的还是粤菜师傅炒的,它都能摆出一样的盘。
理解了这一点,你就会明白为什么iOS也能用Allure:你没有官方适配器,就自己动手把XCTest的结果“翻译”成Allure认识的JSON。这条路走得通,是因为Allure给了你一个开放的中间层,而不是把一切都封装死。
1.2 移动端报告方案对比:Allure的优势和代价
做技术选型,不能只看“好看”,还得看“值不值”。我拿自己用过的一些方案做个对比:
| 方案 | 能看什么 | 明显痛点 |
|---|---|---|
| JUnit/TestNG原生HTML | 用例数量、通过率、失败堆栈 | 没有步骤层级,截图内嵌麻烦,跨iOS和Android完全不统一 |
| 自研HTML报告 | 完全定制 | 开发维护成本高,通常只服务一套框架,换个测试框架就要重写 |
| Xcode自带测试报告 | iOS上信息很全 | 只服务iOS,Android团队看不了,没有跨端统一性 |
| Allure2 | 步骤、标签、附件、历史趋势、环境信息 | 需要接入成本,iOS需要额外转换层 |
Allure最大的价值不只是“好看”,而是让Android和iOS在同一个视图里被横向比较。以前安卓组和iOS组各看各的报告,测试总数、失败率、缺陷分布都拿不到一起说。接入Allure之后,一套标签体系、一套分类规则、一样的图表,管理层扫一眼就能知道两端质量全貌。
代价当然也有:Android的插桩测试要配listener,iOS要写转换脚本,CI上还要维护结果目录的留存策略。这些成本一次性投入,后面是纯收益。下面从环境准备开始,一步步来。
2. 环境准备:装对工具,后续才不折腾
2.1 Java版本与Allure命令行安装
Allure命令行是一个Java程序,所以机器上得有Java运行时。Java 8起步,建议Java 11以上。如果你机器上装了多个JDK,在shell里把JAVA_HOME指明确,不然allure --version可能报错或者显示奇怪的版本。
安装方式按系统选一种就行:
macOS,用Homebrew最省事:
brew install allureWindows,用Scoop或Chocolatey:
scoop install allure或者通用方式:去Allure的官方GitHub Releases页下载allure-commandline的zip包,解压后把bin目录加进PATH。例如Linux/macOS上:
export PATH=$PATH:/path/to/allure-2.30.0/bin装完验证一下:
allure --version能看到版本号,说明基础环境通了。这里有个小白容易踩的坑:下载zip解压后,bin目录在allure目录的根下,不是嵌套在allure/bin又套一层,如果allure命令提示找不到,先检查PATH路径对不对。
2.2 项目依赖与IDE插件
命令行装好只是有了渲染器,要让测试代码往allure-results里写JSON,还得在项目里加适配器依赖。
Java后端项目用Maven或Gradle,以Gradle为例:
dependencies { testImplementation("io.qameta.allure:allure-junit5:2.29.1") }如果你还在用JUnit4,就把适配器换成allure-junit4。用TestNG就换allure-testng。这里务必注意:适配器要和项目实际的测试框架匹配,加错依赖最常见的现象是测试能跑,但allure-results目录是空的。
IDE插件方面,IntelliJ IDEA有Allure插件,装了之后可以在IDE里直接右键打开报告。但这个不是必须的,我更推荐你把命令行跑熟,因为CI上的Linux机器是没有IDE的,依赖IDE的功能一旦上了流水线就用不了。
2.3 用一条简单命令验证链路
环境装好后,别急着接入真实项目,先跑一个最小Demo。
在某个空目录里建一个最简单的测试类,加上适配器依赖后执行测试,然后运行:
allure generate allure-results -o allure-report --clean allure open allure-report浏览器会自动打开一份蓝紫色风格的HTML报告,能显示用例数量和状态,说明整条链路已经通了。注意--clean参数的意思是每次生成前清掉旧的输出目录,避免上次报告的残留干扰判断。这个习惯非常重要,CI上如果你忘了加--clean,偶尔会出现“明明用例改了,报告里还是旧数据”的诡异问题。
3. Android端接入:依赖、埋点与结果抽取
3.1 两种Android测试形态,配置要分清
Android的测试其实分两类,很多人一开始就卡在这:本地JVM单元测试跑在电脑上,速度快但没法真机交互;设备端插桩测试跑在手机或模拟器上,能真实操作App。这俩的Allure配置完全不同。
本地JVM测试,依赖用allure-junit5(JUnit4就用allure-junit4),同时指定一下结果输出目录,不然默认目录有时候会被Gradle清理掉。Gradle配置里加:
testOptions { unitTests.all { systemProperty 'allure.results.directory', "$buildDir/allure-results" } }设备端插桩测试,重点来了,你需要让AndroidJUnitRunner挂上Allure的监听器。在build.gradle或build.gradle.kts的android.defaultConfig里加:
testInstrumentationRunnerArguments["listener"] = "io.qameta.allure.android.core.Listener"依赖加这两个:
dependencies { androidTestImplementation("io.qameta.allure:allure-android:2.4.0") androidTestImplementation("io.qameta.allure:allure-junit4:2.13.8") }版本号以你仓库里的实际可用版本为准,记住一个原则:模型层和适配器版本保持一致,别混搭。为什么设备端要单独配listener?因为插桩测试跑在App进程里,不像本地单元测试能直接被Java-agent注入,必须通过AndroidJUnitRunner的事件回调去捕获用例状态,这个listener就是那座桥。
3.2 在测试代码里埋好"证据链"
Allure报告好不好看,很大程度上取决于测试代码里埋的“证据”够不够。核心就三样:步骤、标签、附件。
以JUnit5的本地单元测试为例:
import io.qameta.allure.*; import org.junit.jupiter.api.Test; @Epic("移动端核心链路") @Feature("登录模块") public class LoginTest { @Test @Story("验证正确凭据可以登录") @Severity(SeverityLevel.BLOCKER) @Description("输入正确账号密码可以进入首页") public void testLoginWithCorrectAccount() { 打开登录页(); 输入账号密码("test@example.com", "123456"); 点击登录(); 断言首页可见(); 截图留证(); } @Step("打开登录页") private void 打开登录页() { // 点击App图标、等待页面元素出现 } @Step("输入账号密码:{account} / {password}") private void 输入账号密码(String account, String password) { // 找到输入框并输入 } @Step("点击登录按钮") private void 点击登录() { // 点击登录按钮 } @Step("断言首页可见") private void 断言首页可见() { // 断言核心元素是否出现 } @Attachment(value = "页面截图", type = "image/png") private byte[] 截图留证() { return 截图工具.takeScreenshot(); } }这里有几个细节值得说透。@Step注解的方法是Allure报告“步骤层”的基础,它会按调用顺序展开成一个可视化步骤树,失败时能一眼看到卡在哪个动作上。步骤方法私有的也没关系,Allure通过字节码增强捕获,不用在意访问权限。@Attachment的返回值必须是byte[],报告会把它渲染成图片;如果返回字符串,则会按文本附件处理。截图这种证据在排查UI自动化问题时简直是救命稻草,没有它会让你在“元素找不到”和“真的没找到”之间反复猜疑。
设备端插桩测试基本类似,把适配器换成allure-android,JUnit4下没有@DisplayName,你就把方法名写清楚,或者用@Description补充可读描述。中文方法名在Allure里显示没问题,但要注意源码文件编码必须是UTF-8,不然编译环境是GBK时直接乱码。
3.3 跑完测试后,把结果拉到本地生成报告
设备端插桩测试执行完,结果不会自动回到电脑上,它写在设备App的外部存储目录里。执行命令:
./gradlew connectedDebugAndroidTest跑完后,结果目录通常在/sdcard/Android/data/<包名>/files/allure-results。你需要拉回本地:
adb pull /sdcard/Android/data/<包名>/files/allure-results ./android-results然后生成报告:
allure generate ./android-results -o ./android-report --clean本地JVM单元测试的结果则直接在构建目录下,比如build/allure-results,直接allure generate build/allure-results -o ./unit-report --clean即可。
这个“拉回本地”的步骤是Android接入最大的区别点,忘了adb pull,你只会在本地看到一个空目录,然后怀疑是不是listener没配置对。实际上测试跑完设备上确实有结果,只是没弄回来。我在接入第一天就因为这个折腾了一个多小时。
4. iOS端接入:绕开XCTest到Allure的格式鸿沟
4.1 为什么iOS不能像Android那样直接对接
Android的测试产出目录是JUnit XML,Allure官方适配器直接认,接入成本低。iOS这边情况完全不同:XCTest跑完的产物是一个二进制.xcresult包,里面是压缩过的数据库、日志、截图索引,没有官方插件能把.xcresult直接转成Allure结果。
所以iOS接入的核心思路就是一层转换管道:先用xcresulttool把XCTest结果导出成JUnit XML,再写一个小脚本把JUnit XML翻译成Allure结果JSON,最后交给allure generate渲染。听上去绕,但实际跑通了之后,这层转换就成了团队固定的CI步骤,一劳永逸。
这个过程中你会发现Allure设计的好处:它不绑定任何厂商格式,只要你按它的JSON规范投喂数据,报告就正常出。这等于给iOS留了一扇非官方但完全可行的门。
4.2 把.xcresult导出成JUnit XML的正确姿势
第一步,用xcodebuild test跑出.xcresult包。
xcodebuild test \ -project 某工程.xcodeproj \ -scheme 某Scheme \ -destination 'platform=iOS Simulator,name=<你的模拟器名>' \ -resultBundlePath ./build/测试结果.xcresult模拟器名字可以用xcrun simctl list devices available查询,把占位符换成实际名称。
第二步,用xcresulttool导出。这里要特别提醒:Xcode 16之前和之后的命令差异很大。新版本(Xcode 16+)可以直接导出JUnit:
xcrun xcresulttool get test-results tests \ --path ./build/测试结果.xcresult \ --format junit > ./build/junit.xml老版本没有get test-results tests这个子命令,得先导出JSON,再用脚本提取testSummaries.json,然后自己生成JUnit XML。我建议你检查一下手头Xcode版本,优先用新命令,能省不少事。
最容易翻车的点是漏了--format junit。默认格式是JSON,你辛辛苦苦导出后,下一层脚本完全不认,报出各种解析错误。所以这个参数一定要带上,且输出重定向到junit.xml文件时用>符号,别用-o,这个命令不支持-o参数。
4.3 用一段小脚本把JUnit XML变成Allure结果
拿到junit.xml后,需要把它变成allure-results目录下的JSON文件。核心就是解析XML里的<testsuite>和<testcase>节点,然后映射成Allure结果JSON。我分享一个足够用的Python脚本:
import xml.etree.ElementTree as ET import json import time import uuid from pathlib import Path def convert(junit_path: str, output_dir: str): tree = ET.parse(junit_path) cases = [] for suite in tree.iter("testsuite"): for case in suite.iter("testcase"): status = "passed" msg = "" failure = case.find("failure") skipped = case.find("skipped") if failure is not None: status = "failed" msg = failure.get("message", "") elif skipped is not None: status = "skipped" cases.append({ "name": case.get("name", "未命名用例"), "classname": case.get("classname", ""), "status": status, "message": msg, "time": float(case.get("time", 0)), }) out = Path(output_dir) out.mkdir(parents=True, exist_ok=True) now_ms = int(time.time() * 1000) for i, c in enumerate(cases): result = { "uuid": str(uuid.uuid4()), "name": c["name"], "status": c["status"], "stage": "finished", "start": now_ms + i * 1000, "stop": now_ms + i * 1000 + int(c["time"] * 1000), "statusMessage": c["message"] if c["message"] else None, "labels": [ {"name": "framework", "value": "XCTest"}, {"name": "suite", "value": c["classname"]}, {"name": "host", "value": "iOS"}, ], } (out / f"{i}-result.json").write_text( json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8" ) if __name__ == "__main__": convert("build/junit.xml", "ios-allure-results")脚本里最需要留意的是状态映射:JUnit XML里没有显式“broken”,只有failed和skipped,而Allure里有failed和broken两种失败态。通常我会把XCTest的失败都映射为failed,如果你想进一步细分,可以在脚本里判断failure元素的type属性,比如type="FatalError"映射为broken。另外,别忘了skipped分支,我第一次写脚本时落了它,导致跳过的用例被默认当成passed,统计直接失真。
生成完ios-allure-results目录后,运行:
allure generate ios-allure-results -o ios-report --clean报告就能出来了。如果XCTest测试过程里存了截图,你可以把图片文件也复制到ios-allure-results目录,然后在JSON的attachments字段里登记:
result["attachments"] = [{ "name": "失败截图", "source": "ios-3.png", "type": "image/png" }]注意source必须是图片相对于allure-results目录的相对路径,随便写一个绝对路径会导致报告里图片裂开。
5. 报告体验再升级:环境、分类与历史趋势
5.1 environment.properties:让报告知道自己在哪
报告能出图只是第一步,专业报告必须包含“当时跑在什么环境”。Allure支持在allure-results目录下放一个environment.properties文件,内容会被渲染成Overview页的Environment区块:
DeviceName=Pixel-7-模拟器 OSVersion=Android 14 AppVersion=3.8.2 TestRunner=ConnectedDebugAndroidTest 执行机器=mac-mini-ci-01这个文件里写什么字段完全由你定义,中英文都行。放进去之后,再执行allure generate,报告顶部就会出现这些环境信息。这个细节很有用,我见过太多团队在复盘时会问“这是哪个版本、哪台机器跑的”,没有环境信息,报告的价值直接打五折。
要提醒的是:environment.properties必须在allure generate之前写入,而且CI上每次跑完测试后要重新生成这个文件,不然这次跑的版本号还是上次的,会出现报告与事实不符的尴尬。
5.2 categories.json:把失败原因分门别类
Allure默认把失败分成“Product defects”和“Test defects”,这归得太粗了。你可以在allure-results目录下放一个categories.json,自定义失败分类的抽屉。比如:
[ { "name": "接口超时", "messageRegex": ".*timeout.*|.*Timed out.*", "matchedStatuses": ["failed"] }, { "name": "已知崩溃", "traceRegex": ".*Crash.*|.*Fatal exception.*", "matchedStatuses": ["broken"] }, { "name": "断言失败", "messageRegex": ".*expected.*but was.*|.*AssertionError.*", "matchedStatuses": ["failed"] } ]配置里的messageRegex匹配的是用例的失败消息(就是我们前面JSON里的statusMessage),traceRegex匹配完整堆栈。命中规则后,报告左侧的Categories抽屉就会把这些用例归到对应分类。这个文件最大的价值是让团队对失败类型形成共识,比如“接口超时”和“真正的功能BUG”分开统计之后,大家就不会一看到失败就慌了,先看分类再判断优先级。
注意文件必须命名为categories.json,放在allure-results根目录,别嵌套到子目录里。改完这个文件,记得重新allure generate,报告里的分类才会更新。
5.3 executor.json与历史趋势:报告不只看当下
一个专业的报告,还要能回答“这是谁跑的”“和上周比是变好了还是变坏了”。executor.json解决前者,历史趋势解决后者。
在allure-results目录下放一个executor.json:
{ "name": "CI流水线", "type": "jenkins", "url": "https://ci.example.com/job/某项目" }报告Overview页就会显示出执行者信息和链接,点击能直接跳回CI任务。
历史趋势图的做法稍微绕一点,但原理很简单:Allure会读取本次生成目录里history文件夹的趋势数据,然后把你生成的报告里新的历史数据汇总进去。实际操作是把上一次报告的history目录拷贝到本次的allure-results目录下,再执行allure generate:
cp -r previous-report/history/. current-results/history/ allure generate current-results -o current-report --clean第一次没有历史文件也没关系,报告会从空趋势开始。在CI上,你只需要在上游任务里保留上次的报告目录,每次生成前拷贝一次历史数据,就能攒出一条完整的历史曲线。这是Allure最容易被忽略但也最出彩的功能,长跑下来,质量趋势比任何一次单点报告都有说服力。
5.4 两个常见小毛病:乱码与时间显示
接入过程中会遇到两个看似无关但很影响体验的问题。
第一个是中文乱码。测试类里方法名用了中文,报告里显示出各种问号或方格。多数情况下是JVM默认编码不是UTF-8导致的。Gradle可以在构建脚本里显式指定:
tasks.withType(Test).configureEach { systemProperty "file.encoding", "UTF-8" }CI的shell里也可以加上export JAVA_TOOL_OPTIONS="-Dfile.encoding=UTF-8"。
第二个是时间显示不对。Allure结果JSON里记录的start和stop是毫秒级时间戳,浏览器在渲染时会按访问者的本地时区换算。如果你发现报告里时间跟CI日志对不上,先确认自己的浏览器时区设置,再确认脚本生成时间戳时用的是不是本地时间。这里建议直接用当前毫秒时间戳(跟我前面脚本里的time.time() * 1000一样),不要自己拼字符串,拼字符串容易出错且不容易排查。
6. 常见问题速查与团队落地经验
6.1 问题与排查对照表
我把接入过程中被问得最多的现象整理成一个速查表,可以当排查手册用:
| 现象 | 最可能的根因 | 解决思路 |
|---|---|---|
Android运行后allure-results是空的 | listener参数没配置,或适配器版本不匹配 | 检查testInstrumentationRunnerArguments,换对应适配器 |
| Android生成了报告但没有测试数据 | 结果留在设备上没拉回本地 | 执行adb pull后再allure generate |
| iOS转出来的报告全是passed | 转换脚本没处理skipped或失败分支 | 检查JUnit XML里failure和skipped节点解析 |
| iOS导出命令报参数错误 | Xcode版本旧,get test-results不可用 | 降级到JSON导出路线,或用新版Xcode |
| 报告里有截图但显示裂开 | attachment的source路径不是相对路径 | 检查JSON里的source字段,确保图片在results目录内 |
| 趋势图始终只有一条数据 | 没有把上一份报告的history目录拷贝进来 | 生成前执行cp -r合并历史数据 |
| 报告中文乱码 | JVM编码不是UTF-8 | 显式设置file.encoding,检查源码编码 |
6.2 我踩过最深的三个坑
第一个坑,是把Android的allure-results目录顺手加进了.gitignore,结果CI上从干净工作区拉代码,测试跑完根本没生成结果目录,报告只剩个空壳。排查了半天才反应过来不是配置文件错了,是目录压根没被创建。后来我养成的习惯是:在CI的生成报告步骤前,先mkdir -p确保结果目录存在。
第二个坑,是iOS转换脚本没有处理skipped。当时一批被条件跳过(比如只在Release模式跑的用例)的测试,全被默认映射成了passed,统计通过率虚高,差点误导版本发布决策。从那以后,我在所有转换脚本里都会显式枚举三种状态:passed、failed、skipped,宁可多写几行,也不让默认值悄悄吃掉状态。
第三个坑,和并行执行有关。并行跑测试本来是为了省时间,结果多个worker往同一个allure-results目录里写JSON,文件互相覆盖,报告里的用例数量忽多忽少。正确做法是给每个worker配置独立的结果目录,例如build/allure-results/worker-1、worker-2,最后生成报告时把所有目录一次性传给allure generate:
allure generate build/allure-results/worker-1 build/allure-results/worker-2 -o build/report --cleanAllure支持多个结果目录合并,这里面唯一要保证的就是每个result文件里的uuid不重复,我用的是uuid.uuid4(),基本不会撞。
6.3 给团队推广时的落地建议
一个人的报告再漂亮,也不如全团队都能用。落地时我建议按这个节奏来:
先把Android本地单元测试接上,这是成本最低、收益最快的一步。然后接iOS的转换脚本,把脚本放在CI仓库里做成固定任务,这样团队成员不需要在自己电脑上装Python依赖,直接在流水线里完成。接着做CI报告归档,按日期留存报告产物,别让流水线把报告覆盖得只剩最新一份。最后再补categories.json和environment.properties,让报告里的信息越来越丰富。
归档策略这里多说一句:报告是静态HTML,占用不大,但也不能无限堆。常见的做法是保留最近30~60天的报告,历史趋势则靠history目录持续累积,这样既能看到长期趋势,又不会让磁盘爆掉。
我个人实际操作中的体会是:Allure这套体系最难的不是某一项配置,而是“让两端结果乖乖变成统一格式”。Android那头有现成适配器,iOS那头靠一层脚本,这两条路都走通之后,剩下的环境信息、分类规则、趋势曲线,都属于锦上添花,配置起来非常快。
最后再分享一个小技巧:如果你团队里有同事对命令行不熟,可以把生成报告这一步封装成一个脚本,输入输出都固定,他们只要跑完测试后执行一句脚本,就能在浏览器里看到完整的HTML报告。仪式感很重要,当报告不再是一堆XML而是一份体面的可视化页面时,团队对自动化测试的信心会明显不一样。