先看懂失败报告:失败信息里到底藏着什么
一个测试用例标记为Failures时,很多人第一反应是代码有bug,但实际上失败只是结果,原因需要从报告里挖。在Android Studio的Run面板里,一条失败记录通常包含三个关键部分:失败的方法名、异常类型和堆栈跟踪。比如 java.lang.AssertionError: expected:<200> but was:<404>,这一行已经把问题指向了接口路径或者请求构造逻辑。如果是 NullPointerException,堆栈的第一行就是你自己的代码包名开头的那一帧,从那里往上排查往往最有效。
除了断言失败和空指针,还有一类容易被忽略的失败叫做测试框架自身的错误,比如 Instrumentation.run() failed 或者 Process crashed。这类失败不代表业务逻辑有问题,而是测试进程崩溃、设备断连、模拟器OOM等环境层面的故障。建议养成习惯:先看异常类型是不是 AssertionError,如果不是,优先怀疑测试环境而不是被测代码。
另外要注意失败信息的可读性问题。默认的断言失败提示有时很短,定位起来费劲。可以在断言时主动附加描述信息,让失败报告一眼就能看懂:
assertEquals("用户积分扣除后余额不正确", 90, account.getPoints());
// 失败时会输出:用户积分扣除后余额不正确 expected:<90> but was:<95>有了描述信息的失败报告,在几十条失败记录里快速筛选关键问题时效率会高很多,这一点在大型项目的CI日志里尤其明显。

常见的失败场景分类与排查思路
第一类是网络依赖导致的失败。单元测试如果直接发起真实网络请求,结果会受服务器状态、本地网络波动影响,产生大量偶发失败。正确做法是用MockWebServer这类工具把HTTP层挡在测试之外,让返回数据完全可控:
MockWebServer server = new MockWebServer();
server.enqueue(new MockResponse()
.setResponseCode(200)
.setBody("{\"code\":0,\"msg\":\"ok\"}"));
server.start();第二类是异步时序问题。使用RxJava或协程的项目里,测试经常在异步回调还没执行完就走到了断言,导致间歇性失败。RxJava可以调用 test() 方法返回TestObserver,它会自动阻塞等待;协程则推荐注入自定义的 TestCoroutineDispatcher,用虚拟时钟控制执行节奏,彻底消除等待时间的不确定性。
第三类和生命周期相关,多出现在Espresso或FragmentScenario测试中。比如Fragment的View还没创建完成就执行了 onView() 查找,会抛出 NoMatchingViewException。Espresso本身会在主线程空闲时同步操作,但如果代码里用了自定义线程池做UI更新,Espresso就无法感知空闲,需要注册 IdlingResource 告诉它什么时候算忙、什么时候算闲,否则测试会持续随机失败。
排查时建议做一个简单实验:单独重跑失败的这条用例。如果单独跑能通过、全量跑才失败,大概率是用例之间存在状态污染,比如某个用例修改了SharedPreferences或静态单例却没有恢复。解决办法是在 @Before 里彻底重置被测对象的状态,保证每个用例都在干净的环境下运行。
失败重试机制:区分偶发失败和稳定失败
并非所有失败都值得人工介入。 instrumentation测试在真机集群上运行时,偶尔会出现一两条因设备抖动导致的失败,重跑一次就通过。这种flaky test如果不加处理,会让团队对CI的红灯逐渐麻木。合理的策略是给测试加自动重试,并对重试后仍失败的用例重点标记。
JUnit4体系下可以通过实现一个TestRule来做重试,核心思路是在 Statement.evaluate() 外包一层循环:
public class RetryRule implements TestRule {
private final int maxRetry;
public RetryRule(int maxRetry) {
this.maxRetry = maxRetry;
}
@Override
public Statement apply(Statement base, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
Throwable caught = null;
for (int i = 0; i <= maxRetry; i++) {
try {
base.evaluate();
return; // 通过则直接返回
} catch (Throwable t) {
caught = t;
}
}
throw caught; // 重试耗尽仍失败,抛出最后一次异常
}
};
}
}使用时在测试类里声明 @Rule public RetryRule retryRule = new RetryRule(2); 即可。不过要注意,重试规则对所有用例生效,可能掩盖真正的稳定缺陷,更精细的做法是用自定义注解 @FlakyTest 标记已知不稳定的用例,只对打标记的用例启用重试。
在Gradle层面也有对应手段。Android的instrumentation测试可以通过adb shell am instrument命令配合 -e retry 参数实现失败重跑,或者在CI脚本里解析失败用例列表,二次执行失败的子集。建议的实践是:第一次全量跑,第二次只重跑失败用例,重跑通过的记录为flaky并生成周报,持续观察哪些用例反复出现在flaky名单里,从根源上重构它们。
从失败测试反推工程质量:让失败变得有意义
失败测试不只是麻烦,它也是衡量工程质量的一面镜子。如果某个模块的用例经常因为时序问题失败,说明该模块的异步设计耦合过重,依赖真实线程调度而非可注入的调度器;如果用例必须依赖真实后端才能通过,说明架构上缺少接口抽象层,Repository层没有做依赖倒置。这些信号比单纯的失败本身更值得关注。
落地层面可以建立三条团队约定:第一,新用例合入前必须连续本地跑十次全绿,过滤掉天生的flaky用例;第二,CI上重试后通过的用例必须留下记录,不允许静默通过;第三,任何被跳过(Ignore)的用例必须关联 issue 编号和修复期限,防止失败用例被无限期搁置。坚持一段时间后你会发现,测试失败的噪音会显著下降,剩下的失败基本都是真实缺陷,测试体系才真正发挥了它应有的价值。