Junit 测试框架

GeckoView 有 大量自定义 代码用于运行 Junit 测试。本文档概述了这些代码的功能和工作原理。

简介

GeckoView 是一个 Android 库,可用于在应用程序中嵌入 Gecko,Gecko 是 Firefox 背后的 Web 引擎。它是 Android 版 Firefox 的基础,旨在用于构建 Web 浏览器,但也可以用于构建需要显示 Web 内容的其他类型的应用程序。

GeckoView 本身除了 Web View 之外没有 UI 元素,并使用称为“委托”的 Java 接口让嵌入器(即使用 GeckoView 的应用程序)实现 UI 行为。

例如,当网页的 JavaScript 代码调用 alert('Hello') 时,嵌入器将收到对 onAlertPrompt 方法的调用,该方法属于 PromptDelegate 接口,并包含显示提示所需的所有信息。

由于大多数委托方法都处理 UI 元素,因此 GeckoView 将在 UI 线程上为嵌入器执行它们,以方便嵌入器。

GeckoResult

对于后续内容,理解 GeckoResult 非常重要。GeckoResult 是一个类似 Promise 的对象,在整个 GeckoView API 中使用,它允许嵌入器异步响应委托调用,并允许 GeckoView 异步返回结果。这对于 GeckoView 尤其重要,因为根据设计原则,它永远不会提供对 Gecko 的同步访问。

例如,在 GeckoView 中安装 WebExtension 时,生成的 WebExtension 对象将在 GeckoResult 中返回,并在扩展程序完全安装后完成。

public GeckoResult<WebExtension> install(...)

为了简化内存安全,GeckoResult 将始终 执行回调 在创建它的同一个线程中,将异步代码转换为单线程的 javascript 风格代码。这目前 使用 Android Looper 实现,这将 GeckoResult 限制在具有 Looper 的线程上,例如 Android UI 线程。

测试概述

鉴于 GeckoView 实际上是 Gecko 和嵌入器之间的转换层,它主要通过集成测试进行测试。绝大多数 GeckoView 测试都采用以下形式:

  • 加载简单的测试网页

  • 通过特权 JavaScript 测试 API 与网页交互

  • 验证是否使用正确的输入调用了正确的委托

并且大部分测试框架都是围绕确保这些交互易于编写和验证而构建的。

可以使用 mach 接口运行 GeckoView 中的测试,大多数 Gecko 测试都使用此接口。例如,要运行 NavigationDelegateTest 中的 loadUnknownHost 测试,您可以在终端中键入以下内容:

./mach geckoview-junit org.mozilla.geckoview.test.NavigationDelegateTest#loadUnknownHost

另一种运行 GeckoView 测试的方法是通过 Android Studio IDE。但是,通过这种方式运行测试时,测试框架的某些部分不会初始化,因此某些测试的行为会不同或失败,稍后将对此进行解释。

测试外壳

作为一个库,GeckoView 具有一个自然且稳定的测试外壳,即 GeckoView API。绝大多数 GeckoView 测试仅使用公开的 API 来验证 API 的行为。

每当 API 不足以正确测试行为时,测试框架都会提供有针对性的“特权”测试 API。

多年来,使用受限且稳定的测试外壳已被证明是编写一致测试的有效方法,这些测试在重构时不会中断。

测试环境

通过 mach 运行时,GeckoView Junit 测试在类似于 mochitests(Gecko 中使用的一种 Web 回归测试)的环境中运行。它们可以访问 example.com 上的 mochitest web 服务器,并继承大多数测试首选项和配置文件。

请注意,当通过 Android Studio 运行测试时,环境将与 mochitests 不同,首选项将继承自默认的 GeckoView 首选项(即消费者构建的 GeckoView 中启用的相同首选项),并且 mochitest web 服务器将不可用。

测试使用 isAutomation 检查来解决此问题,该检查基本上检查测试是在 mach 下运行还是通过 Android Studio 运行。

与大多数其他 Junit 测试不同,GeckoView 测试在 UI 线程上运行。这样做是为了确保在正确的线程上创建 GeckoResult 对象。如果没有这样做,每个测试都可能包含大量在 UI 线程上运行代码的块,从而增加大量的样板代码。

通过注册一个名为 GeckoSessionTestRule 的自定义 TestRule 来在 UI 线程上运行测试,该规则除了其他功能外,还 覆盖了 evaluate 方法并将所有内容包装到 instrumentation.runOnMainSync 调用中。

验证委托

如前所述,验证是否发生了委托调用是 GeckoView 测试最常见的断言之一。为了方便起见,GeckoSessionTestRule 提供了几个 delegate* 实用程序,例如:

sessionRule.delegateUntilTestEnd(...)
sessionRule.delegateDuringNextWait(...)
sessionRule.waitUntilCalled(...)
sessionRule.forCallbacksDuringWait(...)

这些都采用任意委托对象(可能包含多个委托实现),并根据需要处理委托的安装和清理。

GeckoSessionTestRule 提供的另一组功能允许测试同步 wait* 事件,例如:

sessionRule.waitForJS(...)
sessionRule.waitForResult(...)
sessionRule.waitForPageStop(...)

这些功能通过标记 NextWaitDuringWait 事件与 delegate* 功能协同工作。

例如,测试可以使用 session.loadUri 加载页面,使用 waitForPageStop 等待页面加载完成,然后使用 forCallbacksDuringWait 验证是否调用了预期的委托。

请注意,此处的 DuringWait 始终是指上次调用 wait* 方法并完成执行的时间。

接下来的部分将介绍其工作原理和实现方式。

跟踪委托调用

您可能在上一节中注意到 forCallbacksDuringWait 通过重放等待执行期间发生的委托调用来“向后”移动时间。GeckoSessionTestRule 通过 将代理对象注入 每个委托中,并 代理每个调用 到当前委托,根据 delegate 测试调用。

代理委托 使用 Java 反射的 Proxy.newProxyInstance 方法构建,并在每次执行委托上的方法时接收 回调

GeckoSessionTestRule 维护一个 “默认”委托 列表,这些委托在 GeckoView 中使用,并将 使用反射 将传递给 delegate* 调用的对象与代理委托匹配。

例如,当调用:

sessionRule.delegateUntilTestEnd(object : NavigationDelegate, ProgressDelegate {})

GeckoSessionTestRule 将知道将所有 NavigationDelegateProgressDelegate 调用重定向到 delegateUntilTestEnd 中传递的对象。

重放委托调用

一些委托方法需要嵌入器传递输出数据,这在通过重放委托的调用“回溯时间”时需要格外小心。

例如,每当页面加载时,GeckoView 都会调用 GeckoResult<AllowOrDeny> onLoadRequest(...) 来确定加载是否可以继续。但是,在重放委托时,我们不知道 onLoadRequest 的值是什么(或者测试是否会为此安装委托!)。

GeckoSessionTestRule 所做的是,返回委托方法的默认值,并忽略重放的委托方法的返回值。这可能会让测试编写者感到困惑,例如,以下代码不会阻止页面加载

session.loadUri("https://www.mozilla.org")
sessionRule.waitForPageStop()
sessionRule.forCallbacksDuringWait(object : NavigationDelegate {
  override fun onLoadRequest(session: GeckoSession, request: LoadRequest) :
      GeckoResult<AllowOrDeny>? {
    // this value is ignored
    return GeckoResult.deny()
  }
})

因为当 forCallbacksDuringWait 调用执行时,页面已经加载完毕。

跟踪等待

为了跟踪何时发生 wait 以及何时重放委托调用,GeckoSessionTestRule 存储委托调用列表在一个 List<CallRecord> 对象中,其中 CallRecord 是一个包含足够信息来重放委托调用的类。测试规则将跟踪上次等待的委托调用的起始和结束索引,并在调用 forCallbacksDuringWait重放它

为了等待委托调用发生,测试规则将首先检查使用上面描述的调用记录列表已经执行的委托调用。如果没有任何调用匹配,则它将等待新的调用发生,使用 UiThreadUtils.waitForCondition

waitForCondition 也用于实现其他类型的 wait* 方法,例如 waitForResult,它会等待直到 GeckoResult 执行完毕。

waitForCondition 在 UI 线程上运行,并且同步等待事件发生。它等待的事件通常也在 UI 线程上执行,因此它将自身注入到 Android 事件循环中,在每个事件执行后检查条件。如果事件队列中没有更多事件,则发布一个延迟 100 毫秒的任务以避免阻塞事件循环。

执行 JavaScript

您可能已经从前面部分注意到,测试规则允许测试使用 waitForJS 运行任意 JavaScript 代码。但是,GeckoView API 没有提供这样的 API。

waitForJSevaluateJS 的实现方式将是本节的重点。

嵌入器如何运行 JavaScript

嵌入器访问网页的唯一支持方式是编写一个内置的 WebExtension并安装它。这样做是为了避免重写 WebExtension API 提供的大量与 Web 内容相关的 API。

GeckoView 扩展了 WebExtension API,允许嵌入器通过重载本机消息传递 API(通常不在移动设备上实现)与扩展进行通信。嵌入器可以将自身注册为原生应用,内置扩展将能够交换消息打开端口与嵌入器通信。

这在较小的嵌入器中仍然是一个有争议的话题,特别是对于独立开发者而言,我们内部讨论过公开一个更简单的 API 来运行一次性 JavaScript 代码片段的可能性,类似于 Chromium 的 WebView 提供的功能,但目前还没有开发出来。

测试运行器扩展

为了在 GeckoView 中运行任意 JavaScript,测试运行器会安装一个支持扩展

然后,测试框架建立一个用于在主进程中运行代码的后台脚本端口,以及每个窗口的端口,以便能够在测试网页上运行 JavaScript。

当调用 evaluateJS 时,测试框架会将消息发送到扩展,然后扩展在其上调用 eval并返回结果的 JSON 字符串化版本到测试框架。

测试框架还支持使用 evaluatePromiseJS 的 Promise。它的工作方式类似于 evaluateJS,但它不是返回字符串化的值,而是 eval 调用的返回值设置为 this 对象,并使用随机生成的 UUID 作为键。

this[uuid] = eval(...)

evaluatePromiseJS 然后返回一个 ExtensionPromise Java 对象,该对象在其上有一个 getValue 方法,该方法基本上会执行 await this[uuid] 以在需要时从 Promise 获取值。

超越执行 JavaScript

打破 GeckoView API 限制的一种自然方式是运行所谓的“实验扩展”。实验扩展可以访问用 JavaScript 编写的完整 Gecko 前端,并且对它们可以执行的操作没有限制。实验扩展本质上是 Firefox 中旧的附加组件,功能非常强大,但也非常危险。

测试运行器使用实验为测试提供特权 API,例如 setPrefgetLinkColor(出于隐私考虑,网站通常无法访问这些 API)。

每个特权 API 都作为普通的 Java API公开,并且测试框架不提供运行任意 Chrome 代码的方式,以避免开发人员过度依赖依赖于实现的特权代码。