尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

深入理解Espresso:同进程注入与IdlingResource实现稳定UI自动化

深入理解Espresso:同进程注入与IdlingResource实现稳定UI自动化 干过几年Android自动化的人基本都被同一个问题折磨过测试用例要么跑得飞快但脆得要命要么稳定得一批但慢到怀疑人生。一直到接触到Espresso我才算找到那个既快又稳的平衡点。它背后的核心机制——同进程注入、UI线程自动同步、IdlingResource 自定义空闲资源——每一个单独拎出来都值得好好研究更别说把它们组合起来理解整套框架的运作逻辑了。这篇东西不聊API手册上翻得到的基础用法尽量把框架的设计思路和我在真实项目里趟过的坑都摊开讲希望能帮你真正掌控Espresso而不是停留在用例能跑这个层面。1. 同进程注入Espresso 跑得又快又直接的根本原因很多人用Espresso的时候只知道它快快过Appium不知道多少倍但没深究过快在哪。答案就藏在这套框架运行模式里它跑在被测App自己的进程里跟你的业务代码共享一块内存。这个设计选择赋予了它跟传统黑盒UI测试框架完全不同的能力边界。1.1 同进程注入到底是什么意思拿Android里的两代测试框架来做对比就清楚了。老牌的UIAutomator测试代码运行在独立的测试进程里。它靠系统无障碍服务AccessibilityService去读取当前界面的UI层级结构然后通过binder跨进程把点击、滑动这些动作注入进去。这意味着它每一步操作都在绕远路先拿到UI快照再解析控件坐标然后发送系统指令最后等App响应。这套流程天然有延迟而且跨进程通信本身就不便宜。Espresso则完全换了一个玩法。它的测试代码通过AndroidJUnitRunner直接跑在被测App所在的进程里共享同一个虚拟机。测试代码里的onView(...).perform(click())这种调用本质上是直接拿到了App内部的View对象引用在这个进程的内部去dispatch事件。说白了它不是在模拟手指从系统层面操作界面而是直接在App内部触发对应的监听逻辑。这种同进程注入带来的直接好处有三个速度对象在内存里调用在进程内没有跨进程通信的开销。实测下来同样一套点击5个控件的操作序列UIAutomator大概要2到3秒Espresso跑几十毫秒。精度直接访问真实View对象不是跟坐标谈恋爱。哪怕控件位置变了、控件层级变了只要id和匹配条件还在就不会点偏。能力深度因为和App共享进程测试代码理论上能访问App内部的任何对象。想做依赖注入替身、读取内存状态、验证内部逻辑甚至把SharedPreferences里的某个value单独改掉再刷新界面全都可以在测试代码里直接做。1.2 同进程注入的代价你能碰到App内部App的Bug也能碰到你凡事都有两面性。同进程注入最大的代价就是测试进程和被测App完全绑定。如果被测App崩溃了测试进程也会跟着崩溃错误日志看起来往往是灾难性的甚至会直接引发设备端重启。这种情况下很多团队会误以为是测试代码的问题但实际上是被测App本身崩了。另外同进程也意味着Espresso无法做跨应用操作。你想拉下系统通知栏、打开另一个App去验证deep link跳转逻辑、或者操作系统设置页这些全都要借助UiDevice回到UIAutomator那一套体系里做补位。我见过有团队以为Espresso能通吃所有UI场景最后在写跨进程用例时卡了整整一周才意识到这不是框架的全能领域。还有一点需要注意同进程注入依赖Instrumentation机制所以Espresso用例只能在androidTest里运行没法像普通的JVM单元测试一样跑在开发机本地。调试的时候必须连着设备或模拟器启动和构建成本天然比纯单元测试高。1.3 为什么同进程注入决定了你的造数据方式既然测试代码能直接访问App内部那数据准备工作就可以做得非常优雅。我之前做过一个电商App的下单流程测试以前用UIAutomator时跑数据准备得先在生产环境注册一个真实用户下一单真实商品还要去造工具里生成优惠券整个过程又长又不稳定。换到Espresso之后我直接在测试代码里通过依赖注入容器往购物车库里塞一条内存假数据再调用activityRule.launchActivity(intent)带着数据进页面测试确定性瞬间提升了一个量级。我这里说的依赖注入是指App在开发时预留了测试替身注入的接口。如果被测App本身的架构是一坨互相new的代码那Espresso的这种能力也会受限。这是做UI自动化的前置架构要求不是Espresso本身能帮你解决的。2. UI线程自动同步为什么你的测试代码能等但又不靠sleep用过其他测试框架的人都知道等一个异步加载总是靠Thread.sleep(3000)这种东西。跑快了崩溃跑慢了浪费时间而且每次后端响应时间稍微波动一下整个排查过程极其痛苦。Espresso把这个问题从机制层面解决了它的术语叫同步Sync。2.1 Espresso 的同步机制是怎么运作的Espresso笼统地讲内置了一套调度器它保证了一件事当测试代码的ViewAction要执行时主线程一定是空闲的、没有正在执行的UI操作和消息任务。它的依据是Android的UI消息循环机制。Android主线程的所有操作本质上都被封装成Message放进了MessageQueue由Looper无限循环去取出来执行。Espresso的做法是在执行测试的ViewAction之前它会先去查看主线程的MessageQueue等到这个队列里没有待处理的消息了没有正在运行的AsyncTask了所有已注册的IdlingResource都确认空闲了它才真正把ViewAction调度到主线程上执行。这套机制让测试代码看起来是同步执行的写完一行onView(withId(R.id.button)).perform(click())Espresso会确保这个click对应的事件处理完整跑完然后才会执行到下一行断言语句。它的等待不是干等而是一种基于状态的优雅等待。举个例子我测一个大图加载列表的滚动场景时列表RecyclerView的onBindViewHolder加载图片时会触发异步线程拉取数据。Espresso看到AsyncTask还没执行完就会一直等等它执行完了再让我断言第一项是否显示出来。整个过程完全不用写sleep而且不用怕网络慢导致的误报。2.2 自动同步的边界它只能识别它认识的异步这里必须说清楚一个容易被误解的点Espresso的自动同步不是万能的。它默认会等待主线程的MessageQueue空闲、等待AsyncTask结束但不会等待你自定义线程池里的任务、不会等待协程任务、也不会等待Handler.postDelayed里的延时逻辑。我踩过一个很经典的坑测一个点赞功能点击后App会向服务器发请求轮询接口去查最新点赞数然后再刷新UI。当时那个轮询是用Handler.postDelayed做的延时2秒后再次请求。Espresso完全不管这个逻辑它只看到主线程当前无任务就以为空闲了于是继续往下跑结果断言的点赞数还是旧值。那时候不懂同步边界疯狂在测试代码里加sleep加了就过、不加就挂后来才发现是Handler的延时任务压根没被Espresso纳入监控范围。所以要有一个基本认知当你的App有自定义线程池 回调刷新UI这种模式时自动同步就失效了必须靠IdlingResource来补位。只要异步操作的最终结果会回调到主线程、或者会改变UI状态Espresso都能通过等主线程空闲来覆盖大部分场景。但如果你在后台线程里跑了非常耗时的计算、或者用了协程的Dispatchers.IO主线程并没有在忙Espresso就会判断为空闲而继续执行。这个问题如果不处理测试的稳定性迟早出问题。2.3 onIdle() 的存在感手动触发同步时机在onView()之前Espresso其实会自动做同步。但有些场景下比如你想在NoActivityResumed的状态下做断言、或者想等待某个后台任务结束再继续可能就需要手动调用Espresso.onIdle()让框架一次性把当前空闲状态催熟到最新。这个API不算常用但在写自定义的混合测试场景时很关键。我当时处理协程任务结束时就是先通过协程回调通知IdlingResource再在测试代码里Espresso.onIdle()强制同步一次才拿到正确状态。3. 自定义 IdlingResource把不认识的异步变成可等待的异步前面提了Espresso能同步的异步类型是有限的。想要它去等待一个自定义的后台任务、一个协程、甚至一个第三方SDK内部的耗时操作就得自己实现IdlingResource接口把它教会给Espresso。这部分是整个Espresso体系里最核心的进阶能力也是区分会用和用得好的标志。3.1 IdlingResource 接口到底在干嘛看一眼官方接口定义就知道机制很轻量interface IdlingResource { fun getName(): String fun isIdleNow(): Boolean fun registerIdleTransitionCallback(callback: ResourceCallback) interface ResourceCallback { fun onTransitionToIdle() } }逻辑其实很简单如果isIdleNow()返回false说明这个资源还在忙Espresso会一直等。等资源真正变成空闲时记得调用registerIdleTransitionCallback里传进来的回调通知Espresso我这边好了你可以继续了。这里有一个容易忽略的实现要点isIdleNow()里面最好做一次状态检查并缓存结果不要把唯一的空闲通知寄托在下一次Callback触发上。有些场景下资源在isIdleNow()被调用之前已经变成空闲了这时候如果只是等待Callback就永远等不到——因为已经空闲了不会再发回调了。最好的写法是isIdleNow()里如果发现任务已经结束就立即返回true顺带在返回值前把状态标记清除避免重复通知。3.2 一个通用型任务计数器的完整实现我一般会封装一个通用的任务计数型IdlingResource往里面塞一个线程安全的计数器就够了。它适用于绝大多数某个任务开始/结束需要被测试感知的场景import androidx.test.espresso.IdlingResource import java.util.concurrent.atomic.AtomicInteger class CountingIdlingResource(private val resourceName: String) : IdlingResource { private val counter AtomicInteger(0) private val callbackHolder AtomicReferenceIdlingResource.ResourceCallback?() override fun getName(): String resourceName override fun isIdleNow(): Boolean { val isIdle counter.get() 0 if (isIdle) { val callback callbackHolder.getAndSet(null) callback?.onTransitionToIdle() } return isIdle } override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) { callbackHolder.set(callback) } fun increment() { counter.incrementAndGet() } fun decrement() { if (counter.decrementAndGet() 0) { counter.set(0) val callback callbackHolder.getAndSet(null) callback?.onTransitionToIdle() } } }如果你不想自己造轮子官方测试库也提供了一个现成的CountingIdlingResource原理基本一致。我自己习惯实现一个主要为了能加断点调试、打日志排查起来更方便。3.3 接入项目注册时机和真实接入路径接入时最基础也最稳妥的做法是在Before里注册、在After里反注册。Before fun setUp() { // 拿到被测App里的单例或管理器 val resource MyApp.getInstance().getTaskManager() Espresso.registerIdlingResource(resource.idlingResource) } After fun tearDown() { Espresso.unregisterIdlingResource(resource.idlingResource) }这里有个比较常见的工程问题IdlingResource的实例应该放在被测App的代码里还是放在androidTest代码里我的经验是IdlingResource接口实现类放在App的main源码集里因为你希望它在生产代码里被统一维护但registerIdlingResource调用一定要放在测试代码里不污染生产逻辑。如果你的App架构不允许把IdlingResource做到生产代码里也可以用测试专属的工具类去暴露一个静态实例但这样维护起来会麻烦一点。再举个真实的例子。项目里有段时间用了一个自研的网络图片加载SDK加载流程是启动一个后台线程 → 下载图片 → 解码 → 回到主线程显示。Espresso默认根本不认识这个自定义的executor导致每次图片加载没完成断言就先执行了。我在那个SDK的RequestManager里暴露了一个CountingIdlingResource图片加载开始时increment()结束成功/失败时decrement()。然后在测试工程的Before里注册它。改完之后所有跟图片加载相关的用例稳定性直接从70%干到了100%。3.4 IdlingResource 常见的三类坑这类自定义资源写多了坑也踩了无数挑三个最具代表性的说一下。**坑一回调通知时机不对导致测试卡死或提早执行。**前面说过当资源已经从非空闲变回空闲时要保证isIdleNow()能被调到来确认状态。如果只在decrement()里调用onTransitionToIdle()但当isIdleNow()先被调用时可能就无法及时知悉状态变化进而造成死等。所以我在实现里做了双重保障既在decrement()方法里主动通知也在isIdleNow()里检查并触发回调。本质上是为了覆盖任务结束先于查询的竞态窗口。坑二After里忘了反注册。尤其是单测跑不完、测试进程崩溃的情况一旦忘了反注册后续其他测试类执行时Espresso会一直等待一个已经不存在的资源用例全卡死。排查时需要看logcat里是否有Waiting for idle这类关键字。所以unregister一定要放在finally块里别图省事写在正常情况下。**坑三计数器和空闲状态不同步。**有些异步任务会有重入现象比如一个任务里又触发了另一个任务如果只做简单的increment/decrement配对很容易出现计数器先归零、但第二个任务还在跑的情况。我的做法是让计数器的生命周期覆盖整条任务链父任务没结束就不允许子任务独立递减或者给每个任务链分配一个独立的IdlingResource在任务链入口统一increment、出口统一decrement。4. Android 框架选型指南Espresso 之外还有谁能打Espresso再好也不能包打天下。做自动化之前想清楚用什么工具测什么场景比埋头写用例重要得多。这部分把我这几年的踩坑和选型逻辑完整说说。4.1 主流测试框架的 30 秒速览先放一张我非常主观但实战性很强的对比表框架运行模式操作方式典型速度适用场景最大局限Espresso同进程注入直接操作View对象极快毫秒级App内部UI测试、依赖注入与状态验证无法跨应用操作依赖InstrumentationUIAutomator跨进程系统级模拟手势/坐标较慢秒级跨App验证、系统UI、通知栏、权限弹窗拿不到App内部View状态速度和精度都不如EspressoRobolectricJVM本地模拟Android框架环境Shadow机制很快快速反馈的JVM单测、不需要真机的逻辑测试不是真机环境UI渲染和真实设备行为有偏差Appium跨设备跨平台WebDriver协议坐标/层级慢节点通信更重iOS/Android统一的跨平台回归慢、环境依赖复杂、用例脆弱Maestro跨设备跨平台声明式YAML流中等跨App流程验证、演示App流程冒烟灵活性和断言能力偏弱不适合复杂业务逻辑验证只从速度维度看Espresso优势太明显了。但选型不能只看速度还要看你的测试目标是什么。4.2 选型决策先想清楚你测的是什么我的经验是拿到一个自动化需求时先问自己三个问题**第一你的用例只测自己的App内部逻辑吗**如果是优先Espresso。它能做到精确、稳定、快并且能结合依赖注入提升可控性。如果只是测点击登录后走网络成功跳转主页这种纯业务链路用Espresso是天作之合。**第二你的用例里是否会涉及跨App验证**比如你要验证微信拉起支付后回到自己的App、要拉系统通知栏跳转、要处理系统权限弹窗Espresso就完全不够用了。这种场景下要么用纯UIAutomator要么用Espresso配合UiDevice做一个混合实现。我通常会保留一个小的UIAutomator测试模块专门跑这几条跨进程用例。**第三你的用例是要在每次CI里做快速回归还是要在发版前做全量回归**快速回归里如果跑的是纯JVM逻辑网络请求的mock、Presenter层逻辑Robolectric是首选因为它不需要启动模拟器构建速度快到飞起。全量UI回归我还是推荐Espresso 云真机组合一套用例分发到各型号设备上并行跑比单机串行省太多时间。还有一些场景其实并不适合用Espresso去做。比如App的性能监控类测试需要采集帧率/内存建议用系统的dumpsys工具去做不是Espresso的活。再比如要录一段操作视频作为线上验收依据Maestro这种声明式的YAML流更顺手虽然精确性差但胜在简单。4.3 我自己现在的组合测试栈这几年的实操下来我比较稳定的一套组合是纯JVM层逻辑测试用Robolectric JUnit4跑在本地构建毫秒级反馈。App内部UI流程回归用Espresso跑在模拟器/真机上覆盖绝大部分核心链路。跨App与系统UI验证用UIAutomator做补位测试拉起三方支付“通知栏跳转”这类用例。发版前冒烟少量核心流程用Maestro搭一个演示流方便产品验收时不依赖IDE、直接命令行跑。这个组合让我在项目里把UI回归的失败率从之前的近15%降到了1%以内而且每轮测试时长也从1小时压到了15分钟左右。这里面的核心思想就一句话让正确的工具去做它最擅长的那部分而不是非要一个框架通吃所有。回到Espresso本身我想说的一点是它真正的杀手级能力不是那些链式API写起来多顺手而是它从设计上把测试这件事从等、猜、碰运气变成了同步、状态、确定性。尤其是当你把IdlingResource这一套用熟、用透之后你的App测试代码会开始变得听懂App内部的异步节奏而不是靠堆sleep在那里赌运气。这也是我为什么建议每个做Android自动化的团队都值得投入时间去把Espresso这套体系吃透——它不是另一个很快会被替代的小工具而是这些年在Android原生UI测试领域沉淀下来的一套成熟方法论。
返回列表