cover_image

Airbnb 爱彼迎的 Android 自动化测试宝典

爱彼迎技术团队 爱彼迎技术团队
2022年01月27日 09:01

在 Airbnb 自动化测试框架系列文章的第五篇,我们将详细介绍我们的集成测试框架的技术架构。

图片


在该系列前面的文章里,我们已经介绍了我们的状态模拟系统以及如何基于该系统做交互测试。在这篇文章里,我们将深入介绍整个自动化测试系统的架构,包括如何检测应用的空闲状态,以及如何优雅地处理错误等。


为了让读者们可以基于所讲述的内容重新搭建一个类似的系统,并且避免我们遇到的大多数问题,这篇文章将会涉及较多具体的实现细节。


自动化测试框架的架构


我们的集成测试是使用 Espresso 运行的,但我们也在其之上构建了相当复杂的测试工具。这是因为我们需要方便地设置 Fragment,操作它们的 View 以及销毁它们,而直接使用 Espresso 的 API 很难做到这些。在测试用例里,我们也没有做普通的 JUnit 或 Espresso 断言,而是进行截屏、自动点击各层次的 View 以及上传测试报告等。


使用一个 Activity 的基类


我们使用一个自定义的 Activity(命名为 IntegrationTestActivity)运行测试用例,这个 Activity 定义在 App 代码的一个共享库模块。这使得它可以在测试过程中自由操作 Fragment。这个共享库模块是一个 test 类型的依赖,因此不会打包到正式上线的包里。


在 JUnit 侧,每个测试用例都会启动 IntegrationTestActivity,并且把 Fragment 的名字作为字符串参数传入。Activity 利用反射的方法,就可以通过 Fragment 的名字访问到其所定义的模拟状态。然后 Activity 就会遍历该 Fragment 所有的模拟状态,给它设置每个模拟状态、对它执行某些操作(比如截屏),然后清除当前状态,再进入下一个。当所有的模拟状态都被处理完之后,Activity 就会自我清理并把自己的 IdlingResource 标记为空闲状态。


IntegrationTestActivity 是一个负责管理 Fragment 模拟状态的抽象类。它重写了 Activity 所有的方法以防止这些方法在测试的过程中被 Fragment 调用(这在前一篇关于交互测试的文章里也有提及)。IntegrationTestActivity 的子类只需要实现一个方法去验证这些模拟状态即可,比如截屏或者执行交互测试。


举个例子,我们对每个 Fragment 模拟状态进行截屏测试的 Activity 只包含如下代码:

class HappoTestActivity : IntegrationTestActivity() {    override fun testCurrentScreen(        mockProvider: MockedFragmentProvider,        fragment: MvRxFragment,        resetView: (onViewReset: (MvRxFragment) -> Unit) -> Unit,        finishedTestingFragment: () -> Unit    ) {        happoViewSnapshotBuilder.snap(            activity = this,            component = mockProvider.fragmentName,            variant = mockProvider.mockData.name        )        finishedTestingFragment()    }}


与之类似,我们进行交互测试的 Activity 是另一个简单的子类:

class InteractionTestActivity : IntegrationTestActivity() {    override fun testCurrentScreen(        mockProvider: MockedFragmentProvider,        fragment: MvRxFragment,        resetView: (onViewReset: (MvRxFragment) -> Unit) -> Unit,        finishedTestingFragment: () -> Unit    ) {        ActivityInteractionTester(            activity = this,            resetViewCallback = resetView,            entryPointProvider = { this.window.decorView },            interactionManager = interactionManager,            onEnd = { reportJson ->                happoJsonSnapshotBuilder.add(reportJson, mockProvider.fragmentName, mockProvider.mockData.name)                finishedTestingFragment()            }        ).start()    }}


注意这里是如何利用状态模拟框架去测试每个在模拟状态下的 Fragment 的,不需要关注如何设置 Fragment、等待 View 变稳定、销毁 UI,以及其他集成测试固有的繁杂的问题。


此外,我们可以很容易地创建新的 Activity 子类去进行任何我们想要做的其他检查。


运行 Test Activity


在运行这些 Activity 的时候,JUnit 测试会等待和监听 Activity 的结束,以便结束测试。这是通过一个定义在 IntegrationTestActivity 所在模块里的 Espresso IdlingResource 实现的。JUnit 和 Activity 可以通过这个 idler 进行通信。


这个 idler 是在一个自定义的 JUnit TestRule 里注册到 Espresso 的,我们通过 Espresso 等待它。通常情况下,这是通过调用一个 View 的断言来完成的,但是因为我们并不使用 Espresso 断言,所以我们通过调用 Espresso.onIdle() 显式地通知 Espresso 等待 idlers。


在 JUnit 侧,我们的测试用例大致是这样的:

@Testfun screenshotBookingFragment() = runScreenshots("com.airbnb.booking.BookingFragment")
@Testfun screenshotSearchFragment() = runScreenshots("com.airbnb.search.SearchFragment")
fun runScreenshots(fragmentName: String) { val intent = IntegrationTestActivity.intent<HappoTestActivity>( context = InstrumentationRegistry.getInstrumentation().targetContext, fragmentName = fragmentName ) activityTestRule.launchActivity(intent) Espresso.onIdle()}

注意这里每个 Fragment 的声明都只需一行代码即可。后续我们会讲述如何为每个 Fragment 自动生成这些代码。


我们回顾一下,这种单一 Activity 策略的好处是:

  • 对于截屏和交互测试,可以直接操作 Fragment 和 View

  • 拦截对 Activity finish() 等方法的调用,使得 Fragment 在测试过程中不会无意中影响测试框架

  • 通过一个通用的基类提供模拟状态下的 Fragment,方便为特定的测试需求创建子类


然而,这个策略也存在一些挑战,需要一些比较复杂的工作去解决。


空闲状态检测


正常情况下,Espresso 会自动检测空闲状态。比如,一个 Espresso ViewAssertion 在验证断言前会先等待 UI 进入空闲状态。我们自定义的测试 Activity 里,我们并不做 Espresso 断言,也无法访问 Espress 底层 API 获得空闲状态的回调。


但是 IntegrationTestActivity 需要展示 Fragment 并且需要明确知道 Fragment 什么时候被完全展示出来,这时才能在其之上运行测试用例。这意味着我们需要自己实现空闲状态检测。由于以下几个原因,这比较棘手:

  • 考虑所有异步运行的代码就像玩打地鼠一样,而且如果一个功能运行一些框架不知晓的自定义异步代码,考虑所有异步代码甚至是不可能的

  • 如果不考虑所有的异步代码,就可能导致测试结果不一致

  • 如果过于保守,等待时间过长,就会不必要地延长测试时间,甚至导致测试超时


幸运的是,因为我们的 Fragment 数据都是模拟的,我们的任务得以简化。我们不需要担心等待网络请求或者数据库查询等问题。我们只需要等待 Fragment 初始化阶段会影响 UI 的事情。


因为是主线程处理 UI 更新,所以最重要的是等待主线程空闲。方便的是,Handler 提供了一个 API 让我们很容易做到这一点!


Handler().looper.queue.isIdle


我们可以简单地轮询这个方法,直到队列空闲。然而,这个方法也有一个问题。在队列里最后一个 Runnable 运行的时候,Handler 的队列也显示是空闲的。这是因为,Runnable 会先出队列再运行,所以我们可以知道队列是否为空,但无法知道最后一个 Runnable 是否已经运行完。


我们的方法是往 Handler 队列里放入我们自己的 Runnable,当自己的 Runnable 被执行时再检查队列是否为空。这使得我们在检查队列状态前可以先刷新它。一个基本的方法是这样的:

fun waitForLoopers(loopers: List<Looper>) {    val idleDetectors = loopers.map { HandlerIdleDetector(Handler(it)) }    while (idleDetectors.any {  !it.isIdle }) {    }}
class HandlerIdleDetector(val handler: Handler) { var isIdle = false
init { postIdleCheck() }
private fun postIdleCheck() { handler.post { do { isIdle = handler.looper.queue.isIdle } while (isIdle) postIdleCheck() } }}


这段代码传达了基本思想,但要达到发布的标准,还有几个方面可以优化:

  • 在使用结束后需要停止 HandlerIdleDetector 里的 postIdleCheck 循环,以避免无限循环

  • 在等待空闲状态时,除了轮询,可以结合使用回调和协程让线程休眠

  • 如果 Looper 始终不进入空闲,可以添加超时机制提供更可靠的错误消息

  • 不能在被检查的 Looper 的线程里执行 postIdleCheck,因为这会让它与其他 Runnable 互相竞争

  • 通常情况下,仅仅等待所有线程进入空闲是不够的。需要注意,比如动画等 View 的更新,会被放到下一帧。因此需要确保这些线程至少持续空闲一帧的时间(大约 16 毫秒)


我们的 UI 里还广泛使用了 Epoxy,它会在后台线程里做一些操作。使用异步线程检测 RecyclerView 的变化是一种常规做法,因此这类后台线程便成了常见的问题。因为我们实现了自己的空闲状态检测,它维护了一个需要监听的 Looper 的列表,我们可以很容易地把想要监听的线程添加到这个列表上。


需要注意的是,这个方法没有处理通过 postDelayed 延迟执行的 Runnable(Espresso 也没法处理这种情况)。为了防止测试结果不一致,我们不允许在 Fragment 里做这种调用。如果 Fragment 需要延迟执行一个 Runnable(主要是一些动画),我们需要用一个函数包装这种延迟执行,在测试时强行把延时改成 0。


错误处理


如果一个测试用例抛出了一个异常,我们(作为测试框架的开发者)的目标是尽可能清楚地把异常信息展示给开发者。开发者不需细致地分析日志,理解测试框架的代码,或者为了理解异常的根本原因而投入额外的精力。为了让开发者更容易地理解异常发生的原因,我们在框架上做了以下几件事。


首先,如果崩溃发生在非主线程,例如在某个异步任务里,测试的执行器通常会显示一个通用的错误信息:

“Test failed to run to completion. Reason: ‘Instrumentation run failed due to Process crashed.’. Check device logcat for details”


这个信息是毫无帮助的,开发者必须投入额外的工作才能理解这个异常。相反,我们使用一个 Test Rule 为所有的线程注册一个默认的异常处理器,包括 RxJava 和协程。这个异常处理器把异常传给主线程,使得 Espresso 可以处理它并恰当地展示错误信息。


尽管我们提供了准确的错误信息,如果没有异常发生时的上下文,工程师也很难进行调试。一个测试用例可以覆盖 Fragment 的很多个模拟状态,因此很难分辨异常是在测试哪个模拟状态时发生的。你可以分析测试的日志,但是我们想让这个过程更加容易。比如,我们可以提供 Fragment 设置了哪个模拟状态、哪个 View 被点击了等相关信息,或者任何关于测试框架在异常发生时正在做的事情的信息。


为了做到这一点,我们维护了一个堆栈,保存了表示 “测试上下文” 的字符串。框架可以往该堆栈存入任意的字符串,并且在一个阶段结束时弹出字符串。比如,该堆栈可能保存了两个字符串:

  • “Loaded ‘failure state’ for BookingFragment”

  • “Clicking ‘book_button’ view”


然后,因为我们已经有异常处理器捕获抛出的异常,我们可以包装好异常,为开发者提供上下文信息。

val testContextMessage = "Error while testing 'Default State' mock for BookingFragment -> Clicking 'book_button' view on thread 'AsyncTask #1'"
throw IllegalStateException(testContextMessage, originalException)


这些技术让开发者们更容易独立地解决代码里的错误。而作为测试框架的维护者,这也减少了别人直接发信息让我帮忙调试测试错误的次数,这一点也是很重要的。


我们的错误处理方案的最后一部分是关于如何在 Github 的 PR 上展示错误。这个会在后续关于持续集成的文章中讲述。


下一篇:如何保持模拟的一致性


这篇文章介绍了我们的测试框架的实现细节,我们所遇到的问题,以及我们为了让框架更健壮和易于扩展所做的一些决策。


下一篇文章,我们将会介绍导致测试结果不一致的常见原因,以及我们是如何从根本上解决这些问题的。


系列文章索引


本系列文章将系统性介绍爱彼迎 Android 的自动化测试,共包含 7 篇文章。


第 1 部分 - 自动化测试的原理与状态模拟系统

第 2 部分 - 利用 MvRx 和 Happo 做自动化截屏测试

第 3 部分 - 自动化交互测试

第 4 部分 - ViewModel 单元测试的框架

第 5 部分(本篇)- 爱彼迎自动化测试框架的架构

第 6 部分 - 如何保持测试结果一致

第 7 部分 - 测试代码生成及持续集成的配置


图片


原文作者:Eli Hart,译者:Tiga Liang,校对:Ted Chan,Betty Xi。


特别感谢所有项目的参与者,以及相关开源库的贡献者。


感谢阅读!如果你想了解关于爱彼迎技术的更多进展,欢迎关注我们的 Github账号(https://github.com/airbnb/) 以及微信公众号(爱彼迎技术团队)。


图片


继续滑动看下一个
爱彼迎技术团队
向上滑动看下一个