安卓应用测试学习手册-全-

安卓应用测试学习手册(全)

原文:zh.annas-archive.org/md5/2D763C9A9F15D0F0D25AB1997E2D1779

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

无论您在 Android 设计上投入多少时间,甚至在编程时多么小心,错误是不可避免的,bug 也会出现。这本书将帮助您最小化这些错误对您的 Android 项目的影响,并提高您的开发生产力。它将向您展示那些容易避免的问题,帮助您快速进入测试阶段。

《Android 应用测试指南》是第一本也是唯一一本提供实用介绍的书,旨在通过使用各种方法提高您的 Android 应用程序开发,介绍最常见的技术、框架和工具。

作者在将应用测试技术应用于实际项目方面的经验,使他能够分享关于创建专业级 Android 应用程序的见解。

本书涵盖了测试框架支持的基础知识,以及如测试驱动开发(Test-driven Development)等架构和技术,这是软件开发过程中的一个敏捷组成部分,也是一项早期解决错误的技术。从应用于示例项目最基本的单元测试到更复杂的性能测试,这本书以食谱式的方法提供了 Android 测试领域中最广泛使用技术的详细描述。

作者在其职业生涯中参与了各种开发项目,拥有丰富的经验。所有这些研究和知识都有助于创作一本对任何开发者在 Android 测试世界中导航都有用的资源书。

本书内容涵盖

第一章,测试入门,介绍了不同类型的测试及其一般适用于软件开发项目,特别是 Android。然后继续讲述 Android 平台上的测试、单元测试和 JUnit、创建 Android 测试项目并运行测试。

第二章,使用 Android SDK 理解测试,开始更深入地识别可用于创建测试的构建块。它涵盖了断言、TouchUtils(用于测试用户界面)、模拟对象、测试仪器和 TestCase 类层次结构。

第三章,使用测试食谱烘焙,提供了在应用前面描述的纪律和技术时通常会遇到的常见情况的实用示例。这些示例以食谱风格呈现,以便您可以根据自己的项目进行改编和使用。这些食谱涵盖了 Android 单元测试、活动、应用程序、数据库和 ContentProviders、服务、UI、异常、解析器、内存泄漏,以及使用 Espresso 进行测试的探讨。

第四章,管理你的 Android 测试环境,提供了不同的条件来运行测试。它从创建 Android 虚拟设备(AVD)开始,为被测应用程序提供不同的条件和配置,并使用可用选项运行测试。最后,它引入了猴子作为生成用于测试的模拟事件的方式。

第五章,探索持续集成,介绍了这种敏捷软件工程和自动化技术,旨在通过持续集成和频繁测试来提高软件质量并减少集成更改所需的时间。

第六章,实践测试驱动开发,介绍了测试驱动开发这一纪律。它从一般性复习开始,随后转移到与 Android 平台紧密相关 concepts 概念和技巧。这是一个代码密集型的章节。

第七章,行为驱动开发,介绍了行为驱动开发以及一些概念,例如使用通用词汇表达测试,以及将业务参与者包括在软件开发项目中。

第八章,测试和性能分析,介绍了一系列与基准测试和性能分析相关的概念,从传统的日志语句方法到创建 Android 性能测试并使用分析工具。

第九章,替代测试策略,涵盖了添加代码覆盖率以确保你知道哪些已测试哪些未测试,以及在宿主的 Java 虚拟机上测试,研究 Fest、Spoon 以及 Android 测试的未来,以构建和扩展你的 Android 测试范围。

你需要为这本书准备的内容。

为了能够跟随不同章节中的示例,你需要安装一组常见的软件和工具,以及每个章节特别描述的其他组件,包括它们的下载位置。

所有示例基于以下内容:

  • Mac OSX 10.9.4,已完全更新

  • Java SE 版本 1.6.0_24(构建版本 1.6.0_24-b07)

  • Android SDK 工具,版本 24

  • Android SDK 平台工具,版本 21

  • SDK 平台 Android 4.4,API 20

  • Android 支持库,版本 21

  • Android Studio IDE,版本:1.1.0

  • Gradle 版本 2.2.1

  • Git 版本 1.8.5.2

本书的目标读者

如果你是一个希望测试应用程序或优化应用开发流程的 Android 开发者,那么这本书就是为你而写的。不需要有应用程序测试的先前经验。

约定

在这本书中,你会发现多种文本样式,用于区分不同类型的信息。以下是一些样式示例及其含义的解释。

文本中的代码字词如下所示:"要调用am命令,我们将使用adb shell命令"。

代码块设置如下:

dependencies {
    compile project(':dummylibrary')
}

当我们希望引起你对代码块中某个特定部分的注意时,相关的行或项目会以粗体显示:

fahrenheitEditNumber
.addTextChangedListener(
newFehrenheitToCelciusWatcher(fahrenheitEditNumber, celsiusEditNumber));
}

任何命令行输入或输出都如下写出:

junit.framework.ComparisonFailure: expected:<[]> but was:<[123.45]>
at com.blundell.tut.EditNumberTests.testClear(EditNumberTests.java:31)
at java.lang.reflect.Method.invokeNative(Native Method)
at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:191)

新术语重要词汇以粗体显示。你在屏幕上看到的内容,例如菜单或对话框中的单词,会像这样出现在文本中:"第一个测试执行了对 Forwarding Activity 的Go按钮的点击。"

注意

警告或重要说明会像这样出现在一个框中。

提示

提示和技巧会像这样出现。

读者反馈

我们非常欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢或不喜欢的地方。读者的反馈对我们很重要,因为它能帮助我们开发出你真正能从中获益的图书。

如需向我们发送一般反馈,只需将邮件发送至<feedback@packtpub.com>,并在邮件的主题中提及书籍的标题。

如果你在一个主题上有专业知识,并且有兴趣撰写或参与书籍编写,请查看我们的作者指南:www.packtpub.com/authors

客户支持

既然你现在拥有了 Packt 的一本书,我们有一些事情可以帮助你最大限度地利用你的购买。

下载示例代码

你可以从你在www.packtpub.com的账户下载你所购买的所有 Packt Publishing 书籍的示例代码文件。如果你在其他地方购买了这本书,可以访问www.packtpub.com/support注册,我们会直接将文件通过电子邮件发送给你。

勘误

尽管我们已经尽力确保内容的准确性,但错误仍然会发生。如果你在我们的书中发现了一个错误——可能是文本或代码中的错误——如果你能向我们报告,我们将不胜感激。这样做,你可以避免其他读者的困扰,并帮助我们改进本书后续版本。如果你发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择你的书籍,点击Errata Submission Form链接,并输入你的勘误详情。一旦你的勘误被验证,你的提交将被接受,勘误将被上传到我们的网站或添加到该标题下现有勘误列表中。

若要查看之前提交的勘误信息,请访问www.packtpub.com/books/content/support,在搜索栏中输入书籍名称。所需信息将在勘误部分显示。

盗版

在互联网上,版权材料的盗版问题在所有媒体中持续存在。Packt 出版社非常重视我们版权和许可的保护。如果您在任何形式的互联网上发现我们作品非法复制的版本,请立即提供其位置地址或网站名称,以便我们可以采取补救措施。

如果您发现任何涉嫌盗版的材料,请通过<copyright@packtpub.com>联系我们,并提供相关链接。

我们感谢您帮助保护我们的作者以及我们为您提供有价值内容的能力。

问题咨询

如果您对这本书的任何方面有疑问,可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。

问题咨询

如果您在阅读本书的任何部分遇到问题,可以通过<questions@packtpub.com>联系我们,我们将尽我们所能予以解决。

第一章: 开始测试

首先,我将避免介绍 Android,因为它在许多书中已经有所涉及,我倾向于相信,如果你正在阅读一本涵盖这个更高级话题的书,那么你已经开始了 Android 开发。

我将回顾测试背后的主要概念,以及部署在 Android 上的测试策略的技术、框架和工具。

在此概述之后,我们可以将所学到的概念付诸实践。在本章中,我们将涵盖以下内容:

  • 在 Android 上设置测试的基础设施

  • 使用 JUnit 运行单元测试

  • 创建一个 Android 仪器测试项目

  • 运行多个测试

我们将创建一个简单的 Android 项目及其伴随的测试。主项目将非常基础,以便你可以专注于测试组件。

我建议没有 Android 测试经验的新开发者阅读这本书。如果你在 Android 项目上有更多经验,并且已经在使用测试技术,你可以将这一章作为复习或对概念的再次确认。

为何、什么、如何以及何时进行测试?

你应该明白,早期发现错误可以节省大量的项目资源并降低软件维护成本。这是为你的软件开发项目编写测试的最佳已知原因。生产力的提高将很快显现。

此外,编写测试将使你更深入地理解需求和要解决的问题。对于你不理解的软件,你将无法为其编写测试。

这也是编写测试以清楚地理解遗留代码或第三方代码的方法背后的原因,以及拥有测试基础设施以自信地更改或更新代码库。

你的测试覆盖的代码越多,发现隐藏错误的可能性就越高。

如果在覆盖率分析期间,你发现你的代码某些部分没有被测试,应该添加额外的测试来覆盖这部分代码。

为了帮助实现这一要求,请使用 Jacoco(www.eclemma.org/jacoco/),这是一个开源工具套件,用于测量和报告 Java 代码覆盖率。它支持以下各种覆盖率类型:

  • 方法

覆盖率报告也可以以不同的输出格式获取。Jacoco 在某种程度上得到了 Android 框架的支持,并且可以构建一个 Android 应用程序的 Jacoco 检测版本。

我们将在第九章,替代测试策略中分析在 Android 上使用 Jacoco 的情况,以指导我们实现代码的全面测试覆盖。

此屏幕截图显示了一个 Jacoco 代码覆盖率报告,该报告显示为一个 HTML 文件,当代码经过测试时,显示为绿色行:

为何、什么、如何以及何时进行测试?

默认情况下,Android Studio 不支持 Jacoco gradle 插件;因此,你无法在 IDE 中看到代码覆盖率,所以代码覆盖率必须作为单独的 HTML 报告查看。其他插件,如 Atlassian 的 Clover 或带有 EclEmma 的 Eclipse,也提供了其他选项。

测试应当自动化,并且每次你对代码进行更改或添加时,都应该运行一些或全部的测试,以确保之前满足的所有条件仍然满足,并且新代码能够如预期那样通过测试。

这引导我们介绍了持续集成,这将在第五章《发现持续集成》中详细讨论,它使得测试和构建过程的自动化成为可能。

如果你没有使用自动化测试,实际上将无法把持续集成作为开发过程的一部分,并且很难确保更改不会破坏现有代码。

拥有测试可以防止你在接触代码库时,将新的错误引入已经完成的功能中。这些回归很容易发生,而测试是防止这种情况发生的屏障。此外,你现在可以在编译时捕捉和发现问题,即在你开发时,而不是在用户开始抱怨时收到反馈。

应该测试什么

严格来说,你应该测试你的代码中的每一条语句,但这也取决于不同的标准,可以简化为测试执行的主路径或仅一些关键方法。通常,无需测试那些不可能出错的内容;例如,测试 getters 和 setters 通常没有意义,因为你可能不会在自己的代码上测试 Java 编译器,而且编译器已经执行了其测试。

除了你应该测试的特定于域的功能区域之外,还有一些其他需要考虑的 Android 应用程序区域。我们将在以下部分查看这些内容。

活动生命周期事件

你应该测试你的活动是否正确处理了生命周期事件。

如果你的活动需要在onPause()onDestroy()事件期间保存其状态,并在之后的onCreate(Bundle savedInstanceState)中恢复它,那么你应该能够复现并测试所有这些条件,并验证状态是否正确保存和恢复。

也应该测试配置更改事件,因为其中一些事件会导致当前活动被重新创建。你应该测试事件处理是否正确,以及新创建的活动是否保持了之前的状态。配置更改甚至可以由设备旋转触发,因此你应该测试你的应用程序处理这些情况的能力。

数据库和文件系统操作

应该测试数据库和文件系统操作,以确保操作和任何错误都能被正确处理。这些操作应该在较低的操作系统级别孤立测试,通过ContentProviders在较高级别测试,或者直接从应用程序测试。

为了孤立测试这些组件,Android 在android.test.mock包中提供了一些模拟对象。简单来说,可以将模拟对象视为真实对象的直接替代品,在这里您可以更控制对象的行为。

设备的物理特性

在发布您的应用程序之前,您应该确保它可以在所有不同的设备上运行,或者至少应该检测到不受支持的情况并采取适当的措施。

您应该测试的设备特性包括:

  • 网络功能

  • 屏幕密度

  • 屏幕分辨率

  • 屏幕尺寸

  • 传感器的可用性

  • 键盘和其他输入设备

  • GPS

  • 外部存储

在这方面,Android 模拟器可以发挥重要作用,因为实际上不可能访问到具有所有可能功能组合的所有设备,但您可以为几乎每种情况配置模拟器。然而,如前所述,将最终的测试留给实际设备,以便真实用户可以运行应用程序,从而从真实环境中获得反馈。

测试类型

测试拥有多种框架,它们获得来自 Android SDK 和您选择的 IDE 不同程度上的支持。现在,我们将集中讨论如何使用具有完全 SDK 和 ASide 支持的 instrumented Android 测试框架来测试 Android 应用,稍后,我们将讨论其他选择。

根据采用的测试方法,测试可以在开发过程的任何时间实施。然而,我们将提倡在开发周期的早期阶段进行测试,甚至在完整的需求集被定义和编码过程开始之前。

根据被测试的代码,有几种不同类型的测试。无论其类型如何,测试应该验证一个条件,并将此评估的结果作为一个单一的布尔值返回,以指示测试的成功或失败。

单元测试

单元测试是由程序员编写的,面向其他程序员的测试,它应该隔离被测试的组件,并能以一种可重复的方式进行测试。这就是为什么单元测试和模拟对象通常放在一起的原因。您使用模拟对象来隔离单元与其依赖项,监控交互,并能够多次重复测试。例如,如果您的测试从数据库中删除了一些数据,您可能不希望数据真的被删除,这样在下一次运行测试时数据就找不到了。

JUnit 是 Android 上单元测试的事实标准。它是一个简单的开源框架,用于自动化单元测试,最初由 Erich Gamma 和 Kent Beck 编写。

Android 测试用例使用 JUnit 3(这即将在即将发布的 Google 版本中更改为 JUnit 4,但截至本文撰写之时,我们展示的是使用 JUnit 3 的示例)。这个版本没有注解,并使用内省来检测测试。

一个典型的 Android 仪器化 JUnit 测试可能如下所示:

public class MyUnitTestCase extends TestCase {

    public MyUnitTestCase() {
        super("testSomething");
    }

    public void testSomething() {
        fail("Test not implemented yet");
    }
}

提示

你可以从你在www.packtpub.com的账户下载你所购买的所有 Packt 书籍的示例代码文件。如果你在其他地方购买了这本书,可以访问www.packtpub.com/support注册,我们会直接将文件通过电子邮件发送给你。

以下部分将解释可用于构建测试用例的组件。请注意,这些组件以及与测试用例工作的模式不仅限于单元测试,它们也可以用于后续部分将要讨论的其他测试类型。

setUp()方法

此方法被调用来初始化测试夹具(测试夹具是指测试及其周围代码状态)。

重写此方法,你可以有机会创建对象并初始化测试中将要使用的字段。值得注意的是,此设置在每个测试之前发生。

tearDown()方法

此方法被调用来最终确定测试夹具。

重写它,你可以释放初始化或测试中使用的资源。同样,此方法在每个测试之后被调用。

例如,你可以在该方法中释放数据库或关闭网络连接。

在你的测试方法之前和之后,还有更多可以挂钩的方法,但这些方法很少使用,我们将在遇到时进行解释。

测试方法外部

JUnit 设计的方式是在一次遍历中构建整个测试实例树,然后在第二次遍历中执行测试。因此,测试运行器在测试执行期间会保持对所有测试实例的强引用。这意味着在包含许多测试实例的大型和长时间测试运行中,所有测试在完整测试运行结束之前都不会被垃圾回收。这对于 Android 和在有限设备上进行测试尤为重要,因为有些测试可能不是因为内在的失败,而是因为运行应用程序及其测试所需的内存量超过了设备限制而失败。

因此,如果你在测试中分配了外部或有限资源,如ServicesContentProviders,你有责任释放这些资源。例如,在tearDown()方法中显式地将对象设置为 null,允许在完整测试运行结束之前对其进行垃圾回收。

测试方法内部

所有以test开头的public void方法都将被视为测试。与 JUnit 4 不同,JUnit 3 不使用注解来发现测试,而是通过内省来查找它们的名字。Android 测试框架中提供了一些注解,如@SmallTest@MediumTest@LargeTest,它们不会将一个简单方法转换为测试,而是将它们组织在不同的类别中。最终,你将能够使用测试运行器只运行单个类别的测试。

通常,按照经验法则,以描述性的方式命名你的测试,并使用名词和被测试的条件。同时,记得测试异常和错误值,而不仅仅是测试正面情况。

例如,一些有效的测试和命名可能为:

  • testOnCreateValuesAreLoaded()

  • testGivenIllegalArgumentThenAConversionErrorIsThrown()

  • testConvertingInputToStringIsValid()

在测试执行期间,应将某些条件、副作用或方法返回与预期进行比较。为了简化这些操作,JUnit 提供了一整套assert*方法,用于将测试的预期结果与运行后的实际结果进行比较,如果不满足条件,则抛出异常。然后,测试运行器处理这些异常并显示结果。

这些方法被重载以支持不同的参数,包括:

  • assertTrue()

  • assertFalse()

  • assertEquals()

  • assertNull()

  • assertNotNull()

  • assertSame()

  • assertNotSame()

  • fail()

除了这些 JUnit 断言方法,Android 在两个专门的类中扩展了 Assert,提供了额外的测试:

  • MoreAsserts

  • ViewAsserts

模拟对象

模拟对象是代替调用真实领域对象以实现单独测试单元的模仿对象。

通常,这是为了验证是否调用了正确的方法,但它们也可以帮助你的测试与周围代码隔离,能够独立运行测试并确保可重复性。

Android 测试框架支持模拟对象,这在编写测试时非常有用。你需要提供一些依赖关系才能编译测试。还有一些外部库可以在模拟时使用。

Android 测试框架在android.test.mock包中提供了多个类:

  • MockApplication

  • MockContentProvider

  • MockContentResolver

  • MockContext

  • MockCursor

  • MockDialogInterface

  • MockPackageManager

  • MockResources

几乎平台中任何可能与你的 Activity 交互的组件都可以通过实例化这些类之一来创建。

然而,它们并不是真正的实现,而是存根。你需要扩展这些类之一来创建一个真正的模拟对象并覆盖你想实现的方法。任何你没有覆盖的方法将抛出UnsupportedOperationException

集成测试

集成测试旨在测试各个组件一起工作的情况。已经独立进行单元测试的模块现在被组合在一起,以测试集成情况。

通常,Android 活动需要与系统基础设施集成才能运行。它们需要ActivityManager提供的活动生命周期,以及访问资源、文件系统和数据库。

同样的标准适用于其他需要与系统其他部分交互以完成任务的 Android 组件,如ServicesContentProviders

在所有这些情况下,Android 测试框架提供了一些专门的测试类,便于为这些组件创建测试。

用户界面测试

用户界面测试检查应用程序的视觉表现,例如对话框的外观或当对话框被关闭时 UI 的变化。

如果你的测试涉及到 UI 组件,应该特别考虑。你可能已经知道,在 Android 中只允许主线程修改 UI。因此,使用特殊的注解@UIThreadTest来指示特定的测试应该在那个线程上运行,并且能够修改 UI。另一方面,如果你只想在 UI 线程上运行测试的部分内容,你可以使用Activity.runOnUiThread(Runnable r)方法,该方法提供了相应的Runnable,其中包含测试指令。

还提供了一个帮助类TouchUtils,以辅助 UI 测试的创建,允许生成以下事件发送到视图:

  • 点击

  • 拖动

  • 长按

  • 滚动

  • 点击

  • 触摸

通过这些方式,你实际上可以从测试中远程控制你的应用程序。另外,Android 最近引入了 Espresso 用于 UI 自动化测试,我们将在第三章测试配方烘焙中进行介绍。

功能测试或验收测试

在敏捷软件开发中,功能测试或验收测试通常由业务和质量管理(QA)人员创建,并使用业务领域语言表达。这些是高层次的测试,用于验证用户故事或功能的完整性和正确性。理想情况下,这些测试是通过业务客户、业务分析师、QA、测试人员和开发人员的协作创建的。然而,业务客户(产品所有者)是这些测试的主要所有者。

一些框架和工具可以在这个领域提供帮助,例如 Calabash(calaba.sh)或特别值得一提的是 FitNesse(www.fitnesse.org),它们在一定程度上可以轻松集成到 Android 开发过程中,并允许你创建验收测试并检查结果如下:

功能测试或验收测试

近期,在验收测试中,一种名为行为驱动开发(Behavior-driven Development)的新趋势逐渐流行起来。简而言之,它可以被视为测试驱动开发(Test-driven Development)的近亲。其目标是为商业和技术人员提供一个共同的词汇,以增加相互理解。

行为驱动开发可以表达为一个基于三个原则的活动框架(更多信息可以在 behaviour-driven.org 找到):

  • 商业和技术应当以相同的方式指代同一系统

  • 任何系统都应该对商业有一个明确且可验证的价值

  • 前期的分析、设计和规划,其回报都在递减

为了应用这些原则,商业人员通常会参与用高级语言编写测试案例场景,并使用如jbehavejbehave.org)之类的工具。在以下示例中,这些场景被翻译成了表达相同测试场景的 Java 代码。

测试案例场景

作为这项技术的说明,这里有一个过于简化的例子。

产品所有者编写的场景如下:

Given I'm using the Temperature Converter.
When I enter 100 into Celsius field.
Then I obtain 212 in Fahrenheit field.

它会被翻译成类似这样的东西:

@Given("I am using the Temperature Converter")
public void createTemperatureConverter() {
    // do nothing this is syntactic sugar for readability
}

@When("I enter $celsius into Celsius field")
public void setCelsius(int celsius) {
    this.celsius = celsius;
}

@Then("I obtain $fahrenheit in Fahrenheit field")
public void testCelsiusToFahrenheit(int fahrenheit) {
    assertEquals(fahrenheit, 
                 TemperatureConverter.celsiusToFahrenheit(celsius));
}

这使得程序员和商业用户都能够使用领域语言(在本例中是温度转换),并且都能够将其与日常工作联系起来。

性能测试

性能测试以可重复的方式测量组件的性能特性。如果应用程序的某些部分需要性能改进,最佳的方法是在引入更改前后测量性能。

众所周知,过早的优化弊大于利,因此最好清楚地了解你的更改对整体性能的影响。

Android 2.2 中引入的Dalvik JIT编译器改变了一些在 Android 开发中广泛使用的优化模式。如今,Android 开发者网站上关于性能改进的每一条建议都有性能测试作为支撑。

系统测试

系统作为一个整体进行测试,组件、软件和硬件之间的交互得到锻炼。通常,系统测试包括如下额外的测试类别:

  • GUI 测试

  • 冒烟测试

  • 变异测试

  • 性能测试

  • 安装测试

Android Studio 和其他 IDE 支持

JUnit 完全得到 Android Studio 的支持,它允许你创建经过测试的 Android 项目。此外,你还可以在不离开 IDE 的情况下运行测试并分析结果(在一定程度上)。

这还提供了一个更微妙的优点;能够从 IDE 中运行测试,允许你调试那些行为不正确的测试。

在以下截图中,我们可以看到 ASide 运行了19 个单元测试,耗时 1.043 秒,检测到0错误0失败。每个测试的名称及其持续时间也显示出来。如果出现失败,失败 追踪将显示相关信息,如下面的截图所示:

Android Studio 和其他 IDE 支持

Eclipse IDE 也通过使用 Android Development Tools 插件支持安卓。

即使你不在 IDE 中开发,你也可以找到支持使用 gradle 运行测试的方法(如果你不熟悉这个工具,请查看gradle.org)。测试是通过使用命令gradle connectedAndroidTest运行的。这将安装并在连接的安卓设备上为调试版本运行测试。

这实际上与 Android Studio 在后台使用的方法相同。ASide 将运行 Gradle 命令来构建项目并运行测试,尽管是选择性编译。

Java 测试框架

Java 测试框架是安卓测试的支柱,有时你可以不编写针对安卓特定的代码。这可以是一件好事,因为随着我们继续测试任务,你会注意到我们将安卓框架测试部署到设备上,这对我们测试的速度有影响,即我们从测试通过或失败中获取反馈的速度。

如果你巧妙地架构你的应用程序,你可以创建纯 Java 类,可以在脱离安卓的环境中进行隔离测试。这样做的两个主要好处是提高测试结果反馈的速度,并且可以快速将库和代码片段组合起来创建强大的测试套件,你可以利用其他程序员近十年的 Java 测试经验。

安卓测试框架

安卓提供了一个非常先进的测试框架,它扩展了行业标准 JUnit 库,具有适合实现我们之前提到的所有测试策略和类型的具体特性。在某些情况下,需要额外的工具,但大多数情况下,这些工具的集成是简单直接的。

安卓测试环境最相关的主要特性包括:

  • 安卓扩展了 JUnit 框架,提供访问安卓系统对象的功能

  • 一个允许测试控制和检查应用程序的仪器化框架

  • 常用安卓系统对象的模拟版本

  • 运行单个测试或测试套件的工具,可以选择是否使用仪器化

  • 支持在 Android Studio 和命令行中管理和测试测试项目和测试

仪器化

仪器化框架是测试框架的基础。仪器化控制被测应用程序,并允许注入应用程序运行所需的模拟组件。例如,你可以在应用程序启动之前创建模拟上下文,并让应用程序使用它。

使用这种方法可以控制应用与周围环境的所有交互。你还可以在受限环境中隔离你的应用,以便能够预测某些方法返回的强制值,或者为 ContentProvider 的数据库甚至文件系统内容模拟持久且不变的数据。

一个标准的 Android 项目将在一个关联的源文件夹 androidTest 中拥有它的仪器测试,这创建了一个在应用上运行测试的独立应用。这里没有 AndroidManifest,因为它是自动生成的。你可以在 build.gradle 文件中的 Android 闭包内自定义仪器,这些更改将反映在自动生成的 AndroidManifest 中。但是,如果你选择不做任何更改,你仍然可以使用默认设置运行你的测试。

你可以更改的一些示例包括测试应用包名、你的测试运行器,或者如何切换性能测试特性:

  testApplicationId "com.blundell.something.non.default"
  testInstrumentationRunner  "com.blundell.tut.CustomTestRunner"
  testHandleProfiling false
  testFunctionalTest true
  testCoverageEnabled true

在这里,Instrumentation 包(testApplicationId)与主应用是不同的包。如果你不自己更改这个,它将默认使用你的主应用包,并在后面加上 .test 后缀。

然后,声明了仪器测试运行器,如果你创建自定义注释以允许特殊行为,这将很有帮助;例如,每次测试失败时运行两次。如果没有声明运行器,将使用默认的自定义运行器 android.test.InstrumentationTestRunner

目前,testHandleProfilingtestFunctionalTest 尚未记录且未被使用,因此请留意当我们被告知可以如何使用这些功能时。将 testCoverageEnabled 设置为 true 将允许你使用 Jacoco 收集代码覆盖率报告。我们稍后会回到这个话题。

同时,请注意,被测试的应用和测试本身都是 Android 应用,并安装有相应的 APK。在内部,它们将共享同一个进程,因此可以访问相同的功能集。

当你运行一个测试应用时,活动管理器developer.android.com/intl/de/reference/android/app/ActivityManager.html)使用仪器框架来启动和控制测试运行器,后者又使用仪器来关闭主应用的任何运行实例,启动测试应用,然后在同一进程中启动主应用。这使得测试应用的各个方面能够直接与主应用交互。

Gradle

Gradle 是一个高级构建工具集,它允许你管理依赖项并定义自定义登录以构建你的项目。Android 构建系统是建立在 Gradle 之上的一个插件,正是它为你提供了前面讨论过的特定领域语言,例如设置 testInstrumentationRunner

使用 Gradle 的理念是它允许你从命令行构建你的 Android 应用,而不需要使用 IDE,例如持续集成机器。此外,随着 Gradle 集成到 Android Studio 中的项目构建中,你从 IDE 或命令行获得完全相同的自定义构建配置。

其他好处包括能够自定义和扩展构建过程;例如,每次你的 CI 构建项目时,你可以自动将测试版 APK 上传到 Google Play 商店。你可以使用相同的项目创建具有不同功能的多个 APK,例如,一个针对 Google Play 应用内购买的版本,另一个针对亚马逊应用商店的硬币支付版本。

Gradle 和 Android Gradle 插件是一个强大的组合,因此,在本书的剩余示例中,我们将使用这个构建框架。

测试目标

在你的开发项目的发展过程中,你的测试将针对不同的设备。从在模拟器上的简单性、灵活性和测试速度,到不可避免地在特定设备上进行最终测试,你应当能够在所有这些设备上运行你的应用程序。

也有一些中间情况,例如在本地 JVM 虚拟机上、开发计算机上或根据情况在 Dalvik 虚拟机或活动上运行你的测试。

每种情况都有其优缺点,但好消息是,你有所有这些可用的选择来运行你的测试。

模拟器可能是最强大的目标,因为你几乎可以修改其配置中的每个参数来模拟不同的测试条件。最终,你的应用程序应该能够处理所有这些情况,所以最好提前发现这些问题,而不是在应用程序交付后再发现。

真实设备是性能测试的要求,因为从模拟设备中推断性能测量有些困难。只有在使用真实设备时,你才能享受到真实的用户体验。渲染、滚动、抛动和其他情况在交付应用程序之前应该被测试。

创建 Android 项目

我们将创建一个新的 Android 项目。这可以通过访问 ASide 菜单,选择文件 | 新建项目来完成。这将引导我们通过 wysiwyg 向导来创建项目。

在这个特定情况下,我们为所需的组件名称使用以下值(在屏幕间点击下一步按钮):

  • 应用程序名称:AndroidApplicationTestingGuide

  • 公司域名:blundell.com

  • 形式因素:手机和平板电脑

  • 最小 SDK 版本:17

  • 添加一个活动:空白活动(使用默认名称)

下面的截图显示了表单编辑器开始的参考:

创建 Android 项目

当你点击完成并且应用程序创建后,它将自动在app/src目录下生成androidTest源文件夹,你可以在这里添加你的仪器测试用例。

提示

或者,要为现有的 Gradle Android 项目创建一个 androidTest 文件夹,你可以选择 src 文件夹,然后转到文件 | 新建 | 目录。然后在对话框提示中写入androidTest/java。当项目重建时,该路径将自动添加,以便你可以创建测试。

包资源管理器

创建项目后,项目视图应该与以下截图所示的一个图像类似。这是因为 ASide 有多个展示项目大纲的方式。在左侧,我们可以注意到两个源目录的存在,一个用于测试源,显示为绿色,另一个用于项目源,显示为蓝色。在右侧,我们有新的 Android 项目视图,它试图通过压缩无用的和合并功能相似的文件夹来简化层次结构。

现在我们已经建立了基本的基础设施,是时候开始添加一些测试了,如下面的截图所示:

包资源管理器

现在没有什么可以测试的,但当我们正在建立测试驱动开发(Test-driven Development)的基础时,我们添加了一个虚拟测试,只是为了熟悉这项技术。

AndroidApplicationTestingGuide项目中的src/androidTest/java文件夹是添加测试的完美位置。如果你真的想要,可以声明一个不同的文件夹,但我们坚持使用默认设置。包应该与被测试组件的相应包相同。

目前,我们关注的是测试的概念和位置,而不是测试内容。

创建测试用例

如前所述,我们正在项目的src/androidTest/java文件夹中创建我们的测试用例。

你可以通过右键点击包并选择新建... | Java 类手动创建文件。然而,在这个特定的情况下,我们将利用 ASide 来创建我们的 JUnit 测试用例。打开待测试的类(在本例中,是 MainActivity),并在类名上悬停,直到你看到一个灯泡(或者按Ctrl/Command + 1)。从出现的菜单中选择创建测试

创建测试用例

创建测试用例时,我们应该输入以下这些值:

  • 测试库: JUnit 3

  • 类名: MainActivityTest

  • 超类: junit.framework.TestCase

  • 目标包: com.blundell.tut

  • 超类: junit.framework.TestCase

  • 生成: 选择无

输入所有必需的值后,我们的 JUnit 测试用例创建对话框将如下所示。

如你所见,你也可以检查类的一个方法以生成一个空的测试方法存根。这些存根方法在某些情况下可能很有用,但你要考虑测试应该是一个行为驱动的过程,而不是一个方法驱动的过程。

创建测试用例

我们测试的基本基础设施已经就位;剩下的就是添加一个虚拟测试,以验证一切是否按预期工作。现在我们有了测试用例模板,下一步是开始完善它以满足我们的需求。为此,打开最近创建的测试类并添加testSomething()测试。

我们应该有类似这样的内容:

package com.blundell.tut;

import android.test.suitebuilder.annotation.SmallTest;

import junit.framework.TestCase;

public class MainActivityTest extends TestCase {

    public MainActivityTest() {
        super("MainActivityTest");
    }

    @SmallTest
    public void testSomething() throws Exception {
        fail("Not implemented yet");
    }
}

提示

无参数构造函数是运行从命令行指定的特定测试所必需的,稍后使用 am instrumentation 时会解释这一点。

这个测试将始终失败,并显示消息:尚未实现。为了做到这一点,我们将使用junit.framework.Assert类中的 fail 方法,该方法会使用给定的消息使测试失败。

测试注解

仔细查看测试定义,你可能会注意到我们使用了@SmallTest注解来装饰测试,这是一种组织或分类我们的测试并单独运行它们的方法。

还有一些其他测试可以使用的注解,例如:

注解 描述
@SmallTest 标记为作为小型测试的一部分运行的测试。
@MediumTest 标记为作为中型测试的一部分运行的测试。
@LargeTest 标记为作为大型测试的一部分运行的测试。
@Smoke 标记为作为冒烟测试的一部分运行的测试。android.test.suitebuilder.SmokeTestSuiteBuilder将运行所有带有此注解的测试。
@FlakyTest InstrumentationTestCase类的测试方法上使用此注解。当存在此注解时,如果测试失败,将重新执行测试方法。执行的总次数由容差指定,默认为 1。这对于可能因随时间变化的外部条件而失败的测试很有用。例如,要指定容差为 4,你可以使用以下注解:@FlakyTest(tolerance=4)

| @UIThreadTest | 在InstrumentationTestCase类的测试方法上使用此注解。当存在此注解时,测试方法将在应用程序的主线程(或 UI 线程)上执行。由于在存在此注解时可能无法使用 instrumentation 方法,因此,例如,如果你需要在同一测试中修改 UI 并获取 instrumentation 的访问权限,则可以使用其他技术。在这种情况下,你可以使用Activity.runOnUIThread()方法,它允许你创建任何 Runnable 并在 UI 线程中从你的测试中运行它。|

mActivity.runOnUIThread(new Runnable() {
public void run() {
// do somethings
}
});

|

@Suppress 在不应该包含在测试套件中的测试类或测试方法上使用此注解。此注解可以用在类级别,这样该类中的所有方法都不会包含在测试套件中;或者用在方法级别,仅排除一个或一组方法。

既然我们已经有了测试用例,现在是时候运行它们了,接下来我们将要进行这一步。

运行测试

有多种运行我们的测试的方法,我们将在下面进行分析。

此外,如前文关于注解的部分所述,根据情况,测试可以分组或分类并一起运行。

从 Android Studio 运行所有测试

如果你已经采用 ASide 作为你的开发环境,这可能是最简单的方法。这将运行包中的所有测试。

在你的项目中选择应用模块,然后转到 运行 | (安卓图标) 所有测试

如果没有找到合适的设备或模拟器,系统会提示你启动或连接一个。

测试随后运行,结果会在运行视图中展示,如下面的屏幕截图所示:

从 Android Studio 运行所有测试

在 Android DDMS 视图中,也可以在 LogCat 视图中获得测试执行期间产生的结果和消息的更详细视图,如下面的屏幕截图所示:

从 Android Studio 运行所有测试

从你的 IDE 运行单个测试用例

如果你需要,也可以从 ASide 运行单个测试用例。打开存放测试的文件,右键点击你想要运行的方法名,就像运行所有测试一样,选择 运行 | (安卓图标) testMethodName

当你运行这个时,像往常一样,只有这个测试会被执行。在我们的例子中,我们只有一个测试,所以结果将类似于前面展示的屏幕截图。

注意

这样运行单个测试是一个快捷方式,实际上为你创建了一个针对该方法的特定运行配置。如果你想查看这方面的详细信息,从菜单中选择 运行 | 编辑配置,在 Android 测试 下,你应该能看到你刚刚执行的测试的配置名称。

从模拟器运行

模拟器使用的默认系统映像中安装了 Dev Tools 应用程序,提供了许多便捷的工具和设置。在这些工具中,我们可以找到一个相当长的列表,如下面的屏幕截图所示:

从模拟器运行

现在,我们关注的是Instrumentation,这是我们运行测试的方法。此应用程序列出了所有在项目中定义了 instrumentation 标签测试的已安装包。我们可以根据包名选择我们的测试,如下面的屏幕截图所示:

从模拟器运行

以这种方式运行测试时,结果可以通过 DDMS / LogCat 查看,如前一部分所述。

从命令行运行测试

最后,也可以从命令行运行测试。如果你想要自动化或脚本化这个过程,这很有用。

要运行测试,我们使用 am instrument 命令(严格来说,是 am 命令和 instrument 子命令),它允许我们通过指定包名和其他一些选项来运行测试。

你可能想知道“am”代表什么。它是 Activity Manager 的简称,是 Android 内部基础设施的主要组成部分,系统服务器在启动过程中启动它,并负责管理 Activities 及其生命周期。此外,如我们所见,它也负责 Activity 的测试。

am instrument 命令的一般用法是:

am instrument [flags] <COMPONENT> -r -e <NAME> <VALUE> -p <FILE>-w

下表总结了最常用的选项:

选项 描述
-r 打印原始结果。这有助于收集原始性能数据。
-e <NAME> <VALUE> 通过名称设置参数。我们很快就会讨论其用法。这是一个通用选项参数,允许我们设置<名称, 值>对。
-p <FILE> 将分析数据写入外部文件。
-w 在退出之前等待测试完成。这通常用于命令中。虽然不是强制性的,但非常有用,否则你将无法看到测试结果。

要调用 am 命令,我们将使用 adb shell 命令,或者如果你已经在模拟器或设备上运行了 shell,可以直接在 shell 命令提示符中发出 am 命令。

运行所有测试

此命令行将打开 adb shell,然后运行除性能测试之外的所有测试:

$: adb shell 
#: am instrument -w com.blundell.tut.test/android.test.InstrumentationTestRunner

com.blundell.tut.MainActivityTest:

testSomething的失败:

junit.framework.AssertionFailedError: Not implemented yet

at com.blundell.tut.MainActivityTest.testSomething(MainActivityTest.java:15)
at java.lang.reflect.Method.invokeNative(Native Method)
at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:191)
at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:176)
at android.test.InstrumentationTestRunner.onStart
 (InstrumentationTestRunner.java:554)
at android.app.Instrumentation$InstrumentationThread.run
 (Instrumentation.java:1701)

Test results for InstrumentationTestRunner=.F
Time: 0.002

FAILURES!!!
Tests run: 1,  Failures: 1,  Errors: 0

请注意,使用–w声明的包是你的测试包,而不是被测应用包。

从特定测试用例运行测试

要运行特定测试用例中的所有测试,你可以使用:

$: adb shell 
#: am instrument -w -e class com.blundell.tut.MainActivityTest com.blundell.tut.test/android.test.InstrumentationTestRunner

通过名称运行特定测试

此外,我们可以在命令行中指定要运行的测试:

$: adb shell 
#: am instrument -w -e class com.blundell.tut.MainActivityTest\#testSomething com.blundell.tut.test/android.test.InstrumentationTestRunner

除非我们的测试用例中有一个无参数构造函数,否则不能以这种方式运行此测试;这就是我们之前添加它的原因。

按类别运行特定测试

如前所述,可以使用注解(测试注解)将测试分组到不同的类别中,你可以运行此类别中的所有测试。

可以在命令行中添加以下选项:

选项 描述
-e unit true 这运行所有单元测试。这些测试不是从InstrumentationTestCase派生的(也不是性能测试)。
-e func true 这运行所有功能测试。这些测试是从InstrumentationTestCase派生的。
-e perf true 这包括性能测试。
-e size {small &#124; medium &#124; large} 这将根据添加到测试的注解运行小型、中型或大型测试。
-e annotation <注解名称> 这将运行带有此注解的测试。此选项与大小选项互斥。

在我们的示例中,我们将测试方法testSomething()@SmallTest进行了注解。因此,这个测试被认为属于那个类别,并且当我们指定测试大小为小型时,最终会与其他属于同一类别的测试一起运行。

这个命令行将运行所有带有@SmallTest注解的测试:

$: adb shell 
#: am instrument -w -e size small com.blundell.tut.test/android.test.InstrumentationTestRunner

使用 Gradle 运行测试

你的 Gradle 构建脚本也可以帮助你运行测试,这实际上会在幕后执行前面的命令。Gradle 可以用以下命令运行你的测试:

gradle connectedAndroidTest

创建自定义注解

如果你决定按照除大小之外的其他标准对测试进行排序,可以创建自定义注解,然后在命令行中指定。

例如,假设我们想根据测试的重要性来安排它们,因此我们创建了一个注解@VeryImportantTest,我们将在编写测试的任何类中使用它(例如MainActivityTest):

package com.blundell.tut;

/**
 * Marker interface to segregate important tests
 */
@Retention(RetentionPolicy.RUNTIME)
public @interface VeryImportantTest {
}

接着,我们可以创建另一个测试并用@VeryImportantTest进行注解:

@VeryImportantTest
public void testOtherStuff() {
fail("Also not implemented yet");
}

因此,如我们之前提到的,我们可以将此注解包含在 am instrument 命令行中,只运行带注解的测试:

$: adb shell 
#: am instrument -w -e annotation com.blundell.tut.VeryImportantTest com.blundell.tut.test/android.test. InstrumentationTestRunner

运行性能测试

我们将在第八章,测试和性能分析中回顾性能测试的细节,但在这里,我们将介绍 am instrument 命令可用的选项。

为了在测试运行中包含性能测试,你应该添加这个命令行选项:

  • -e perf true:这包括性能测试

干运行

有时,你可能只需要知道将要运行哪些测试,而不是实际运行它们。

这是你需要添加到命令行的选项:

  • -e log true:这显示将要运行的测试,而不是实际运行它们。

如果你在编写测试脚本或可能构建其他工具,这会很有用。

调试测试

你应该假设你的测试也可能有错误。在这种情况下,适用常规的调试技术,例如,通过 LogCat 添加消息。

如果需要更复杂的调试技术,你应该将调试器附加到测试运行器上。

为了在不放弃 IDE 的便利性的同时做到这一点,并且不需要记住难以记忆的命令行选项,你可以调试运行你的运行配置。这样,你可以在测试中设置断点并使用它。要切换断点,你可以在编辑器中选择所需的行,并在边缘处左键点击。

完成后,你将进入一个标准的调试会话,调试窗口应该可供你使用。

从命令行调试测试也是可能的;你可以使用代码指令等待调试器附加。我们不使用这个命令;如果你需要更多详细信息,可以在(developer.android.com/reference/android/test/InstrumentationTestRunner.html)找到。

其他命令行选项

am instrument 命令接受除了前面提到的<名称, 值>对之外的其它对:

名称
debug true。在代码中设置断点。
package 这是测试应用中一个或多个完全限定包的名称。
class 一个由测试运行器执行的完全限定测试用例类。可选地,这可以包括由哈希(#)与类名分隔的测试方法名称。
coverage true。运行 EMMA 代码覆盖率,并将输出写入可以指定的文件中。我们将在第九章,替代测试策略中详细介绍如何为我们的测试支持 EMMA 代码覆盖率。

总结

我们已经回顾了 Android 测试背后的主要技术和工具。掌握了这些知识后,我们可以开始我们的旅程,以便在我们软件开发项目中利用测试的好处。

到目前为止,我们已经讨论了以下主题:

  • 我们简要分析了测试的原因、内容、方法和时机。现在,既然你已经给予了测试应有的重视,我们将更专注于探索如何进行测试。

  • 我们列举了在项目中可能需要的不同和最常见的测试类型,描述了一些我们可以依赖的测试工具箱中的工具,并提供了一个 JUnit 单元测试的介绍性示例,以便更好地理解我们正在讨论的内容。

  • 我们还使用 Android Studio IDE 和 Gradle 创建了我们第一个带有测试的 Android 项目。

  • 我们还创建了一个简单的测试类来测试项目中的 Activity。我们还没有添加任何有用的测试用例,但添加这些简单的用例是为了验证我们的基础设施。

  • 我们还从 IDE 和命令行运行了这个简单的测试,以了解我们有哪些替代方案。在这个过程中,我们提到了活动管理器及其命令行化身 am。

  • 我们创建了一个自定义注解来排序我们的测试,并演示如何分离或区分测试套件。

在下一章中,我们将更详细地分析提到的技术、框架和工具,并提供它们使用示例。

第二章:理解使用 Android SDK 进行测试

我们现在知道如何在 Android 项目中创建测试以及如何运行这些测试。现在是时候更深入地挖掘,以识别可用于创建更有用的测试的构建块。

在本章中,我们将涵盖以下主题:

  • 常见断言

  • 视图断言

  • 其他断言类型

  • 用于测试用户界面的辅助工具

  • 模拟对象

  • 检测

  • TestCase类层次结构

  • 使用外部库

我们将分析这些组件,并在适用的情况下展示它们的使用示例。本章中的示例故意从包含它们的原始 Android 项目中分离出来。这样做是为了让您集中精力只关注所呈现的主题,尽管可以按照后面的说明下载包含在一个项目中的完整示例。现在,我们关注的是树木,而不是森林。

在呈现的示例中,我们将识别可重用的常见模式,这将帮助您为自己的项目创建测试。

演示应用程序

已经创建了一个非常简单的应用程序,以演示本章中一些测试的使用。该应用程序的源代码可以从 XXXXXXXXXXXX 下载。

下面的屏幕截图展示了这个应用程序的运行情况:

演示应用程序

在阅读本章中的测试解释时,您可以随时参考提供的演示应用程序,以查看测试的实际效果。前面的简单应用程序有一个可点击的链接、文本输入、点击按钮和定义的布局 UI,我们可以逐一测试这些。

断言深入理解

断言是检查可以评估的条件的方法。如果条件不满足,断言方法将抛出异常,从而终止测试的执行。

JUnit API 包含了Assert类。这是所有TestCase类的基类,其中包含多种用于编写测试的断言方法。这些继承的方法用于测试各种条件,并且为了支持不同的参数类型而被重载。根据检查的条件,它们可以分为以下不同的组,例如:

  • assertEquals

  • assertTrue

  • assertFalse

  • assertNull

  • assertNotNull

  • assertSame

  • assertNotSame

  • fail

被测试的条件非常明显,通过方法名称可以轻松识别。可能需要关注的是assertEquals()assertSame()。前者在对象上使用时,断言传递的参数对象通过调用对象的equals()方法是相等的。后者断言两个对象引用同一个对象。如果某些情况下,类没有实现equals(),那么assertEquals()assertSame()将执行相同的操作。

当测试中的一个断言失败时,将抛出AssertionFailedException,这表示测试已经失败。

在开发过程中,有时您可能需要创建一个当时并未实现的测试。但是,您希望标记该测试的创建已推迟(我们在第一章,开始测试中添加了测试方法存根)。在这种情况下,您可以使用总是失败并使用自定义消息指明条件的fail()方法:

  public void testNotImplementedYet() {
    fail("Not implemented yet");
  }

然而,fail()还有另一个常见的用途值得一提。如果我们需要测试一个方法是否抛出异常,我们可以用 try-catch 块包围代码,并在没有抛出异常时强制失败。例如:

public void testShouldThrowException() {
    try {
      MyFirstProjectActivity.methodThatShouldThrowException();
      fail("Exception was not thrown");
    } catch ( Exception ex ) {
      // do nothing
    }
  }

注意

JUnit4 有一个注解@Test(expected=Exception.class),这取代了在测试异常时使用fail()的需要。使用这个注解,只有当预期的异常被抛出时,测试才会通过。

自定义消息

值得知道的是,所有的assert方法都提供了一个包含自定义String消息的重载版本。如果断言失败,测试运行器将打印这个自定义消息,而不是默认消息。

这背后的前提是,有时,通用错误消息没有透露足够的信息,而且测试失败的原因并不明显。自定义消息在查看测试报告时可以极大地帮助轻松识别失败,因此强烈建议作为最佳实践使用这个版本。

以下是一个使用此建议的简单测试示例:

public void testMax() {
int a = 10;
int b = 20;

int actual = Math.max(a, b);

String failMsg = "Expected: " + b + " but was: " + actual;
assertEquals(failMsg, b, actual);
}

在前面的示例中,我们可以看到另一个实践,这将帮助您轻松组织和理解测试。这就是为保存实际值的变量使用明确的名称。

注意

还有其他一些库提供了更好的默认错误消息以及更流畅的测试界面。其中一个值得一看的是 Fest(code.google.com/p/fest/)。

静态导入

尽管基本的断言方法是从 Assert 基类继承而来的,但某些其他断言需要特定的导入。为了提高测试的可读性,有一个模式是从相应类静态导入断言方法。使用这种模式,而不是:

  public void testAlignment() {
 int margin = 0;
   ...
 android.test.ViewAsserts.assertRightAligned(errorMsg, editText, margin);
  }

我们可以通过添加静态导入来简化它:

import static android.test.ViewAsserts.assertRightAligned;

public void testAlignment() {
   int margin = 0;
 assertRightAligned(errorMsg, editText, margin);
}

视图断言

之前引入的断言处理了各种类型的参数,但它们仅用于测试简单条件或简单对象。

例如,我们有assertEquals(short expected, short actual)来测试short值,assertEquals(int expected, int actual)来测试整数值,assertEquals(Object expected, Object actual)来测试任何Object实例等。

通常,在 Android 中测试用户界面时,你会遇到更复杂的方法,这些方法主要与视图有关。在这方面,Android 提供了一个包含大量断言的类android.test.ViewAsserts(更多详情请见developer.android.com/reference/android/test/ViewAsserts.html),用于测试视图之间以及它们在屏幕上的绝对和相对位置关系。

这些方法也提供了重载以提供不同的条件。在断言中,我们可以找到以下内容:

  • assertBaselineAligned:此断言用于判断两个视图是否基于基线对齐,即它们的基线是否在同一 y 位置。

  • assertBottomAligned:此断言用于判断两个视图是否底部对齐,即它们的底部边缘是否在同一 y 位置。

  • assertGroupContains:此断言用于判断指定组是否包含一个特定的子视图,且仅包含一次。

  • assertGroupIntegrity:此断言用于判断指定组的完整性。子视图数量应大于等于 0,每个子视图都不应为空。

  • assertGroupNotContains:此断言用于判断指定组不包含特定的子视图。

  • assertHasScreenCoordinates:此断言用于判断一个视图在可见屏幕上是否有特定的 x 和 y 位置。

  • assertHorizontalCenterAligned:此断言用于判断测试视图相对于参考视图是否水平居中对齐。

  • assertLeftAligned:此断言用于判断两个视图是否左对齐,即它们的左侧边缘是否在同一 x 位置。也可以提供一个可选的边距。

  • assertOffScreenAbove:此断言用于判断指定视图是否位于可见屏幕上方。

  • assertOffScreenBelow:此断言用于判断指定视图是否位于可见屏幕下方。

  • assertOnScreen:此断言用于判断一个视图是否在屏幕上。

  • assertRightAligned:此断言用于判断两个视图是否右对齐,即它们的右侧边缘是否在同一 x 位置。也可以指定一个可选的边距。

  • assertTopAligned:此断言用于判断两个视图是否顶部对齐,即它们的顶部边缘是否在同一 y 位置。也可以指定一个可选的边距。

  • assertVerticalCenterAligned:此断言用于判断测试视图相对于参考视图是否垂直居中对齐。

下面的示例展示了如何使用ViewAssertions来测试用户界面布局:

  public void testUserInterfaceLayout() {
    int margin = 0;
    View origin = mActivity.getWindow().getDecorView();
    assertOnScreen(origin, editText);
    assertOnScreen(origin, button);
    assertRightAligned(editText, button, margin);
  }

assertOnScreen方法使用一个原点来查找请求的视图。在这种情况下,我们使用顶层窗口装饰视图。如果由于某些原因,你不需要在层次结构中那么高,或者这种方法不适用于你的测试,你可以在层次结构中使用另一个根视图,例如View.getRootView(),在我们的具体示例中,将是editText.getRootView()

更多的断言

如果之前审查的断言似乎不足以满足您的测试需求,Android 框架中仍然包含另一个类,涵盖了其他情况。这个类是MoreAssertsdeveloper.android.com/reference/android/test/MoreAsserts.html)。

这些方法也支持不同的参数类型重载。在断言中,我们可以找到以下几种:

  • assertAssignableFrom:此断言一个对象可以分配给一个类。

  • assertContainsRegex:此断言预期的 Regex 匹配指定String的任何子字符串。如果不符合则使用指定的消息失败。

  • assertContainsInAnyOrder:此断言指定的Iterable包含精确预期的元素,但顺序不限。

  • assertContainsInOrder:此断言指定的Iterable包含精确预期的元素,并且顺序相同。

  • assertEmpty:此断言一个Iterable是空的。

  • assertEquals:这是针对一些 JUnit 断言中未涉及的Collections

  • assertMatchesRegex:此断言指定的Regex必须完全匹配String,如果不匹配则提供消息失败。

  • assertNotContainsRegex:此断言指定的 Regex 不匹配指定 String 的任何子字符串,如果不匹配则提供消息失败。

  • assertNotEmpty:此断言一些在 JUnit 断言中未涉及的集合不是空的。

  • assertNotMatchesRegex:此断言指定的Regex不精确匹配指定的 String,如果匹配则提供消息失败。

  • checkEqualsAndHashCodeMethods:这是一个用于一次性测试equals()hashCode()结果的工具。这测试应用在两个对象上的equals()是否与指定结果匹配。

下面的测试检查通过点击 UI 按钮调用的首字母大写方法在调用过程中是否出现错误:

@UiThreadTest
public void testNoErrorInCapitalization() {
String msg = "capitalize this text";
editText.setText(msg);

button.performClick();

String actual = editText.getText().toString();
String notExpectedRegexp = "(?i:ERROR)";
String errorMsg = "Capitalization error for " + actual;
assertNotContainsRegex(errorMsg, notExpectedRegexp, actual);
}

如果您不熟悉正则表达式,花些时间访问developer.android.com/reference/java/util/regex/package-summary.html,这是值得的!

在这个特定情况下,我们希望以不区分大小写的方式(为此设置标志i)匹配结果中包含的单词ERROR。也就是说,如果由于某种原因,在我们的应用程序中大小写不起作用,并且包含错误消息,我们可以使用这个断言检测这种情况。

注意

请注意,由于这是一个修改用户界面的测试,我们必须使用@UiThreadTest进行注解;否则,它将无法从不同的线程修改 UI,并且我们会收到以下异常:

INFO/TestRunner(610): ----- begin exception -----
INFO/TestRunner(610): android.view.ViewRoot$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
INFO/TestRunner(610):     at android.view.ViewRoot.checkThread(ViewRoot.java:2932)
[...]
INFO/TestRunner(610):     at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:1447)
INFO/TestRunner(610): ----- end exception -----

TouchUtils 类

有时,在测试 UI 时,模拟不同类型的触摸事件会很有帮助。这些触摸事件可以通过多种方式生成,但可能使用android.test.TouchUtils是最简单的。这个类提供了可重用的方法,在从InstrumentationTestCase派生的测试用例中生成触摸事件。

这些特性方法允许与被测 UI 进行模拟交互。TouchUtils类提供了基础设施,以使用正确的 UI 或主线程注入事件,因此无需特殊处理,你也不需要在测试中使用@UIThreadTest注解。

TouchUtils 支持以下操作:

  • 点击一个视图并释放

  • 轻敲一个视图(触摸它并快速释放)

  • 长点击一个视图

  • 拖动屏幕

  • 拖动视图

下面的测试代表了TouchUtils的典型使用方法:

    public void testListScrolling() {
        listView.scrollTo(0, 0);

        TouchUtils.dragQuarterScreenUp(this, activity); 
        int actualItemPosition = listView.getFirstVisiblePosition();

        assertTrue("Wrong position", actualItemPosition > 0);
    }

这个测试执行以下操作:

  • 将列表重新定位到开始位置,以便从已知条件开始

  • 滚动列表

  • 检查第一个可见位置,以确认它是否正确滚动

即使是最复杂的 UI 也可以通过这种方式进行测试,它可以帮助你检测可能影响用户体验的各种条件。

模拟对象

我们在第一章《开始测试》中看到了 Android 测试框架提供的模拟对象,并评估了关于不使用真实对象将我们的测试与周围环境隔离开来的担忧。

下一章将介绍测试驱动开发(Test-driven Development),如果我们是测试驱动开发的纯粹主义者,我们可以讨论使用模拟对象的问题,更倾向于使用真实对象。Martin Fowler 在他的精彩文章《Mocks aren't stubs》中称这两种风格为经典的和模拟的测试驱动开发二分法,该文章可以在www.martinfowler.com/articles/mocksArentStubs.html在线阅读。

独立于这场讨论,我们将介绍模拟对象作为可用的构建块之一,因为有时在测试中使用模拟对象是推荐、可取、有用甚至不可避免的。

Android SDK 在子包android.test.mock中提供了以下类以帮助我们:

  • MockApplication:这是Application类的模拟实现。所有方法均不具备功能,并抛出UnsupportedOperationException

  • MockContentProvider:这是ContentProvider的模拟实现。所有方法均不具备功能,并抛出UnsupportedOperationException

  • MockContentResolver:这是ContentResolver类的模拟实现,它将测试代码与真实内容系统隔离开来。所有方法均不具备功能,并抛出UnsupportedOperationException

  • MockContext:这是一个模拟上下文类,可以用来注入其他依赖项。所有方法均不具备功能,并抛出UnsupportedOperationException

  • MockCursor:这是一个模拟的游标类,它将测试代码与实际的游标实现隔离开来。所有方法都是非功能性的,当使用时会抛出UnsupportedOperationException

  • MockDialogInterface:这是DialogInterface类的模拟实现。所有方法都是非功能性的,当使用时会抛出UnsupportedOperationException

  • MockPackageManager:这是PackageManager类的模拟实现。所有方法都是非功能性的,当使用时会抛出UnsupportedOperationException

  • MockResources:这是一个模拟的Resources类。

所有这些类都具有非功能性的方法,当使用时将抛出UnsupportedOperationException。如果你需要使用其中一些方法,或者你发现你的测试因这个Exception而失败,你应该扩展这些基类之一并提供所需的功能。

MockContext 概述

这个模拟可以用来将其他依赖项、模拟或监视器注入到被测试的类中。扩展这个类以提供你想要的行为,覆盖相应的方法。Android SDK 提供了一些预构建的模拟Context对象,每个对象都有单独的使用场景。

IsolatedContext 类

在你的测试中,你可能会发现需要将正在测试的 Activity 与其他 Android 组件隔离开来,以防止不必要的交互。这可以是完全隔离,但有时,这种隔离避免了与其他组件的交互,为了让你的 Activity 仍然正确运行,需要与系统建立一些联系。

对于这些情况,Android SDK 提供了android.test.IsolatedContext,这是一个模拟的Context,它不仅阻止了与大部分底层系统的交互,还满足了与其他包或组件(如ServicesContentProviders)交互的需求。

文件和数据库操作的替代路径

在某些情况下,我们只需要能够为文件和数据库操作提供一条替代路径。例如,如果我们正在实际设备上测试应用程序,我们可能不希望影响现有的数据库,而是使用我们自己的测试数据。

这些情况可以利用另一个不属于android.test.mock子包,而是属于android.test的类,即RenamingDelegatingContext

这个类允许我们通过在构造函数中指定的前缀来修改对文件和数据库的操作。所有其他操作都被委托给必须在构造函数中指定的委托上下文。

假设我们正在测试的Activity使用了一个我们想要控制的数据库,可能引入特殊内容或固定数据来驱动我们的测试,我们不想使用真实的文件。在这种情况下,我们创建一个RenamingDelegatingContext类,它指定了一个前缀,我们的未更改的 Activity 将使用这个前缀来创建任何文件。

例如,如果我们的 Activity 尝试访问一个名为birthdays.txt的文件,而我们提供了一个指定前缀testRenamingDelegatingContext类,那么在测试时,这个相同的 Activity 将改为访问文件testbirthdays.txt

MockContentResolver 类

MockContentResolver类以非功能方式实现所有方法,如果你尝试使用它们,它会抛出UnsupportedOperationException异常。这个类的目的是将测试与真实内容隔离开来。

假设你的应用程序使用一个ContentProvider类来为你的 Activity 提供信息。你可以使用ProviderTestCase2为这个ContentProvider创建单元测试,我们稍后会进行分析,但是当我们尝试为 Activity 针对ContentProvider编写功能测试或集成测试时,就不太明显应该使用哪种测试用例。最明显的选择是ActivityInstrumentationTestCase2,尤其是如果你的功能测试模拟用户体验,因为你可能需要sendKeys()方法或类似的方法,而这些方法在这些测试中是可用的。

你可能遇到的第一个问题是,不清楚在哪里注入一个MockContentResolver以使你的测试能够使用ContentProvider的测试数据。也无法注入一个MockContext

这个问题将在第三章,使用测试配方烘焙中得到解决,其中提供了更多细节。

TestCase 基类

这是 JUnit 框架所有其他测试用例的基类。它实现了我们之前分析的示例中的基本方法(setUp())。TestCase类还实现了junit.framework.Test接口,这意味着它可以作为一个 JUnit 测试来运行。

你的 Android 测试用例应该始终扩展TestCase或其子类。

默认构造函数

所有测试用例都需要一个默认构造函数,因为有时,根据使用的测试运行器,这是唯一被调用的构造函数,也用于序列化。

根据文档,这个方法不打算被“凡人”在没有调用setName(String name)的情况下使用。

因此,为了取悦众神,通常在这个构造函数中使用一个默认的测试用例名称,并在之后调用给定的名称构造函数:

public class MyTestCase extends TestCase {
 public MyTestCase() {
 this("MyTestCase Default Name");
 }

   public MyTestCase(String name) {
      super(name);
   }
}

提示

下载示例代码

你可以从你在www.packtpub.com的账户下载你所购买的 Packt Publishing 书籍的所有示例代码文件。如果你在其他地方购买了这本书,可以访问www.packtpub.com/support注册,文件会直接通过电子邮件发送给你。

给定名称构造函数

这个构造函数接受一个名称作为参数来标记测试用例。它将出现在测试报告中,并在你尝试确定失败的测试来自哪里时非常有帮助。

setName()方法

有些扩展了TestCase的类没有提供给定名称的构造函数。在这种情况下,唯一的选择是调用setName(String name)

AndroidTestCase基类

这个类可以用作通用 Android 测试用例的基类。

当你需要访问 Android 资源、数据库或文件系统中的文件时,请使用它。上下文存储在此类的字段中,名为mContext,如果需要,可以在测试中使用,或者也可以使用getContext()方法。

基于此类的测试可以使用Context.startActivity()启动多个 Activity。

Android SDK 中有各种扩展了此基类的测试用例:

  • ApplicationTestCase<T extends Application>

  • ProviderTestCase2<T extends ContentProvider>

  • ServiceTestCase<T extends Service>

使用AndroidTestCase Java 类时,你继承了一些可以使用的基断言方法;让我们更详细地看看这些方法。

assertActivityRequiresPermission()方法

此方法的签名如下:

public void assertActivityRequiresPermission(String packageName, String className, String permission)

描述

这个断言方法检查特定 Activity 的启动是否受到特定权限的保护。它需要以下三个参数:

  • packageName:这是一个指示要启动的活动包名的字符串

  • className:这是一个指示要启动的活动类的字符串

  • permission:这是一个包含要检查的权限的字符串

启动 Activity 后,预期会出现SecurityException,它指出错误消息中缺少所需的权限。此断言实际上并不处理活动的实例化,因此不需要 Instrumentation。

示例

这个测试检查MyContactsActivity活动中写入外部存储所需的android.Manifest.permission.WRITE_EXTERNAL_STORAGE权限:

public void testActivityPermission() {
  String pkg = "com.blundell.tut";
  String activity =  PKG + ".MyContactsActivity";
  String permission = android.Manifest.permission.CALL_PHONE;
  assertActivityRequiresPermission(pkg, activity, permission);
}

提示

总是使用android.Manifest.permission中描述权限的常量,而不是字符串,这样如果实现发生更改,你的代码仍然有效。

assertReadingContentUriRequiresPermission方法

此方法的签名如下:

public void assertReadingContentUriRequiresPermission(Uri uri, String permission)

描述

这个断言方法检查从特定 URI 读取是否需要作为参数提供的权限。

它需要以下两个参数:

  • uri:这是需要查询权限的 Uri

  • permission:这是一个包含要查询的权限的字符串

如果生成了一个包含指定权限的SecurityException类,则此断言被验证。

示例

这个测试尝试读取联系人信息,并验证是否生成了正确的SecurityException

  public void testReadingContacts() {
    Uri URI = ContactsContract.AUTHORITY_URI;
    String PERMISSION = android.Manifest.permission.READ_CONTACTS;
    assertReadingContentUriRequiresPermission(URI, PERMISSION);
  }

assertWritingContentUriRequiresPermission()方法

此方法的签名如下:

public void assertWritingContentUriRequiresPermission (Uri uri, String permission)

描述

这个断言方法检查向特定Uri插入是否需要作为参数提供的权限。

它需要以下两个参数:

  • uri:这是需要查询权限的 Uri

  • permission:这是一个包含查询权限的字符串

如果生成了一个包含指定权限的SecurityException类,则此断言被验证。

示例

这个测试尝试写入联系人并验证是否生成了正确的SecurityException

  public void testWritingContacts() {
  Uri uri = ContactsContract.AUTHORITY_URI;
   String permission = android.Manifest.permission.WRITE_CONTACTS;
  assertWritingContentUriRequiresPermission(uri, permission);
}

Instrumentation(检测)

在应用程序代码运行之前,系统会实例化 Instrumentation,从而允许监控系统与应用之间的所有交互。

与许多其他 Android 应用组件一样,instrumentation 的实现是在AndroidManifest.xml文件中的<instrumentation>标签下描述的。然而,随着 Gradle 的出现,这一过程现已自动化,我们可以在应用的build.gradle文件中更改 instrumentation 的属性。测试的AndroidManifest文件将会自动生成:

defaultConfig {
  testApplicationId 'com.blundell.tut.tests'
testInstrumentationRunner  "android.test.InstrumentationTestRunner"
}

如果您没有声明前面代码中提到的值,则它们也是默认值,这意味着您不需要这些参数就可以开始编写测试。

testApplicationId属性定义了测试包的名称。默认情况下,它是测试包名称下的应用+ tests。您可以使用testInstrumentationRunner声明自定义测试运行器。如果您想以自定义方式运行测试,例如并行测试执行,这将非常有用。

开发中还有许多其他参数,我建议您关注 Google Gradle 插件网站(tools.android.com/tech-docs/new-build-system/user-guide)。

ActivityMonitor内部类

如前所述,Instrumentation 类用于监控系统与应用程序或测试中的 Activities 之间的交互。内部类Instrumentation.ActivityMonitor允许监控应用程序内的单个 Activity。

示例

假设我们的 Activity 中有一个TextView,它包含一个 URL 并设置了自动链接属性:

  <TextView 
       android:id="@+id/link
       android:layout_width="match_parent"
    android:layout_height="wrap_content"
       android:text="@string/home"
    android:autoLink="web" " />

如果我们想验证点击超链接后是否正确跳转并调用了某个浏览器,我们可以创建如下测试:

  public void testFollowLink() {
        IntentFilter intentFilter = new IntentFilter(Intent.ACTION_VIEW);
        intentFilter.addDataScheme("http");
        intentFilter.addCategory(Intent.CATEGORY_BROWSABLE);

        Instrumentation inst = getInstrumentation();
        ActivityMonitor monitor = inst.addMonitor(intentFilter, null, false);
        TouchUtils.clickView(this, linkTextView);
        monitor.waitForActivityWithTimeout(3000);
        int monitorHits = monitor.getHits();
        inst.removeMonitor(monitor);

        assertEquals(1, monitorHits);
    } 

在这里,我们将执行以下操作:

  1. 为那些会打开浏览器的意图创建一个IntentFilter

  2. 根据基于IntentFilter类的Instrumentation添加一个监控。

  3. 点击超链接。

  4. 等待活动(希望是浏览器)。

  5. 验证监控点击次数是否增加。

  6. 移除监控。

使用监控,我们可以测试与系统和其他 Activity 的最复杂的交互。这是创建集成测试的一个非常强大的工具。

InstrumentationTestCase

InstrumentationTestCase类是各种测试用例的直接或间接基类,这些测试用例可以访问 Instrumentation。以下是最重要的直接和间接子类的列表:

  • ActivityTestCase

  • ProviderTestCase2<T extends ContentProvider>

  • SingleLaunchActivityTestCase<T extends Activity>

  • SyncBaseInstrumentation

  • ActivityInstrumentationTestCase2<T extends Activity>

  • ActivityUnitTestCase<T extends Activity>

InstrumentationTestCase类在android.test包中,并扩展了junit.framework.TestCase,后者又扩展了junit.framework.Assert

launchActivitylaunchActivityWithIntent方法

这些实用方法用于从测试中启动活动。如果没有使用第二个选项指定 Intent,将使用默认的 Intent:

public final T launchActivity (String pkg, Class<T> activityCls, Bundle extras)

注意

模板类参数TactivityCls中使用,并作为返回类型,将其使用限制为该类型的活动。

如果你需要指定一个自定义的 Intent,你可以使用以下代码,它还添加了intent参数:

public final T launchActivityWithIntent (String pkg, Class<T> activityCls, Intent intent)

sendKeyssendRepeatedKeys方法

在测试活动的 UI 时,你将需要模拟与基于 qwerty 的键盘或 DPAD 按钮的交互,以发送按键来完成字段、选择快捷方式或在不同的组件间导航。

这就是sendKeyssendRepeatedKeys的不同用途。

sendKeys有一个接受整数值作为按键的版本。它们可以从KeyEvent类中定义的常量中获得。

例如,我们可以这样使用sendKeys方法:

    public void testSendKeyInts() {
        requestMessageInputFocus();
        sendKeys(
                KeyEvent.KEYCODE_H,
                KeyEvent.KEYCODE_E,
                KeyEvent.KEYCODE_E,
                KeyEvent.KEYCODE_E,
                KeyEvent.KEYCODE_Y,
                KeyEvent.KEYCODE_DPAD_DOWN,
                KeyEvent.KEYCODE_ENTER);
        String actual = messageInput.getText().toString();

        assertEquals("HEEEY", actual);
    }

在这里,我们发送HEY字母键,然后使用它们的整数值发送ENTER键到被测试的活动。

或者,我们可以通过连接我们想要发送的按键来创建一个字符串,忽略KEYCODE前缀,并用最终被忽略的空格分隔它们:

      public void testSendKeyString() {
        requestMessageInputFocus();

        sendKeys("H 3*E Y DPAD_DOWN ENTER");
        String actual = messageInput.getText().toString();

        assertEquals("HEEEY", actual);
    }

在这个测试中,我们与前一个测试做了完全相同的事情,但我们使用了String "H 3* EY DPAD_DOWN ENTER"。请注意,String中的每个键都可以用重复因子前缀和*以及要重复的键。我们在前面的例子中使用了3*E,这与E E E相同,即字母E三次。

如果我们的测试需要发送重复的按键,还有一种专门为这种情况设计的替代方法:

public void testSendRepeatedKeys() {
        requestMessageInputFocus();

        sendRepeatedKeys(
                1, KeyEvent.KEYCODE_H,
                3, KeyEvent.KEYCODE_E,
                1, KeyEvent.KEYCODE_Y,
                1, KeyEvent.KEYCODE_DPAD_DOWN,
                1, KeyEvent.KEYCODE_ENTER);
        String actual = messageInput.getText().toString();

        assertEquals("HEEEY", actual);
    }

这是用另一种方式实现的相同测试。重复次数在每次按键前。

runTestOnUiThread帮助方法

runTestOnUiThread方法是一个帮助方法,用于在 UI 线程上运行测试的一部分。我们在requestMessageInputFocus()方法内部使用了这个方法;这样我们可以在使用Instrumentation.waitForIdleSync()等待应用程序空闲之前,将焦点设置在我们的 EditText 上。此外,runTestOnUiThread方法会抛出异常,所以我们必须处理这种情况:

private void requestMessageInputFocus() {
        try {
            runTestOnUiThread(new Runnable() {
                @Override
                public void run() {
                    messageInput.requestFocus();
                }
            });
        } catch (Throwable throwable) {
            fail("Could not request focus.");
        }
        instrumentation.waitForIdleSync();
    }

如我们之前讨论的,若要在 UI 线程上运行测试,我们可以使用@UiThreadTest注解。然而,有时我们只需要将测试的部分内容在 UI 线程上运行,因为测试的其他部分不适合在 UI 线程上运行,例如数据库调用,或者我们使用其他提供 UI 线程基础设施的帮助方法,例如TouchUtils方法。

ActivityTestCase 类

这主要是一个包含其他访问 Instrumentation 的测试用例的通用代码的类。

如果您正在实现特定行为的测试用例,而现有的替代方案不符合您的需求,可以使用这个类。这意味着除非您想为其他测试实现一个新的基类,否则您不太可能使用这个类。例如,考虑一个场景,谷歌推出一个新组件,而您想围绕它编写测试(如SuperNewContentProvider)。

如果情况不是这样,您可能会发现以下选项更适合您的需求:

  • ActivityInstrumentationTestCase2<T extends Activity>

  • ActivityUnitTestCase<T extends Activity>

抽象类android.test.ActivityTestCase扩展了android.test.InstrumentationTestCase,并为其他不同的测试用例(如android.test.ActivityInstrumentationTestCaseandroid.test.ActivityInstrumentationTestCase2android.test.ActivityUnitTestCase)提供基类。

注意

android.test.ActivityInstrumentationTestCase测试用例自 Android API Level 3(Android 1.5)起已被弃用,不应用于新项目中。尽管它早已被弃用,但其自动导入的名称仍然很常见,因此要小心!

scrubClass 方法

scrubClass方法是该类中的受保护方法之一:

protected void scrubClass(Class<?> testCaseClass)

它在之前讨论的几个测试用例实现中的tearDown()方法中被调用,以清理可能作为非静态内部类实例化的类变量,从而避免保留对它们的引用。

这是为了防止大型测试套件出现内存泄漏。

如果在访问这些类变量时遇到问题,将抛出IllegalAccessException

ActivityInstrumentationTestCase2 类

ActivityInstrumentationTestCase2类可能是您编写功能性 Android 测试用例最常使用的类。它提供了对单个 Activity 的功能测试。

这个类可以访问 Instrumentation,并通过调用InstrumentationTestCase.launchActivity()使用系统基础结构创建被测 Activity。创建后,可以操作和监控 Activity。

如果您需要在启动 Activity 之前提供自定义 Intent,可以在调用getActivity()之前使用setActivityIntent(Intent intent)注入一个 Intent。

这个测试用例对于测试通过用户界面的交互非常有用,因为可以注入事件以模拟用户行为。

构造函数

这个类只有一个公开的非弃用构造函数,如下所示:

ActivityInstrumentationTestCase2(Class<T> activityClass)

它应该使用与类模板参数相同的 Activity 类的 Activity 实例来调用。

setUp方法

setUp方法是初始化测试案例字段和其他需要初始化的固定组件的确切位置。

这是一个示例,展示了您可能在测试用例中反复出现的某些模式:

 @Override
 protected void setUp() throws Exception {
   super.setUp();
   // this must be called before getActivity()
   // disabling touch mode allows for sending key events
   setActivityInitialTouchMode(false);

   activity = getActivity();
   instrumentation = getInstrumentation();
   linkTextView = (TextView) activity.findViewById(R.id.main_text_link);
   messageInput = (EditText) activity.findViewById(R.id.main_input_message);
   capitalizeButton = (Button) activity.findViewById(R.id.main_button_capitalize);
 } 

我们执行以下操作:

  1. 调用超类方法。这是 JUnit 模式,在这里应该遵循以确保正确操作。

  2. 禁用触摸模式。为了使其生效,这应该在 Activity 创建之前完成,通过调用getActivity()。它将测试中的 Activity 的初始触摸模式设置为禁用。触摸模式是 Android UI 的一个基本概念,在developer.android.com/guide/topics/ui/ui-events.html#TouchMode中有讨论。

  3. 使用getActivity()启动 Activity。

  4. 获取 Instrumentation。我们能够访问 Instrumentation 是因为ActivityInstrumentationTestCase2继承了InstrumentationTestCase

  5. 查找 Views 并设置字段。在这些操作中,请注意使用的R类来自目标包,而不是测试包。

tearDown方法

通常,这个方法会清理在setUp中初始化的内容。例如,如果您在测试之前创建了一个集成测试,设置了一个模拟 Web 服务器,那么您可能想在之后将其拆除以释放资源。

在此示例中,我们确保我们使用的对象被处置:

@Override  
protected void tearDown() throws Exception {
    super.tearDown();
      myObject.dispose();
}

ProviderTestCase2<T>

这是一个旨在测试ContentProvider类的测试用例。

ProviderTestCase2类也扩展了AndroidTestCase。类模板参数T表示正在测试的ContentProvider。此测试的实现使用了IsolatedContextMockContentResolver,这些我们在本章前面介绍过的是模拟对象。

构造函数

这个类只有一个公开的非弃用构造函数。如下所示:

ProviderTestCase2(Class<T> providerClass, String providerAuthority)

应该使用与类模板参数相同的ContentProvider类的ContentProvider类实例来调用它。

第二个参数是提供程序的权限,通常在ContentProvider类中定义为AUTHORITY常量。

一个示例

这是一个典型的ContentProvider测试示例:

public void testQuery() {
    String segment = "dummySegment";
    Uri uri = Uri.withAppendedPath(MyProvider.CONTENT_URI, segment);
    Cursor c = provider.query(uri, null, null, null, null);
    try {
      int actual = c.getCount();

       assertEquals(2, actual);
    } finally {
        c.close();
  }
}

在此测试中,我们期望查询返回一个包含两行(这只是一个适用于您特定情况的行数示例)的 Cursor,并断言此条件。

通常,在setUp方法中,我们获取对示例中的mProvider提供者的引用,使用getProvider()

有趣的是,由于这些测试使用了 MockContentResolverIsolatedContext,实际的数据库内容并未受到影响,我们还可以运行像这样的破坏性测试:

public void testDeleteByIdDeletesCorrectNumberOfRows() {
    String segment = "dummySegment";
    Uri uri = Uri.withAppendedPath(MyProvider.CONTENT_URI, segment);

    int actual = provider.delete(uri, "_id = ?", new String[]{"1"});

    assertEquals(1, actual);
}

这个测试从数据库中删除了一些内容,但之后数据库会被恢复到初始内容,以免影响其他测试。

ServiceTestCase<T>

这是一个专门用于测试服务的测试案例。这个类中还包括了锻炼服务生命周期的方法,如 setupServicestartServicebindServiceshutDownService

构造函数

这个类只有一个公开的、未被弃用的构造函数。如下所示:

ServiceTestCase(Class<T> serviceClass)

它应该使用 Service 类的一个实例来调用,该实例与作为类模板参数的 Service 相同。

TestSuiteBuilder.FailedToCreateTests

TestSuiteBuilder.FailedToCreateTests 类是一个特殊的 TestCase 类,用于指示在 build() 步骤期间发生的失败。也就是说,在测试套件创建期间,如果检测到错误,你会收到一个异常,如下所示,这表示构建测试套件失败:

INFO/TestRunner(1): java.lang.RuntimeException: Exception during suite construction
INFO/TestRunner(1):     at android.test.suitebuilder.TestSuiteBuilder$FailedToCreateTests.testSuiteConstructionFailed(TestSuiteBuilder.java:239)
INFO/TestRunner(1):     at java.lang.reflect.Method.invokeNative(Native Method)
[...]
INFO/TestRunner(1):     at android.test.InstrumentationTestRunner.onStart(InstrumentationTestRunner.java:520)
INFO/TestRunner(1):     at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:1447)

在测试项目中使用库

你的 Android 项目可能需要一个外部 Java 库或 Android 库。现在,我们将解释如何将这些库整合到你的项目中,以便进行测试。请注意,以下内容解释了本地模块(Android 库)的使用方法,但同样的规则也适用于外部 JAR(Java 库)文件或外部 AAR(Android 库)文件。

假设在一个 Activity 中,我们正在从一个属于库的类中创建对象。为了我们的示例,假设这个库叫做 dummyLibrary,提到的类是 Dummy

所以我们的 Activity 会像这样:

import com.blundell.dummylibrary.Dummy;

public class MyFirstProjectActivity extends Activity {
    private Dummy dummy;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        final EditText messageInput = (EditText) findViewById(R.id.main_input_message);
        Button capitalizeButton = (Button) findViewById(R.id.main_button_capitalize);
        capitalizeButton.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                String input = messageInput.getText().toString();
                messageInput.setText(input.toUpperCase());
            }
        });

        dummy = new Dummy();
    }

    public Dummy getDummy() {
        return dummy;
    }

    public static void methodThatShouldThrowException() throws Exception {
        throw new Exception("This is an exception");
    }

}

这个库是一个 Android AAR 模块,因此应该按照常规方式添加到你的 build.gradle 依赖项中:

dependencies {
    compile project(':dummylibrary')
}

如果这是一个外部库,你会将 project(':dummylibrary') 替换为 'com.external.lib:name:version'

现在,让我们创建一个简单的测试。根据我们以往的经验,我们知道如果需要测试一个 Activity,应该使用 ActivityInstrumentationTestCase2,这正是我们将要做的。我们的简单测试将如下所示:

public void testDummy() {
  assertNotNull(activity.getDummy());
}

前面的代码中的测试在第一次运行时通过了!注意,在不久前(Gradle 之前),测试甚至无法编译。我们不得不采取各种措施,将测试库添加到我们的 Android 测试项目中,或者使 JAR/AAR 文件可以从我们的主项目导出。现在是停下来反思 Gradle 和 Android Studio 强大功能的好时机,它们为我们免费提供了许多手动设置。

概述

我们研究了创建测试的最相关的构建块和可重用模式。在这个过程中,我们:

  • 理解了 JUnit 测试中常见的断言

  • 解释了 Android SDK 中找到的专业断言

  • 探索了 Android 中的模拟对象及其在 Android 测试中的应用

  • 示例展示了在 Android SDK 中可用的不同测试用例的使用方法

既然我们已经拥有了所有的构建模块,现在是时候开始创建越来越多的测试,以获得掌握这项技术所需的经验。

下一章将为您提供在 Android 上何时以及何处使用不同测试用例的示例。这将让我们在了解在具有特定测试场景时应采用何种测试方法方面拥有更广泛的专业知识。

第三章:测试方法的应用

本章提供了多个常见情况的实用示例,这些示例应用了前几章描述的纪律和技术。示例以易于跟随的方式呈现,因此你可以调整并用于自己的项目。

本章将涵盖以下主题:

  • 安卓单元测试

  • 测试活动和应用程序

  • 测试数据库和内容提供者

  • 测试本地和远程服务

  • 测试用户界面

  • 测试异常

  • 测试解析器

  • 测试内存泄漏

  • 使用 Espresso 进行测试

在本章之后,你将有一个参考,可以将不同的测试方法应用到你的项目中,以应对不同的情境。

安卓单元测试

有些情况下,你确实需要隔离测试应用程序的部分内容,与底层系统的联系很少。在安卓中,系统是活动框架(Activity framework)。在这种情况下,我们必须选择一个在测试层次结构中足够高的基类,以移除一些依赖,但又不能太高,以至于我们需要负责一些基本的基础设施,如实例化上下文(Context),例如。

在这些情况下,候选基类是AndroidTestCase,因为这样可以在不考虑活动(Activities)的情况下使用上下文(Context)和资源(Resources):

public class AccessPrivateDataTest extends AndroidTestCase {

   public void testAccessAnotherAppsPrivateDataIsNotPossible()  {
        String filesDirectory = getContext().getFilesDir().getPath();
        String privateFilePath = filesDirectory + 
"/data/com.android.cts.appwithdata/private_file.txt";
        try {
            new FileInputStream(privateFilePath);
            fail("Was able to access another app's private data");
        } catch (FileNotFoundException e) {
            // expected
        }
   }
}

提示

本例基于Android 兼容性测试套件(CTS)。CTS 旨在为应用开发者提供一个一致的 Android 硬件和软件环境,无论原始设备制造商如何。

AccessPrivateDataTest类扩展了AndroidTestCase,因为这是一个不需要系统基础设施的单元测试。在这种情况下,我们不能直接使用TestCase,因为我们稍后会用到getContext()

这个测试方法testAccessAnotherAppsPrivateDataIsNotPossible()测试了对另一个包私有数据的访问,如果可以访问则测试失败。为此,捕获了预期的异常,如果异常没有发生,则会使用自定义消息调用fail()。测试看似简单,但你可以看到这对于防止无意中的安全错误非常有效。

测试活动和应用程序

在这里,我们涵盖了一些在日常测试中会遇到的常见情况,包括处理意图(Intents)、偏好设置(Preferences)和上下文(Context)。你可以根据具体需求调整这些模式。

模拟应用程序和偏好设置

在安卓术语中,应用程序是指需要维护全局应用状态时使用的基类。完整的包名是android.app.Application。在处理共享偏好设置时可以使用。

我们期望那些更改这些偏好设置值的测试不会影响实际应用程序的行为。如果没有正确的测试框架,这些测试可能会删除将偏好值存储为共享偏好的应用程序中的用户账户信息。这听起来可不是个好主意。因此,我们真正需要的是模拟一个Context,它同时也能模拟对SharedPreferences的访问。

我们最初的尝试可能是使用RenamingDelegatingContext,但不幸的是,它并不模拟SharedPreferences,尽管它已经很接近了,因为它模拟了数据库和文件系统的访问。所以首先,我们需要模拟对共享偏好的访问。

提示

每当遇到一个新类(如RenamingDelegatingContext)时,阅读相关的 Java 文档以了解框架开发者期望如何使用它是个不错的主意。更多信息,请参考developer.android.com/reference/android/test/RenamingDelegatingContext.html

RenamingMockContext 类

让我们创建一个专门的ContextRenamingDelegatingContext类是一个很好的起点,因为我们之前提到过,数据库和文件系统的访问将被模拟。问题是怎样模拟对SharedPreferences的访问。

请记住,RenamingDelegatingContext,顾名思义,将所有操作委托给一个Context。所以我们的问题的根源就在这个Context中。当从Context访问SharedPreferences时,你会使用getSharedPreferences(String name, int mode)。为了改变这个方法的工作方式,我们可以在RenamingMockContext内部重写它。现在我们有了控制权,我们可以用我们的测试前缀来添加名称参数,这意味着当我们的测试运行时,它们将写入与主应用程序不同的偏好设置文件:

public class RenamingMockContext extends RenamingDelegatingContext {

    private static final String PREFIX = "test.";

    public RenamingMockContext(Context context) {
        super(context, PREFIX);
    }

    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        return super.getSharedPreferences(PREFIX + name, mode);
    }
}

现在,我们可以完全控制偏好设置、数据库和文件的存储方式。

模拟上下文

我们有RenamingMockContext这个类。现在,我们需要一个使用它的测试。由于我们将要测试应用程序,测试的基类将是ApplicationTestCase。这个测试用例提供了一个框架,你可以在这个框架中在一个受控环境中测试应用程序类。它为应用程序的生命周期提供基本支持,以及钩子来注入各种依赖并控制应用程序测试的环境。使用setContext()方法,我们可以在创建应用程序之前注入RenamingMockContext

我们将要测试一个名为TemperatureConverter的应用程序。这是一个简单的应用程序,用于将摄氏度转换为华氏度,反之亦然。我们将在第六章,实践测试驱动开发中讨论更多关于这个应用程序的开发。现在,这些细节不是必需的,因为我们专注于测试场景。TemperatureConverter应用程序将把任何转换的小数位数存储为共享偏好设置。因此,我们将创建一个测试来设置小数位数,然后检索它以验证其值:

public class TemperatureConverterApplicationTests extends ApplicationTestCase<TemperatureConverterApplication> {

    public TemperatureConverterApplicationTests() {
        this("TemperatureConverterApplicationTests");
    }

    public TemperatureConverterApplicationTests(String name) {
        super(TemperatureConverterApplication.class);
        setName(name);
    }

    public void testSetAndRetreiveDecimalPlaces() {
        RenamingMockContext mockContext = new RenamingMockContext(getContext());
        setContext(mockContext);
        createApplication();
        TemperatureConverterApplication application = getApplication();

        application.setDecimalPlaces(3);

        assertEquals(3, application.getDecimalPlaces());
    }
}

我们使用TemperatureConverterApplication模板参数扩展了ApplicationTestCase

然后,我们使用了在第二章中讨论的给定名称构造函数模式,了解使用 Android SDK 的测试

在这里,我们没有使用setUp()方法,因为类中只有一个测试——正如他们所说,你不会需要它。有一天,如果你要向这个类添加另一个测试,这时你可以重写setUp()并移动行为。这遵循了 DRY 原则,即不要重复自己,这会导致软件更易于维护。因此,在测试方法顶部,我们创建模拟上下文并使用setContext()方法为此测试设置上下文;我们使用createApplication()创建应用程序。你需要确保在createApplication之前调用setContext,因为这是你获得正确实例化顺序的方式。现在,实际测试所需行为的代码设置小数位数,检索它,并验证其值。就是这样,使用RenamingMockContext让我们控制SharedPreferences。每当请求SharedPreference时,该方法将调用委派上下文,为名称添加前缀。应用程序使用的原始SharedPreferences类保持不变:

public class TemperatureConverterApplication extends Application {
    private static final int DECIMAL_PLACES_DEFAULT = 2;
    private static final String KEY_DECIMAL_PLACES = ".KEY_DECIMAL_PLACES";

    private SharedPreferences sharedPreferences;

    @Override
    public void onCreate() {
        super.onCreate();
        sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
    }

    public void setDecimalPlaces(int places) {
        Editor editor = sharedPreferences.edit();
        editor.putInt(KEY_DECIMAL_PLACES, places);
        editor.apply();
    }

    public int getDecimalPlaces() {
        return sharedPreferences.getInt(KEY_DECIMAL_PLACES, DECIMAL_PLACES_DEFAULT);
    }
}

我们可以通过为TemperatureConverterApplication类提供一些共享偏好设置中的值,运行应用程序,然后执行测试,并最终验证执行测试后该值未受影响,以确保我们的测试不会影响应用程序。

测试活动(Testing activities)

下一个示例展示了如何使用ActivityUnitTestCase<Activity>基类完全独立地测试活动。第二个选择是ActivityInstrumentationTestCase2<Activity>。然而,前者允许你创建一个活动但不将其附加到系统,这意味着你不能启动其他活动(你是一个活动的单一单元)。这种父类的选择不仅要求你在设置时更加小心注意,同时也为被测试的活动提供了更大的灵活性和控制。这种测试旨在测试一般的活动行为,而不是活动实例与系统其他组件的交互或任何与 UI 相关的测试。

首先要明确,下面是被测试的类。这是一个带有一个按钮的简单活动。当按下此按钮时,它会触发一个意图来启动拨号器并结束自己:

public class ForwardingActivity extends Activity {
    private static final int GHOSTBUSTERS = 999121212;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_forwarding);
        View button = findViewById(R.id.forwarding_go_button);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = new Intent("tel:" + GHOSTBUSTERS);
                startActivity(intent);
                finish();
            }
        });
    }
}

对于我们的测试case,我们扩展了ActivityUnitTestCase<ForwardingActivity>,正如我们之前提到的,作为一个Activity类的单元测试。这个被测试的活动将脱离系统,因此它仅用于测试其内部方面,而不是与其他组件的交互。在setUp()方法中,我们创建了一个意图,用于启动我们被测试的活动,即ForwardingActivity。注意getInstrumentation()的使用。此时在setUp()方法中的活动上下文getContext类仍然是 null:

public class ForwardingActivityTest extends ActivityUnitTestCase<ForwardingActivity> {
    private Intent startIntent;

    public ForwardingActivityTest() {
        super(ForwardingActivity.class);
    }

    @Override
    protected void setUp() throws Exception {
        super.setUp();
        Context context = getInstrumentation().getContext();
        startIntent = new Intent(context, ForwardingActivity.class);
    }

现在设置完成了,我们可以继续进行我们的测试:

public void testLaunchingSubActivityFiresIntentAndFinishesSelf() {
Activity activity = startActivity(startIntent, null, null);
View button = activity.findViewById(R.id.forwarding_go_button);

button.performClick();

assertNotNull(getStartedActivityIntent());
assertTrue(isFinishCalled());
}

第一个测试对 Forwarding 活动的Go按钮进行点击。该按钮的onClickListener类调用startActivity(),并带有一个定义了将要启动的新Activity的意图。执行此操作后,我们验证用于启动新活动的Intent不为 null。getStartedActivityIntent()方法返回了如果被测试的活动调用了startActivity(Intent)startActivityForResult(Intent, int)所使用的意图。接下来,我们断言finish()被调用,通过验证FinishCalled()的返回值来做到这一点,如果被测试活动中的finish方法之一(finish()finishFromChild(Activity)finishActivity(int))被调用,它将返回true

public void testExampleOfLifeCycleCreation() {
  Activity activity = startActivity(startIntent, null, null);

  // At this point, onCreate() has been called, but nothing else
  // so we complete the startup of the activity
  getInstrumentation().callActivityOnStart(activity);
  getInstrumentation().callActivityOnResume(activity);

  // At this point you could test for various configuration aspects
  // or you could use a Mock Context 
  // to confirm that your activity has made
  // certain calls to the system and set itself up properly.

  getInstrumentation().callActivityOnPause(activity);

  // At this point you could confirm that 
  // the activity has paused properly,
  // as if it is no longer the topmost activity on screen.

    getInstrumentation().callActivityOnStop(activity);

  // At this point, you could confirm that 
  // the activity has shut itself down appropriately,
  // or you could use a Mock Context to confirm that 
  // your activity has released any
  // system resources it should no longer be holding.

  // ActivityUnitTestCase.tearDown() is always automatically called
  // and will take care of calling onDestroy().
 }

第二个测试可能是这个测试案例中更有趣的测试方法。这个测试案例演示了如何执行活动生命周期。启动活动后,onCreate()会自动调用,然后我们可以通过手动调用其他生命周期方法来进行测试。为了能够调用这些方法,我们使用了这个测试的Intrumentation。同时,我们不手动调用onDestroy(),因为tearDown()会为我们调用它。

让我们逐步了解代码。这个方法以之前分析过的测试相同的方式启动 Activity。Activity 启动后,系统会自动调用其 onCreate() 方法。然后我们使用 Instrumentation 来调用其他生命周期方法,以完成被测试 Activity 的启动。这些对应于 Activity 生命周期中的 onStart()onResume()

现在 Activity 已经完全启动,是时候测试我们感兴趣的那些方面了。一旦完成,我们可以按照生命周期的其他步骤进行。请注意,这个示例测试在这里并没有断言任何内容,只是指出了如何逐步执行生命周期。为了完成生命周期,我们调用了 onPause()onStop()。我们知道,onDestroy() 会被 tearDown() 自动调用,因此避免了它。

这个测试代表了一个测试框架。你可以用它来隔离测试你的 Activities,以及测试与生命周期相关的案例。注入模拟对象还可以方便地测试 Activity 的其他方面,比如访问系统资源。

测试文件、数据库和内容提供者

一些测试案例需要执行数据库或 ContentProvider 操作,很快就需要模拟这些操作。例如,如果我们正在实机上测试一个应用程序,我们不想干扰该设备上应用程序的正常运行,尤其是如果我们更改可能被多个应用程序共享的值。

这些案例可以利用另一个不属于 android.test.mock 包,而是属于 android.test 的模拟类,即 RenamingDelegatingContext

请记住,这个类允许我们模拟文件和数据库操作。在构造函数中提供的缀会在修改这些操作的目标时使用。所有其他操作都委托给你指定的委托上下文。

假设我们正在测试的 Activity 使用了一些我们希望在某种方式下控制的文件或数据库,可能是为了引入特殊内容来驱动我们的测试,而我们不想或不能使用真实的文件或数据库。在这种情况下,我们创建一个指定前缀的 RenamingDelegatingContext。我们使用这个前缀提供模拟文件,并引入我们需要驱动测试的任何内容,被测试的 Activity 可以毫无修改地使用它们。

保持我们的 Activity 不变(即不修改它以从不同的来源读取数据)的优势在于,这样可以确保所有测试的有效性。如果我们引入了一个只为测试而设计的改变,我们将无法确保在实际条件下,Activity 的行为是相同的。

为了说明这个情况,我们将创建一个极其简单的 Activity。

MockContextExampleActivity 活动在 TextView 中显示文件的内容。我们想要演示的是,在 Activity 正常运行时与处于测试状态时,它如何显示不同的内容:

public class MockContextExampleActivity extends Activity {
    private static final String FILE_NAME = "my_file.txt";

    private TextView textView;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_mock_context_example);

        textView = (TextView) findViewById(R.id.mock_text_view);
        try {
            FileInputStream fis = openFileInput(FILE_NAME);
            textView.setText(convertStreamToString(fis));
        } catch (FileNotFoundException e) {
            textView.setText("File not found");
        }
    }

    private String convertStreamToString(java.io.InputStream is) {
   Scanner s = new Scanner(is, "UTF-8").useDelimiter("\\A");
       return s.hasNext() ? s.next() : "";
    }

    public String getText() {
        return textView.getText().toString();
    }
}

这是我们的简单活动。它读取 my_file.txt 文件的内容并将其显示在 TextView 上。它还会显示可能发生的任何错误。显然,在真实场景中,你会比这有更好的错误处理。

我们需要为此文件准备一些内容。创建文件最简单的方法可能如下面的代码所示:

$ adb shell 
$ echo "This is real data" > data/data/com.blundell.tut/files/my_file.txt

$ echo "This is *MOCK* data" > /data/data/com.blundell.tut/files/test.my_file.txt

我们创建了两个不同的文件,一个名为 my_file.txt,另一个名为 test.my_file.txt,内容不同。后者表示它是一个模拟内容。如果你现在运行前面的活动,你会看到这是真实数据,因为它是从预期的文件 my_file.txt 中读取的。

下面的代码演示了在我们的活动测试中使用这个模拟数据:

public class MockContextExampleTest 
extends ActivityUnitTestCase<MockContextExampleActivity> {

private static final String PREFIX = "test.";
private RenamingDelegatingContext mockContext;

public MockContextExampleTest() {
super(MockContextExampleActivity.class);
}

@Override
protected void setUp() throws Exception {
super.setUp();
mockContext = new RenamingDelegatingContext(getInstrumentation().getTargetContext(), PREFIX);
mockContext.makeExistingFilesAndDbsAccessible();
}

public void testSampleTextDisplayed() {
setActivityContext(mockContext);

   startActivity(new Intent(), null, null);

assertEquals("This is *MOCK* data\n", getActivity().getText());
}
}

MockContextExampleTest 类扩展了 ActivityUnitTestCase,因为我们要对 MockContextExampleActivity 进行隔离测试,并且我们将注入一个模拟上下文;在这种情况下,注入的上下文是作为依赖的 RenamingDelegatingContext

我们的夹具包括模拟上下文 mockContextRenamingDelegatingContext,使用通过 getInstrumentation().getTargetContext() 获取的目标上下文。请注意,运行仪器化的上下文与被测试活动的上下文是不同的。

这里有一个基本步骤——由于我们希望让现有的文件和数据库可供这个测试使用,因此我们必须调用 makeExistingFilesAndDbsAccessible()

然后,我们的名为 testSampleTextDisplayed() 的测试通过使用 setActivityContext() 注入模拟上下文。

提示

在调用 startActivity() 启动被测活动之前,你必须调用 setActivityContext() 来注入一个模拟上下文。

然后,通过使用刚刚创建的空白意图调用 startActivity() 启动活动。

我们通过使用我们添加到活动中的 getter 来获取 TextView 中持有的文本值。我绝不建议在真实项目中仅仅为了测试而改变生产代码(即暴露 getter),因为这可能导致错误、其他开发者的错误使用模式和安全问题。然而,这里,我们是在展示使用 RenamingDelegatingContext 而不是测试正确性。

最后,获取的文本值与字符串 This is MOCK* data 进行了对比。这里需要注意的是,用于此测试的值是测试文件内容,而不是真实文件内容。

浏览器提供者测试

这些测试基于 Android 开源项目(AOSP)的浏览器模块。AOSP 有很多很好的测试示例,使用它们作为这里的例子可以让你不必编写大量用于设置测试场景的样板代码。它们旨在测试浏览器书签的一部分方面,内容提供者,这是 Android 平台(不是 Chrome 应用,而是默认的浏览器应用)包含的标准浏览器的一部分:

public class BrowserProviderTests extends AndroidTestCase {
    private List<Uri> deleteUris;

    @Override
    protected void setUp() throws Exception {
       super.setUp();
        deleteUris = new ArrayList<Uri>();
    }

    @Override
    protected void tearDown() throws Exception {
        for (Uri uri : deleteUris) {
            deleteUri(uri);
        }
        super.tearDown();
    }
}

注意

AndroidTestCase. The BrowserProviderTests class extends AndroidTestCase because a Context is needed to access the provider content.

setUp()方法中创建的夹具创建了一个Uris列表,用于跟踪每个测试在tearDown()方法结束时需要删除的插入的Uris。开发者本可以使用一个模拟内容提供者来避免这个麻烦,以保持测试与系统的隔离。无论如何,tearDown()方法遍历这个列表并删除存储的Uris。这里不需要重写构造函数,因为AndroidTestCase不是一个参数化类,我们也不需要在其中进行特殊操作。

现在是测试时间:

public void testHasDefaultBookmarks() {
  Cursor c = getBookmarksSuggest("");
  try {
    assertTrue("No default bookmarks", c.getCount() > 0);
  } finally {
    c.close();
  }
}

testHasDefaultBookmarks()方法是一个测试,用于确保数据库中始终存在一些默认书签。启动时,游标遍历通过调用getBookmarksSuggest("")获得的默认书签,这返回一个未经过滤的书签游标;这就是内容提供者查询参数为""的原因:

public void testPartialFirstTitleWord() {
   assertInsertQuery(
"http://www.example.com/rasdfe", "nfgjra sdfywe", "nfgj");
}

testPartialFirstTitleWord()方法以及其他三个类似的方法(这里未显示的testFullFirstTitleWord()testFullFirstTitleWordPartialSecond()testFullTitle())测试书签的插入。为此,它们使用书签的 URL、标题和查询调用assertInsertQuery()assertInsertQuery()方法将书签添加到书签提供者中,插入作为参数给出的指定标题的 URL。返回的Uri被验证不为空且不完全是默认的。最后,Uri被插入到tearDown()中要删除的Uri实例列表中。以下代码可以在显示的实用方法中看到:

public void testFullTitleJapanese() {
String title = "\u30ae\u30e3\u30e9\u30ea\u30fc\u30fcGoogle\u691c\u7d22";
assertInsertQuery("http://www.example.com/sdaga", title, title);
}

注意

Unicode 是一个计算行业标准,旨在一致且唯一地编码全世界书面语言中使用的字符。Unicode 标准使用十六进制来表示一个字符。例如,值\u30ae 表示片假名字母 GI(ギ)。

我们有多个测试旨在验证此书签提供者对于除了英语之外的其他地区和语言的利用情况。这些特定案例涵盖了书签标题中日语的使用情况。测试testFullTitleJapanese()以及这里未显示的其他两个测试,即testPartialTitleJapanese()testSoundmarkTitleJapanese()是之前使用 Unicode 字符引入的测试的日语版本。建议在不同的条件下测试应用程序的组件,就像在这种情况下,使用具有不同字符集的其他语言。

接下来有几个实用方法。这些是在测试中使用的工具。我们之前简要介绍了assertInsertQuery(),现在让我们看看其他方法:

private void assertInsertQuery(String url, String title, String query) {
        addBookmark(url, title);
        assertQueryReturns(url, title, query);
    }
    private void addBookmark(String url, String title) {
        Uri uri = insertBookmark(url, title);
        assertNotNull(uri);
        assertFalse(BOOKMARKS_URI.equals(uri));
        deleteUris.add(uri);
    }
    private Uri insertBookmark(String url, String title) {
        ContentValues values = new ContentValues();
        values.put("title", title);
        values.put("url", url);
        values.put("visits", 0);
        values.put("date", 0);
        values.put("created", 0);
        values.put("bookmark", 1);
        return getContext().getContentResolver().insert(BOOKMARKS_URI, values);
    }

private void assertQueryReturns(String url, String title, String query) {
  Cursor c = getBookmarksSuggest(query);
  try {
    assertTrue(title + " not matched by " + query, c.getCount() > 0);
    assertTrue("More than one result for " + query, c.getCount() == 1);
    while (c.moveToNext()) {
      String text1 = getCol(c, SearchManager.SUGGEST_COLUMN_TEXT_1);
      assertNotNull(text1);
      assertEquals("Bad title", title, text1);
      String text2 = getCol(c, SearchManager.SUGGEST_COLUMN_TEXT_2);
      assertNotNull(text2);
      String data = getCol(c, SearchManager.SUGGEST_COLUMN_INTENT_DATA);
      assertNotNull(data);
      assertEquals("Bad URL", url, data);
    }
  } finally {
    c.close();
  }
}

private String getCol(Cursor c, String name) {
  int col = c.getColumnIndex(name);
  String msg = "Column " + name + " not found, " 
               + "columns: " + Arrays.toString(c.getColumnNames());
  assertTrue(msg, col >= 0);
  return c.getString(col);
}

private Cursor getBookmarksSuggest(String query) {
  Uri suggestUri = Uri.parse("content://browser/bookmarks/search_suggest_query");
  String[] selectionArgs = {query};
  Cursor c = getContext().getContentResolver().query(suggestUri, null, "url LIKE ?", selectionArgs, null);
  assertNotNull(c);
  return c;
}

private void deleteUri(Uri uri) {
  int count = getContext().getContentResolver().delete(uri, null, null);
  assertEquals("Failed to delete " + uri, 1, count);
}

assertInsertQuery()方法在addBookmark()之后调用assertQueryReturns(urltitlequery),以验证getBookmarksSuggest(query)返回的游标是否包含预期的数据。这个期望可以概括为:

  • 查询返回的行数大于 0

  • 查询返回的行数等于 1

  • 返回行中的标题不为空

  • 查询返回的标题与方法的参数完全相同

  • 对于建议的第二行不为空

  • 查询返回的 URL 不为空

  • 这个 URL 与作为方法参数发出的 URL 完全匹配

这种策略为我们的测试提供了一个有趣的模式。我们需要创建的一些实用方法来完成我们的测试,也可以自行验证多个条件,提高我们的测试质量。

在我们的类中创建断言方法,可以引入一种特定领域的测试语言,当测试系统的其他部分时可以重复使用。

测试异常

我们在第一章中提到过开始测试,我们指出你应该测试异常和错误值,而不仅仅是测试正面情况:

@Test(expected = InvalidTemperatureException.class)
public final void testExceptionForLessThanAbsoluteZeroF() {
 TemperatureConverter.
fahrenheitToCelsius(TemperatureConverter.ABSOLUTE_ZERO_F - 1);
}

@Test(expected = InvalidTemperatureException.class)
public final void testExceptionForLessThanAbsoluteZeroC() {
  TemperatureConverter.
celsiusToFahrenheit(TemperatureConverter.ABSOLUTE_ZERO_C - 1);
}

我们之前已经介绍过这些测试,但在这里,我们将更深入地探讨它。首先要注意的是,这些是 JUnit4 测试,意味着我们可以使用expected注解参数测试异常。当你下载本章的示例项目时,你会看到它被分为两个模块,其中一个是核心模块,它是一个纯 Java 模块,因此我们有使用 JUnit4 的机会。在撰写本文时,Android 已经宣布支持 JUnit4,但尚未发布,因此对于 Android 的仪器测试,我们仍然使用 JUnit3。

每当我们有一个应该生成异常的方法时,我们都应该测试这种异常情况。最佳的做法是使用 JUnit4 的expected参数。这声明测试应该抛出异常,如果没有抛出异常或抛出不同的异常,测试将失败。在 JUnit3 中也可以通过在 try-catch 块中调用测试方法,捕获预期的异常,否则失败:

    public void testExceptionForLessThanAbsoluteZeroC() {
        try {
          TemperatureConverter.celsiusToFahrenheit(ABSOLUTE_ZERO_C - 1);
          fail();
        } catch (InvalidTemperatureException ex) {
          // do nothing we expect this exception!
        }
    }

测试本地和远程服务

当你想测试一个android.app.Service时,想法是扩展ServiceTestCase<Service>类,在受控环境中进行测试:

public class DummyServiceTest extends ServiceTestCase<DummyService> {
    public DummyServiceTest() {
        super(DummyService.class);
    }

    public void testBasicStartup() {
        Intent startIntent = new Intent();
        startIntent.setClass(getContext(), DummyService.class);
        startService(startIntent);
    }

    public void testBindable() {
        Intent startIntent = new Intent();
        startIntent.setClass(getContext(), DummyService.class);
        bindService(startIntent);
    }
}

构造函数,像其他类似的情况一样,调用父构造函数,将 Android 服务类作为参数传递。

这之后是testBasicStartup()。我们使用一个 Intent 启动服务,在这里创建它,将其类设置为正在测试的服务类。我们还为这个 Intent 使用仪器化上下文。这个类允许一些依赖注入,因为每个服务都依赖于其运行的上下文以及与之关联的应用程序。这个框架允许你注入修改过的、模拟的或独立的依赖替代品,从而执行真正的单元测试。

注意

依赖注入DI)是一种软件设计模式,涉及组件如何获取其依赖关系。你可以手动完成这一操作,或者使用众多依赖注入库中的一个。

由于我们只是按原样运行测试,服务将被注入一个功能完整的Context和一个通用的MockApplication对象。然后,我们使用startService(startIntent)方法启动服务,这与通过Context.startService()启动服务的方式相同,并提供它所提供的参数。如果你使用此方法启动服务,它将自动由tearDown()停止。

另一个测试testBindable(),将测试服务是否可以被绑定。这个测试使用bindService(startIntent),它以与通过Context.bindService()启动服务相同的方式启动正在测试的服务,并提供它所提供的参数。它返回与服务通信的通道。如果客户端无法绑定到服务,它可能返回 null。这个测试应该用类似assertNotNull(service)的断言检查服务中的 null 返回值,以验证服务是否正确绑定,但实际没有这样做,因此我们可以专注于使用的框架类。在编写类似情况的代码时,请务必包含此测试。

返回的IBinder通常是一个使用 AIDL 描述的复杂接口。为了测试这个接口,你的服务必须实现一个getService()方法,如本章示例项目中的DummService所示;该方法有以下实现:

    public class LocalBinder extends Binder {
        DummyService getService() {
            return DummyService.this;
        }
    }

广泛使用模拟对象

在前面的章节中,我们描述并使用了 Android SDK 中存在的模拟类。尽管这些类可以覆盖很多情况,但也有其他 Android 类和你的领域类需要考虑。你可能需要其他模拟对象来丰富你的测试用例。

几个库提供了满足我们模拟需求的基础设施,但现在我们专注于 Mockito,这可能是 Android 中使用最广泛的库。

注意

这不是一个 Mockito 教程。我们只是分析它在 Android 中的使用,所以如果你不熟悉它,我建议你查看其网站上的文档,网址为code.google.com/p/mockito/

Mockito 是一个开源软件项目,可在 MIT 许可下使用,并提供测试替身(模拟对象)。由于其验证期望的方式和动态生成的模拟对象,它非常适合测试驱动开发,因为它们支持重构,且在重命名方法或更改其签名时,测试代码不会断裂。

概括其文档,Mockito 最相关的优势如下:

  • 执行后询问交互问题

  • 它不是期望-运行-验证——避免昂贵的设置

  • 一种模拟简单 API 的方法

  • 使用类型进行简单的重构

  • 它可以模拟具体类以及接口

为了演示其用法,并确立一种稍后可以用于其他测试的风格,我们正在完成一些示例测试用例。

注意

在本文撰写时,Android 支持的最新版 Mockito 是 Dexmaker Mockito 1.1。你可能想尝试其他版本,但很可能会遇到问题。

我们首先应该做的是将Mockito作为依赖添加到你的 Android 仪器测试中。这只需简单地在你的依赖闭包中添加androidTestCompile引用。Gradle 会完成剩下的工作,即下载 JAR 文件并将其添加到你的类路径中:

dependencies {
    // other compile dependencies

    androidTestCompile('com.google.dexmaker:dexmaker-mockito:1.1')
}

为了在我们的测试中使用 Mockito,我们只需要从org.mockito静态导入其方法。通常,你的 IDE 会给你静态导入这些选项,但如果它没有,你可以尝试手动添加(如果手动添加时代码变红,那么你遇到的问题就是库不可用的问题)。

  import static org.mockito.Matchers.*;
import static org.mockito.Mockito.*;

最好使用特定的导入,而不是使用通配符。这里使用通配符只是为了简洁。很可能当你的 IDE 自动保存时,它会将它们扩展为所需的导入(或者如果你没有使用它们,就会移除它们!)。

导入库

我们已经将 Mockito 库添加到了项目的 Java 构建路径中。通常这不会有问题,但有时,重新构建项目会导致以下错误,阻止项目构建:错误:在 APK 打包期间文件重复

这取决于项目中包含了多少库以及它们是什么。

大多数可用的开源库都包含类似 GNU 提议的内容,并包含如LICENSENOTICECHANGESCOPYRIGHTINSTALL等文件。当我们尝试在同一个项目中包含多个库以最终构建一个单一的 APK 时,我们会立即遇到这个问题。你可以在你的build.gradle中解决这个问题:

    packagingOptions {
        exclude 'META-INF/LICENSE'
        exclude 'folder/duplicatedFileName'
  }

Mockito 使用示例

让我们创建一个只接受有符号十进制数的EditText,我们将它称为EditNumberEditNumber使用InputFilter提供此功能。在以下测试中,我们将执行此过滤器来验证是否实现了正确的行为。

为了创建测试,我们将使用EditNumberEditText继承的一个属性,这样它就可以添加一个监听器,实际上是一个TextWatcher。这将提供当EditNumber的文本发生变化时调用的方法。这个TextWatcher是测试的协助者,我们可以将其实现为单独的类,并验证调用其方法的结果,但这样做既繁琐,可能会引入更多错误,所以我们采用的方法是使用 Mockito,以避免编写外部的TextWatcher

这正是我们引入一个模拟的TextWatcher来检查文本变化时方法调用的方式。

EditNumber 过滤器测试

这个测试套件将执行EditNumberInputFilter行为,检查TextWatcher模拟上的方法调用并验证结果。

我们使用AndroidTestCase,因为我们希望独立于其他组件或活动来测试EditNumber

我们有多个需要测试的输入(我们允许小数,但不允许多个小数点、字母等),因此我们可以有一个带有预期输入数组和预期输出数组的测试。然而,测试可能会变得非常复杂,难以维护。更好的方法是针对InputFilter的每个测试用例都有一个测试。这允许我们给测试赋予有意义的名称,并解释我们旨在测试的内容。我们将以如下列表结束:

testTextChangedFilter*
        * WorksForBlankInput
        * WorksForSingleDigitInput
        * WorksForMultipleDigitInput
        * WorksForZeroInput
        * WorksForDecimalInput
        * WorksForNegativeInput
        * WorksForDashedInput
        * WorksForPositiveInput
        * WorksForCharacterInput
        * WorksForDoubleDecimalInput

现在,我们将通过一个测试testTextChangedFilterWorksForCharacterInput()来介绍模拟对象的使用,如果你查看示例项目,你会发现所有其他测试都遵循相同的模式,实际上我们已经提取了一个帮助方法,该方法作为所有测试的自定义断言:

public void testTextChangedFilterWorksForCharacterInput() {
  assertEditNumberTextChangeFilter("A1A", "1");
}
/**
 * @param input  the text to be filtered 
 * @param output the result you expect once the input has been filtered
*/
private void assertEditNumberTextChangeFilter(String input, String output) {
 int lengthAfter = output.length();
 TextWatcher mockTextWatcher = mock(TextWatcher.class);
 editNumber.addTextChangedListener(mockTextWatcher);

 editNumber.setText(input);

 verify(mockTextWatcher)
.afterTextChanged(editableCharSequenceEq(output));
 verify(mockTextWatcher)
.onTextChanged(charSequenceEq(output), eq(0), eq(0), eq(lengthAfter));
 verify(mockTextWatcher)
.beforeTextChanged(charSequenceEq(""), eq(0), eq(0), eq(lengthAfter));
}

如你所见,测试用例非常直接;它断言当你将A1A输入到EditNumber视图的文本中时,文本实际上被更改为1。这意味着我们的 EditNumber 已经过滤掉了字符。当我们查看assertEditNumberTextChangeFilter(input, output)帮助方法时,会发生一件有趣的事情。在我们的帮助方法中,我们验证了InputFilter是否正在执行其工作,这里我们使用了 Mockito。使用 Mockito 模拟对象时有四个常见步骤:

  1. 实例化准备好的模拟对象。

  2. 确定预期的行为并将其存根以返回任何固定数据。

  3. 调用方法,通常是通过调用测试类的各个方法。

  4. 验证模拟对象的行为以通过测试。

根据第一步,我们使用mock(TextWatcher.class)创建一个模拟的TextWatcher,并将其设置为 EditNumber 上的TextChangedListener

在这个实例中,我们跳过第二步,因为没有固定数据,即我们模拟的类没有任何预期返回值的方法。稍后我们在另一个测试中会回到这一点。

在第三步中,我们已经设置好了模拟对象,可以执行测试方法以执行其预期操作。在我们的案例中,方法是editNumber.setText(input),预期操作是设置文本,从而触发我们的InputFilter运行。

第四步是验证文本是否确实被我们的过滤器更改。让我们稍微分解一下第四步。以下是我们再次的验证:

verify(mockTextWatcher)
.afterTextChanged(editableCharSequenceEq(output));
verify(mockTextWatcher)
.onTextChanged(charSequenceEq(output), eq(0), eq(0), eq(lengthAfter));
verify(mockTextWatcher)
.beforeTextChanged(charSequenceEq(""), eq(0), eq(0), eq(lengthAfter));

我们将使用两个自定义匹配器(editableCharSequenceEq(String)charSequenceEq(String)),因为我们关心的是比较 Android 使用的不同类(如 EditableCharSequence)的字符串内容。当你使用一个特殊的匹配器时,这意味着对该验证方法调用的所有比较都需要一个特殊的包装方法。

另一个匹配器 eq(),期望得到一个等于给定值的 int。后者由 Mockito 为所有原始类型和对象提供,但我们需要实现 editableCharSequenceEq()charSequenceEq(),因为这是一个针对 Android 的特定匹配器。

Mockito 有一个预定义的 ArgumentMatcher,可以帮助我们创建匹配器。你扩展这个类,它会给你一个要覆盖的方法:

    abstract boolean matches(T t);

matches 参数匹配器方法期望得到一个你可以用来与预定义变量进行比较的参数。这个参数是你方法调用的“实际”结果,而预定义变量是“预期”的。然后你决定返回 true 或 false,看它们是否相同。

你可能已经意识到,自定义 ArgumentMatcher 类在测试中的频繁使用可能会变得非常复杂,并可能导致错误,为了简化这个过程,我们将使用一个辅助类,我们称之为 CharSequenceMatcher。我们还有 EditableCharSequenceMatcher,可以在本章的示例项目中找到:

class CharSequenceMatcher extends ArgumentMatcher<CharSequence> {

    private final CharSequence expected;

    static CharSequence charSequenceEq(CharSequence expected) {
        return argThat(new CharSequenceMatcher(expected));
    }

    CharSequenceMatcher(CharSequence expected) {
        this.expected = expected;
    }

    @Override
    public boolean matches(Object actual) {
        return expected.toString().equals(actual.toString());
    }

    @Override
    public void describeTo(Description description) {
        description.appendText(expected.toString());
    }
}

我们通过返回将作为参数传递的对象与转换为字符串后我们预定义的字段比较的结果来实现匹配。

我们还覆盖了 describeTo 方法,这允许我们在验证失败时更改错误消息。这是一个始终要记住的好技巧:在这样做之前和之后,查看错误消息。

Argument(s) are different! Wanted: 
textWatcher.afterTextChanged(<Editable char sequence matcher>);
Actual invocation has different arguments:
textWatcher.afterTextChanged(1);

Argument(s) are different! Wanted: 
textWatcher.afterTextChanged(1XX);
Actual invocation has different arguments: 
textWatcher.afterTextChanged(1);

当我们使用匹配器的静态实例化方法并将其作为静态方法导入测试中时,我们可以简单地编写:

verify(mockTextWatcher).onTextChanged(charSequenceEq(output), …

隔离测试视图

我们在这里分析的测试是基于 Android SDK ApiDemos 项目中的 Focus2AndroidTest。它演示了当行为本身无法被隔离时,如何测试符合布局的视图的一些属性。测试视图的可聚焦性就是这种情况之一。

我们只测试单个视图。为了避免创建完整的 Activity,这个测试扩展了 AndroidTestCase。你可能考虑过仅使用 TestCase,但不幸的是,这是不可能的,因为我们需要一个 Context 来通过 LayoutInflater 加载 XML 布局,而 AndroidTestCase 将为我们提供此组件:

public class FocusTest extends AndroidTestCase {
 private FocusFinder focusFinder;

 private ViewGroup layout;

 private Button leftButton;
 private Button centerButton;
 private Button rightButton;

@Override
protected void setUp() throws Exception {
 super.setUp();

 focusFinder = FocusFinder.getInstance();
 // inflate the layout
 Context context = getContext();
 LayoutInflater inflater = LayoutInflater.from(context);
 layout = (ViewGroup) inflater.inflate(R.layout.view_focus, null);

 // manually measure it, and lay it out
 layout.measure(500, 500);
 layout.layout(0, 0, 500, 500);

 leftButton = (Button) layout.findViewById(R.id.focus_left_button);
 centerButton = (Button) layout.findViewById(R.id.focus_center_button);
 rightButton = (Button) layout.findViewById(R.id.focus_right_button);
}

设置将按以下方式准备我们的测试:

  1. 我们请求一个FocusFinder类。这是一个提供用于查找下一个可聚焦视图的算法的类。它实现了单例模式,因此我们使用FocusFinder.getInstance()来获取它的引用。这个类有几种方法可以帮助我们找到在不同条件下可聚焦和可触摸的项,例如在给定方向上最近的或者从特定矩形区域开始搜索。

  2. 然后,我们获取LayoutInflater类并展开测试下的布局。由于我们的测试与其他系统部分隔离,我们需要考虑的一件事是,我们必须手动测量和布局组件。

  3. 然后,我们使用查找视图模式并将找到的视图分配给字段。

在前面的章节中,我们列举了我们的工具库中所有可用的断言,您可能还记得,为了测试视图的位置,我们在ViewAsserts类中有一套完整的断言。然而,这取决于布局是如何定义的:

public void testGoingRightFromLeftButtonJumpsOverCenterToRight() {
 View actualNextButton = 
focusFinder.findNextFocus(layout, leftButton, View.FOCUS_RIGHT);
 String msg = "right should be next focus from left";
 assertEquals(msg, this.rightButton, actualNextButton);
}

public void testGoingLeftFromRightButtonGoesToCenter() {
 View actualNextButton = 
focusFinder.findNextFocus(layout, rightButton, View.FOCUS_LEFT);
 String msg = "center should be next focus from right";
 assertEquals(msg, this.centerButton, actualNextButton);
}

testGoingRightFromLeftButtonJumpsOverCenterToRight()方法,如其名称所示,测试了当焦点从左向右移动时,右侧按钮获得焦点的情况。为了实现这一搜索,我们在setUp()方法中获得的FocusFinder实例被使用。这个类有一个findNextFocus()方法,可以获取在给定方向上接收焦点的视图。获得的值与我们的预期进行对比检查。

类似地,testGoingLeftFromRightButtonGoesToCenter()测试检查了相反方向上的焦点移动。

测试解析器

在许多情况下,您的 Android 应用程序依赖于从 Web 服务获取的外部 XML、JSON 消息或文档。这些文档用于本地应用程序和服务器之间的数据交换。有许多用例需要从服务器获取 XML 或 JSON 文档,或者由本地应用程序生成并发送到服务器。理想情况下,由这些活动调用的方法必须独立测试以实现真正的单元测试,为此,我们需要在 APK 中包含一些模拟文件以运行测试。

但问题是,我们可以在哪里包含这些文件呢?

让我们找出答案。

安卓资产

首先,可以在 Android SDK 文档中找到关于资产定义的简要回顾。

“资源”和“资产”之间的区别表面上看不大,但通常您会更频繁地使用资源来存储外部内容,而不是使用资产。真正的区别在于,放在资源目录中的任何东西都可以通过 Android 编译的 R 类轻松地从应用程序中访问。而放在资产目录中的任何东西将保持其原始文件格式,为了读取它,您必须使用AssetManager将文件作为字节流读取。因此,将文件和数据放在资源(res/)目录中可以更容易地访问它们。

显然,assets 是我们需要存储将被解析以测试解析器的文件。

因此,我们的 XML 或 JSON 文件应该放在 assets 文件夹中,以防止编译时被操纵,并能够在应用程序或测试运行时访问原始内容。

但要小心,我们需要将它们放在androidTest文件夹的 assets 中,因为这样,这些就不是应用程序的一部分,而且我们不想在发布实时应用程序时将它们与我们的代码打包在一起。

解析器测试

这个测试实现了一个AndroidTestCase,因为我们只需要一个上下文来引用我们的 assets 文件夹。同时,我们在测试中编写了解析,因为此测试的重点不是如何解析 xml,而是如何从你的测试中引用模拟资产:

public class ParserExampleActivityTest extends AndroidTestCase {

 public void testParseXml() throws IOException {
 InputStream assetsXml = getContext().getAssets()
.open("my_document.xml");

  String result = parseXml(assetsXml);
  assertNotNull(result);
 }
}
}

InputStream类是通过使用getContext().getAssets()从 assets 中打开my_document.xml文件获得的。请注意,这里获得的上下文和资产来自测试包,而不是被测 Activity。

接下来,使用最近获得的InputStream调用parseXml()方法。如果发生IOException,测试将失败并输出堆栈跟踪中的错误,如果一切顺利,我们将测试结果不为空。

然后,我们应该在名为my_document.xml的资产中提供我们想要用于测试的 XML,资产应该在测试项目文件夹下;默认情况下,这是androidTest/assets

内容可能是:

<?xml version="1.0" encoding="UTF-8" ?>
<records>
  <record>
    <name>Paul</name>
  </record>
</records>

测试内存使用情况

有时,内存消耗是衡量测试目标(无论是 Activity、Service、Content Provider 还是其他组件)良好行为的一个重要因素。

为了测试这种情况,我们可以使用一个实用测试工具,你可以在运行测试循环后,主要从其他测试中调用它:

public void assertNotInLowMemoryCondition() {
//Verification: check if it is in low memory
ActivityManager.MemoryInfo mi = new ActivityManager.MemoryInfo();
 ((ActivityManager)getActivity()
.getSystemService(Context.ACTIVITY_SERVICE)).getMemoryInfo(mi);
assertFalse("Low memory condition", mi.lowMemory);
}

这个断言可以从其他测试中调用。首先,它通过使用getSystemService()获取实例后,使用getMemoryInfo()ActivityManager获取MemoryInfo。如果系统认为自己当前处于低内存状态,则lowMemory字段被设置为true

在某些情况下,我们想要更深入地了解资源使用情况,并可以从进程表中获得更详细的信息。

我们可以创建另一个辅助方法来获取进程信息,并在我们的测试中使用它:

    private String captureProcessInfo() {
        InputStream in = null;
        try {
           String cmd = "ps";
           Process p = Runtime.getRuntime().exec(cmd);
           in = p.getInputStream();
           Scanner scanner = new Scanner(in);
           scanner.useDelimiter("\\A");
           return scanner.hasNext() ? scanner.next() : "scanner error";
        } catch (IOException e) {
           fail(e.getLocalizedMessage());
        } finally {
           if (in != null) {
               try {
                   in.close();
               } catch (IOException ignore) {
               }
            }
        }
        return "captureProcessInfo error";
    }

为了获得这些信息,使用Runtime.exec()执行了一个命令(在本例中使用了ps,但你可以根据需要调整它)。这个命令的输出被连接在一个字符串中,稍后返回。我们可以使用返回值将输出发送到测试中的日志,或者进一步处理内容以获得摘要信息。

这是一个记录输出的例子:

        Log.d(TAG, captureProcessInfo());

当运行此测试时,我们可以获取有关运行进程的信息:

D/ActivityTest(1): USER     PID   PPID  VSIZE  RSS     WCHAN    PC   NAME
D/ActivityTest(1): root      1     0     312    220   c009b74c 0000ca4c S /init
D/ActivityTest(1): root      2     0     0      0     c004e72c 00000000 S kthreadd
D/ActivityTest(1): root      3     2     0      0     c003fdc8 00000000 S ksoftirqd/0
D/ActivityTest(1): root      4     2     0      0     c004b2c4 00000000 S events/0
D/ActivityTest(1): root      5     2     0      0     c004b2c4 00000000 S khelper
D/ActivityTest(1): root      6     2     0      0     c004b2c4 00000000 S suspend
D/ActivityTest(1): root      7     2     0      0     c004b2c4 00000000 S kblockd/0
D/ActivityTest(1): root      8     2     0      0     c004b2c4 00000000 S cqueue
D/ActivityTest(1): root      9     2     0      0     c018179c 00000000 S kseriod

输出为了简洁起见已被截断,但如果你运行它,你会得到系统上运行的完整进程列表。

获得的信息简要解释如下:

描述
USER 这是文本用户 ID。
PID 这是进程的进程 ID 号。
PPID 这是父进程 ID。
VSIZE 这是进程的虚拟内存大小,以 KB 为单位。这是进程保留的虚拟内存。
RSS 这是常驻集合大小,即任务已使用的非交换物理内存(以页为单位)。这是进程实际占用的真实内存页数。这不包括尚未按需加载的页面。
WCHAN 这是进程等待的“通道”。它是系统调用的地址,如果需要文本名称,可以在名称列表中查找。
PC 这是当前的 EIP(指令指针)。

| 状态(无标题) | 这表示以下的过程状态: |

  • S 用于表示在可中断状态下的睡眠

  • R 用于表示运行中

  • T 用于表示已停止的进程

  • Z 用于表示僵尸进程

|

描述
NAME 这表示命令名称。Android 中的应用程序进程会以其包名重命名。

使用 Espresso 进行测试

测试 UI 组件可能很困难。了解视图何时被加载或确保不在错误的线程上访问视图可能会导致奇怪的行为和不确定的测试结果。这就是谷歌发布了一个用于 UI 相关自动化测试的帮助库 Espresso 的原因。(code.google.com/p/android-test-kit/wiki/Espresso)。

将 Espresso 库 JAR 添加到/libs文件夹中可以实现,但为了方便 Gradle 用户,谷歌发布了他们的 Maven 仓库版本(因为幸运的是,在 2.0 版本之前这是不可用的)。使用 Espresso 时,还需要使用捆绑的 TestRunner。因此,设置变为:

dependencies {
// other dependencies
androidTestCompile('com.android.support.test.espresso:espresso-core:2.0')
}
android {
    defaultConfig {
    // other configuration
    testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
// Annoyingly there is a overlap with Espresso dependencies at the moment 
// add this closure to fix internal jar file name clashes
packagingOptions {
        exclude 'LICENSE.txt'
    }
}

一旦 Espresso 依赖项被添加到项目中,您就可以流畅地断言 UI 元素的行为。在我们的示例中,我们有一个允许您订购 Espresso 咖啡的 Activity。当您按下订单按钮时,会出现一个精美的 Espresso 图像。我们希望在一个自动化测试中验证这种行为。

首先要做的是设置我们的 Activity 进行测试。我们使用ActivityInstrumentationTestCase2,这样我们就可以拥有一个完整的生命周期 Activity 运行。在测试开始时或setup()方法中需要调用getActivity(),以允许 Activity 启动并且 Espresso 在恢复状态下找到 Activity:

public class ExampleEspressoTest extends ActivityInstrumentationTestCase2<EspressoActivity> {

    public ExampleEspressoTest() {
        super(EspressoActivity.class);
    }

    @Override
    public void setUp() throws Exception {
        getActivity();
    }

设置完成后,我们可以使用 Espresso 编写一个测试,点击按钮并检查图像是否在 Activity 中显示(变为可见):

    public void testClickingButtonShowsImage() {
        Espresso.onView(
              ViewMatchers.withId(R.id.espresso_button_order))
              perform(ViewActions.click());

        Espresso.onView(
              ViewMatchers.withId(R.id.espresso_imageview_cup))
                .check(ViewAssertions.matches(ViewMatchers.isDisplayed()));
    }

这个示例展示了使用 Espresso 查找我们的订单按钮,点击按钮,并检查我们的订单 Espresso 是否对用户可见。Espresso 有一个流畅的接口,意味着它遵循构建器样式模式,而且大多数方法调用可以被链式调用。在上面的示例中,我为了清晰展示了完全限定类,但这些可以很容易地更改为静态导入,使测试更具可读性:

    public void testClickingButtonShowsImage() {
        onView(withId(R.id.espresso_button_order))
                .perform(click());

        onView(withId(R.id.espresso_imageview_cup))
                .check(matches(isDisplayed()));
    }

现在可以以更加句子的样式来阅读这个。这个示例展示了使用 Espresso 查找我们的订单按钮onView(withId(R.id.espresso_button_order))。点击perform(click()),然后我们找到咖啡杯图片onView(withId(R.id.espresso_imageview_cup)),并检查它是否对用户可见check(matches(isDisplayed()))

这表明你需要考虑的类只有:

  • Espresso:这是入口点。始终从这一点开始与视图交互。

  • ViewMatchers:这用于在当前层次结构中定位视图。

  • ViewActions:这用于对定位的视图执行点击、长按等操作。

  • ViewAssertions:这用于在执行操作后检查视图的状态。

Espresso 有一个非常强大的 API,它允许你测试视图之间的位置,匹配 ListView 中的数据,直接从头部或脚部获取数据,并检查 ActionBar/ToolBar 中的视图以及许多其他断言。另一个特点是它能处理线程;Espresso 将等待异步任务完成,然后断言 UI 是否已更改。这些特性以及更多的解释都列在 wiki 页面上(code.google.com/p/android-test-kit/w/list)。

概述

在本章中,我们提出了涵盖广泛情况的几个现实世界中的测试示例。在创建你自己的测试时,你可以将它们作为起点。

我们涵盖了一系列的测试方法,你可以为你的测试进行扩展。我们使用了模拟上下文,并展示了RenamingDelegatingContext如何在各种情况下被用来改变测试获取的数据。我们还分析了这些模拟上下文注入测试依赖的过程。

然后,我们使用ActivityUnitTestCase以完全隔离的方式测试活动。我们使用AndroidTestCase以隔离的方式测试视图。我们展示了结合使用 Mockito 和ArgumentMatchers来提供任何对象的定制匹配器的模拟对象。最后,我们探讨了潜在的内存泄漏分析,并窥视了使用 Espresso 测试 UI 的强大功能。

下一个章节将专注于管理你的测试环境,以便你能够以一致、快速且始终确定性的方式运行测试,这导致了自动化和那些淘气的猴子!

第四章:管理你的安卓测试环境

既然我们已经完全理解了可用的安卓测试 SDK,并且准备好了一系列测试食谱来断言和验证我们应用的行为,现在是提供不同的测试运行条件的时候了,探索其他测试,或者甚至手动使用应用程序来了解最终用户的体验会是什么样的。

在本章中,我们将涵盖:

  • 创建安卓虚拟设备(AVD)为应用程序提供不同的条件和配置

  • 了解在创建 AVD 时我们可以指定的不同配置

  • 如何运行 AVD

  • 如何创建无头模拟器

  • 解锁屏幕以运行所有测试

  • 模拟现实生活中的网络条件

  • 使用 HAXM 加速你的 AVD

  • 安卓虚拟设备的替代方案

  • 运行猴子程序以生成发送到应用程序的事件

创建安卓虚拟设备

为了最大可能地检测到与应用程序运行设备相关的问题,你需要尽可能广泛的设备功能和配置覆盖。

虽然最终和结论性的测试应该总是在真实设备上运行,但随着设备和外形尺寸的不断增加,实际上你不可能拥有每种设备来进行测试。云中也有设备农场,可以在各种设备上进行测试(搜索cloud device testing),但有时,它们的成本超出了普通开发者的预算。安卓提供了一种方式,通过不同的 AVD 配置(一个模拟器)几乎逐字地模拟大量功能和配置,以方便不同的配置。

注意

本章中的所有示例都是在 OSX 10.9.4(Mavericks)32 位系统上运行,使用 Android SDK Tools 23.0.5 和安装的平台 4.4.2(API 20)。

要创建一个 AVD,你可以在终端使用android avd命令,或者在 Android Studio 内通过工具 | 安卓 | AVD 管理器或其快捷图标。如果你从终端运行 AVD 管理器,你会得到一个与从 Android Studio 运行稍微不同的 GUI,但它们的功能相同。我们将使用 Android Studio 中的 AVD 管理器,因为这是最有可能的使用场景。

通过点击图标,你可以访问AVD 管理器。在这里,你按下创建设备...按钮来创建一个新的 AVD,会出现以下对话框:

创建安卓虚拟设备

现在,你可以为硬件选择一个配置手机(我们选择 Nexus 5),点击下一步,并选择一个安卓版本(KitKat x86)。再次点击下一步,你将看到设备的汇总信息。你可以点击完成,使用默认值创建 AVD。然而,如果你需要支持特定的配置,可以指定不同的硬件属性。我们将 AVD 名称改为testdevice。通过使用显示高级设置按钮,还可以访问更多属性。

可以设置广泛的属性。一些亮点包括:

  • RAM 大小/SD 卡大小

  • 模拟或使用你的网络摄像头作为前后摄像头

  • 改变网络速度/模拟延迟

设置比例也很有用,以便在类似于真实设备大小的窗口中测试你的应用程序。一个非常常见的错误是在至少是真实设备两倍大小的 AVD 窗口中测试应用程序,并使用鼠标指针,认为一切都没问题,然后在 5 或 6 英寸的物理设备屏幕上才意识到 UI 上的一些项目用手指是无法触摸的。

最后,反复在相同条件下测试你的应用程序也很有帮助。为了能够反复在相同条件下进行测试,有时删除之前会话中输入的所有信息会很有帮助。如果是这种情况,请确保取消勾选存储快照以加快启动速度,以便每次都能从零开始。

从命令行运行 AVD

如果我们可以从命令行运行不同的 AVD,或许还能自动化我们运行或脚本测试的方式,那不是很好吗?

通过将 AVD 从其 UI 窗口中释放出来,我们开启了一个全新的自动化和脚本编写可能性世界。

好的,让我们来探索这些选项。

无界面模拟器

当我们运行自动化测试且无人查看窗口,或者测试运行器和应用程序之间的交互非常快以至于几乎看不到任何内容时,无界面模拟器(不显示其 UI 窗口)就非常方便。

同时,值得注意的是,有时直到你看到屏幕上的交互,才能理解某些测试为什么会失败,因此在选择模拟器的运行模式时,请根据自身判断来决定。

在运行 AVD 时,我们可能会注意到它们的网络通信端口是在运行时分配的,从5554开始,每次增加2。这被用来命名模拟器并设置其序列号;例如,使用端口5554的模拟器成为emulator-5554。这在开发过程中运行 AVD 时非常有用,因为我们不需要关注端口分配。然而,如果我们同时运行多个模拟器,这可能会导致混淆,难以追踪哪个测试在哪个模拟器上运行。

在这些情况下,我们将指定手动端口以保持对特定 AVD 的控制。

通常,当我们同时在一个以上的模拟器上运行测试时,不仅想要分离窗口,还希望避免声音输出。我们也会为此添加选项。

启动我们刚刚创建的测试 AVD 的命令行如下,端口号必须是 5554 到 5584 之间的整数:

$ emulator -avd testdevice -no-window -no-audio -no-boot-anim -port 5580

我们现在可以检查设备是否在设备列表中:

$ adb devices
List of devices attached
emulator-5580  device

下一步是安装应用程序和测试:

$ adb -s emulator-5580 install YourApp.apk
347 KB/s (16632 bytes in 0.046s) : /data/local/tmp/YourApp.apk
Success
$ adb -s emulator-5580 install YourAppTests.apk
222 KB/s (16632 bytes in 0.072s)
 pkg: /data/local/tmp/YourAppTests.apk
Success

然后,我们可以使用指定的序列号在它上面运行测试:

$ adb -s emulator-5580 shell am instrument -w\ 
com.blundell.tut.test/android.test.InstrumentationTestRunner
com.blundell.tut.test.MyTests:......
com.blundell.tut.test.MyOtherTests:..........
Test results for InstrumentationTestRunner=..................
Time: 15.295
OK (20 tests)

禁用键盘锁

我们可以看到测试正在运行,而无需任何干预和访问模拟器 GUI。

有时,如果你以更标准的方式运行测试,例如从 IDE 启动的标准模拟器,可能会收到一些测试未失败的错误。在这种情况下,其中一个原因是模拟器可能被锁定在第一屏,我们需要解锁才能运行涉及 UI 的测试。

要解锁屏幕,你可以使用以下命令:

$ adb -s emulator-5580 emu event send EV_KEY:KEY_MENU:1 EV_KEY:KEY_MENU:0

锁屏也可以通过编程禁用。在仪器测试类中,你应当在 setup() 中添加以下代码,很可能是在此函数中:

 @Override
 public void setUp() throws Exception {
   Activity activity = getActivity();
   Window window = activity.getWindow();
   window.addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
 }

这将为这些测试解除键盘锁,并且具有不需要任何额外安全权限或更改测试应用(已弃用的替代方法需要,见developer.android.com/reference/android/app/KeyguardManager.html)的优点。

清理

在某些情况下,你还需要清理在运行测试后启动的服务和进程。这防止后者的测试结果受到之前测试结束条件的影响。在这些情况下,最好从已知条件开始,释放所有已使用的内存,停止服务,重新加载资源,并重新启动进程,这可以通过热启动模拟器来实现:

$ adb -s emulator-5580 shell 'stop'; sleep 5; start'

这条命令行为我们打开模拟器的 shell,并运行停止和启动命令,正如人们所说,是将其关闭再重新打开。

这些命令的输出可以通过使用 logcat 命令来监控:

$ adb -s emulator-5580 logcat

你将看到如下信息:

D/AndroidRuntime(1):
D/AndroidRuntime(1): >>>>>>>>>> AndroidRuntime START <<<<<<<<<<
D/AndroidRuntime(1): CheckJNI is ON
D/AndroidRuntime(1): --- registering native functions ---
I/SamplingProfilerIntegration(1): Profiler is disabled.
I/Zygote  (1): Preloading classes...
I/ServiceManager(2): service 'connectivity''connectivity''connectivity''' died
I/ServiceManager(2): service 'throttle''throttle''throttle''' died
I/ServiceManager(2): service 'accessibility''accessibility''accessibility''' died

终止模拟器

当我们完成一个无头模拟器实例的工作后,我们开始使用之前提到的命令。我们使用以下命令行来杀死它:

$ adb -s emulator-5580 emu kill

这将阻止模拟器在主机计算机上释放已使用的资源并终止模拟器进程。

额外的模拟器配置

有时,我们需要测试的内容超出了创建或配置 AVD 时可以设置的选项范围。

其中一个情况可能是需要测试我们的应用程序在不同的地区设置下的表现。假设我们想要在设置为日语和日本的模拟器上测试我们的应用程序,就像是在日本手机上一样。

我们可以在模拟器命令行中传递这些属性。-prop 命令行选项允许我们设置可以在其中设置的任何属性:

$ emulator -avd testdevice -no-window -no-audio -no-boot-anim -port 5580   -prop persist.sys.language=ja -prop persist.sys.country=JP

为了验证我们的设置是否成功,我们可以使用 getprop 命令来验证它们,例如:

$ adb –s emulator-5580 shell "getprop persist.sys.language"
ja
$ adb –s emulator-5580 shell "getprop persist.sys.country"
JP

如果你想要在玩转持久设置后清除所有用户数据,你可以使用以下命令:

$ adb -s emulator-5580 emu kill
$ emulator -avd testdevice -no-window -no-audio -no-boot-anim -port 5580 -wipe-data

这之后,模拟器将会全新启动。

注意

更多关于设置模拟器硬件选项的可选属性信息,可以在developer.android.com/tools/devices/managing-avds-cmdline.html#hardwareopts找到。

模拟网络条件

在不同的网络条件下进行测试至关重要,但往往被忽视。这可能导致误解,认为应用程序因为使用了不同速度和延迟的主机网络而表现出不同的行为。

Android 模拟器支持网络限速,例如,支持更慢的网络速度和更高的连接延迟。在创建 AVD 时可以选择,也可以随时通过命令行使用-netspeed <speed>-netdelay <delay>选项在模拟器中进行设置。

支持的完整选项列表如下:

对于网络速度:

选项 描述 速度 [kbits/s]
-netspeed gsm GSM/CSD 上传:14.4,下载:14.4
-netspeed hscsd HSCSD 上传:14.4,下载:43.2
-netspeed gprs GPRS 上传:40.0,下载:80.0
-netspeed edge EDGE/EGPRS 上传:118.4,下载:236.8
-netspeed umts UMTS/3G 上传:128.0,下载:1920.0
-netspeed hsdpa HSDPA 上传:348.0,下载:14400.0
-netspeed full 无限制 上传:0.0,下载:0.0
-netspeed <num> 选择上传和下载速度 上传:如指定,下载:如指定
-netspeed <up>:<down> 选择单独的上传和下载速度 上传:指定速度,下载:指定速度

对于延迟:

选项 描述 延迟 [msec]
-netdelay gprs GPRS 最小 150,最大 550
-netdelay edge EDGE/EGPRS 最小 80,最大 400
-netdelay umts UMTS/3G 最小 35,最大 200
-netdelay none 无延迟 最小 0,最大 0
-netdelay <num> 选择确切的延迟 延迟如指定
-netdelay <min>:<max> 选择最小和最大延迟 最小和最大延迟如指定

如果未指定值,模拟器将使用以下默认值:

  • 默认网络速度为无限制

  • 默认网络延迟为无延迟。

这是一个使用这些选项选择 GSM 网络速度 14.4 kbits/sec 和 GPRS 延迟 150 至 500 毫秒的模拟器示例:

$ emulator -avd testdevice -port 5580 -netspeed gsm -netdelay gprs

当模拟器运行时,你可以通过 Telnet 客户端内的 Android 控制台验证这些网络设置或交互式更改它们:

$ telnet localhost 5580
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Android Console: type 'help' for a list of commands
OK

连接之后,我们可以输入以下命令:

network status
Current network status:
 download speed:      14400 bits/s (1.8 KB/s)
 upload speed:        14400 bits/s (1.8 KB/s)
 minimum latency:  150 ms
 maximum latency:  550 ms
OK

你可以使用模拟器手动或自动测试使用网络服务的应用程序。

在某些情况下,这不仅涉及限制网络速度,还涉及更改 GPRS 连接的状态,以研究应用程序如何应对这些情况。要更改此状态,我们也可以在正在运行的模拟器中使用 Android 控制台。

例如,要从网络注销模拟器,我们可以使用:

$ telnet localhost 5580

在收到OK子提示后,我们可以通过发出以下命令将数据网络模式设置为未注册。这将关闭所有数据:

gsm data unregistered
OK
quit
Connection closed by foreign host.

在这种条件下测试应用程序后,你可以通过使用以下命令行再次连接它:

gsm data home
OK

要验证状态,你可以使用以下命令行:

gsm status
gsm voice state: home

gsm data state:  home

OK

使用 HAXM 加速你的 AVD

使用 Android 虚拟设备时,你会注意到它们并不是最响应灵敏的模拟器。这是因为 AVD 模拟器不支持硬件 GL,所以 GL 代码会被转换为 ARM 软件,并在由 QEMU(AVD 运行在顶层的托管虚拟机监控器)模拟的硬件上运行。Google 一直在解决这个问题,现在,高效使用宿主 GPU 正在提高速度(SDK 17)。在这个级别及以上的模拟器上,响应性已经得到了改善。

使用 Intel 的硬件加速执行管理器(HAXM)可以获得另一个速度提升。如果你的 AVD 运行 x86 架构,使用 HAXM 可以获得 5 到 10 倍的速度提升,因为它可以本地执行 CPU 命令。

HAXM 的工作原理是允许 CPU 命令在你的硬件上运行(即你的 Intel CPU),而在此之前,QEMU 会模拟 CPU,所有命令都是通过软件执行的,这就是原始架构为何笨拙的原因。

根据要求,你需要拥有支持 VT(虚拟化技术)的 Intel 处理器和一个基于 x86 的模拟器,最低 SDK 版本为 10(姜饼)。Intel 声称,从 2005 年开始的大多数 Intel 处理器都将支持 VT 卸载作为标准。

安装很简单;从 Android SDK 管理器的附加部分下载 HAXM,找到下载的文件,并按照安装程序说明操作。你可以通过从终端运行以下命令来确认安装成功:

kextstat | grep intel 

如果你收到包含com.intel.kext.intelhaxm的消息,说明你已经安装并可以运行你的快速 x86 模拟器了。你不需要做其他事情,只需确保你的 Android 模拟器的 CPU/ABI 是 x86,HAXM 就会在后台为你运行。

AVD 的替代方案

Android 虚拟设备并不是你运行 Android 应用的唯一方式。现在有一些解决方案可供选择。在 Google 上快速搜索可以找到这个列表(我不在这里写出来,因为它们可能会很快过时)。我个人推荐的一个是 GenyMotion 模拟器。这是一个使用 x86 架构虚拟化来提高效率的 Android 模拟器。它比 AVD 运行得更快更流畅。缺点是它仅对个人使用免费,并且截至撰写本文时,它并不能模拟设备所有的传感器,但我知道他们正在忙于解决这个问题。

运行 monkey

你可能听说过无限猴子定理。这个定理指出,一个猴子在打字机上随机按键无限次,最终会打出一段给定的文本,比如威廉·莎士比亚的完整作品。Android 版本的这个定理则是说,一个在设备上产生随机触摸的猴子可能会在远少于无限的时间里让你的应用崩溃。

Android 特性中包含一个猴子应用(goo.gl/LSWg85),它会生成随机事件,而不是使用真正的猴子。

对我们的应用程序运行猴子以生成随机事件的最简单方法是:

$ adb -e shell monkey -p com.blundell.tut -v -v 1000

你将会接收到以下输出:

Events injected: 1000
:Sending rotation degree=0, persist=false
:Dropped: keys=0 pointers=4 trackballs=0 flips=0 rotations=0
## Network stats: elapsed time=2577ms (0ms mobile, 0ms wifi, 2577ms not connected)
// Monkey finished

猴子将只向指定的包(-p)发送事件,在这种情况下是 com.blundell.tut,以非常详细的方式(-v -v)。发送的事件数量将是 1000。

客户端-服务器猴子

另外一种运行猴子命令的方法。它也提供了一个客户端-服务器模型,最终允许创建控制发送哪些事件的脚本,并不只依赖于随机生成。

通常,猴子使用的端口是 1080,但如果你更喜欢,可以使用其他端口:

$ adb -e shell monkey -p com.blundell.tut --port 1080 &

然后,我们需要重定向模拟器的端口:

$ adb -e forward tcp:1080 tcp:1080

现在,我们准备发送事件。要手动执行,我们可以使用 Telnet 客户端:

$ telnet localhost 1080

建立连接后,我们可以输入特定的猴子命令:

tap 150 200
OK

最后,退出 telnet 命令。

如果我们需要反复测试应用程序,创建一个包含我们想要发送的命令的脚本会方便得多。一个猴子脚本可能如下所示:

# monkey
tap 200 200
type HelloWorld
tap 200 350
tap 200 200
press DEL
press DEL
press DEL
press DEL
press DEL
type Monkey 
tap 200 350

注意

monkey tap 的 API 是 tap <x 像素位置> <y 像素位置>

因此,如果你运行的模拟器与记录猴子命令的分辨率不同,你可能会得到错误的触摸事件。

启动本章的示例应用后,我们可以运行这个脚本来测试用户界面。要启动应用,你可以使用模拟器窗口并点击其启动图标,或者使用命令行指定要启动的活动,如果模拟器是无头模式,这将是唯一的选择,如下所示:

$ adb shell am start -n com.blundell.tut/.MonkeyActivity

这在日志中由以下行通知:

Starting: Intent { cmp=com.blundell.tut/.MonkeyActivity}

应用程序启动后,你可以使用脚本和 netcat 实用工具发送事件:

$ nc localhost 1080 < ch_4_code_ex_10.txt

这会将脚本文件中的事件发送到模拟器。这些事件包括:

  1. 触摸并选择编辑文本输入。

  2. 输入 Hello World

  3. 点击按钮显示提示信息。

  4. 再次触摸并选择编辑文本。

  5. 删除其内容。

  6. 输入 Monkey

  7. 点击按钮显示Hello Monkey的提示信息。

这样,可以创建包含触摸事件和按键按下的简单脚本。

使用 monkeyrunner 进行测试脚本编写

Monkey 的能力相当有限,流程控制的缺失限制了其仅能用于非常简单的场景。为了绕过这些限制,创建了一个名为 monkeyrunner 的新项目。尽管如此,这个名字几乎相同,导致大量的混淆,因为它们之间没有任何关联。

Monkeyrunner,已包含在最新版本的 Android SDK 中,是一个提供 API 的工具,用于编写外部控制 Android 设备或模拟器的脚本。

Monkeyrunner 构建于 Jython 之上 (jython.org/),这是 Python 编程语言的一个版本 (python.org/),设计在 Java 平台上运行。

根据其文档,monkeyrunner 工具为 Android 测试提供了以下独特的功能。这些只是可以从 monkeyrunner 主页获取的完整功能列表、示例和参考文档的亮点 (developer.android.com/tools/help/monkeyrunner_concepts.html):

  • 多设备控制monkeyrunner API 可以在多个设备或模拟器上应用一个或多个测试套件。你可以物理连接所有设备或一次性启动所有模拟器(或两者兼有),然后以编程方式逐个连接到每个设备,并运行一个或多个测试。你也可以以编程方式启动模拟器配置,运行一个或多个测试,然后关闭模拟器。

  • 功能测试monkeyrunner可以运行一个 Android 应用的自动化从头到尾的测试。你提供通过按键或触摸事件输入的值,并以截图的形式查看结果。

  • 回归测试monkeyrunner可以通过运行应用并比较其输出截图与一组已知正确的截图来测试应用的稳定性。

  • 可扩展自动化:由于monkeyrunner是一个 API 工具包,你可以开发一整套基于 Python 的模块和程序来控制 Android 设备。除了使用monkeyrunner API 本身,你还可以使用标准的 Python OS 和 subprocess 模块调用 Android 工具,如 Android 调试桥。你还可以向monkeyrunner API 添加自己的类。这在线文档的“使用插件扩展 monkeyrunner”部分有更详细的描述。

获取测试截图。

目前,monkeyrunner 最明显的用途之一是获取待测应用的截图以供进一步分析或比较。

可以通过以下步骤获取这些截图:

  1. 导入所需的模块。

  2. 与设备建立连接。

  3. 检查设备是否已连接。

  4. 启动活动。

  5. 为活动启动添加一些延迟。

  6. 输入'hello'。

  7. 添加一些延迟以允许事件被处理。

  8. 获取截图。

  9. 将其保存到文件中。

  10. 返回退出活动。

以下是执行上述步骤所需的脚本代码:

#! /usr/bin/env monkeyrunner

import sys

# Imports the monkeyrunner modules used by this program
from com.android.monkeyrunner import MonkeyRunner, MonkeyDevice, MonkeyImage
# Connects to the current device, returning a MonkeyDevice object
device = MonkeyRunner.waitForConnection()

if not device:
    print >> sys.stderr, "Couldn't" "get connection"
    sys.exit(1)

device.startActivity(component='com'.blundell.tut/.MonkeyActivity')

MonkeyRunner.sleep(3.0)

device.type("hello")

# Takes a screenshot
MonkeyRunner.sleep(3.0)
result = device.takeSnapshot()

# Writes the screenshot to a file
result.writeToFile('/tmp/device.png')

device.press('KEYCODE_BACK', 'DOWN'_AND_UP')

脚本运行后,你可以在/tmp/device.png找到活动的截图。

记录和回放

如果你需要更简单的方法,无需手动创建这些脚本。为了简化这个过程,Android 源代码仓库中的 SDK 项目中包含的monkey_recorder.py脚本可以用来记录事件描述,这些描述稍后会被另一个名为monkey_playback.py的脚本解释。

从命令行运行monkey_recorder.py,你将看到这个用户界面:

记录和回放

这个界面有一个工具栏,工具栏上有按钮可以在记录的脚本中插入不同的命令:

按钮名 描述
等待 这表示需要等待多少秒。这个数字是通过一个对话框请求的。
按下按钮 这会发送菜单主页最近应用搜索按钮。按下向下向上事件。
输入内容 这会发送一个字符串。
滑动 这会在指定方向、距离和步数发送一个滑动事件。
导出动作 这会保存脚本。
刷新显示 这会刷新显示的截图副本。

完成脚本后,保存它,假设文件名为script.mr,然后你可以使用以下命令行重新运行它:

$ monkey_playback.py script.mr

现在,所有的事件都将被重新播放。

总结

在本章中,我们涵盖了所有将我们的应用程序及其测试暴露于广泛条件和配置的替代方案,从不同的屏幕尺寸,到设备(如相机或键盘)的可用性,再到模拟真实网络条件以检测我们应用程序中的问题。

我们还分析了所有可用的选项,以便能够在模拟器脱离窗口时远程控制它们。这为进行测试优先开发奠定了基础,我们将在第六章,实践测试驱动开发中回到这个话题。

我们讨论了 AVD 的速度,并看到了如何改进这一点,以及如何在 GenyMotion 和 HAXM 中查看模拟器选择。最后,介绍了一些脚本编写替代方案,并提供了一些入门示例。

在下一章中,我们将探索持续集成——一种依赖于自动运行所有测试套件以及配置、启动和停止模拟器以自动化完整构建过程的工作方式。

第五章:探索持续集成

持续集成是软件工程中的一种敏捷技术,旨在通过持续的应用集成和频繁的测试,而不是在开发周期结束时采用更传统的集成和测试方法,来提高软件质量并减少集成更改所需的时间。

持续集成已经得到了广泛的应用,商业工具和开源项目的激增是其成功的明显证明。这不难理解,因为任何在职业生涯中参与过使用传统方法的软件开发项目的人都可能经历过所谓的集成地狱,即集成更改所需的时间超过了做出更改的时间。这让你想起了什么?相反,持续集成是频繁且小步骤集成更改的做法。这些步骤是微不足道的,如果注意到错误,它如此之小以至于可以立即修复。最常见的做法是在每次提交到源代码仓库后触发构建过程。

这种实践还意味着除了源代码需要由版本控制系统(VCS)维护之外的其他要求:

  • 构建应当通过运行单个命令来自动化。这个特性已经被如makeant这样的工具支持了很长时间,并且最近也被mavengradle支持。

  • 构建应当自我测试,以确认新构建的软件符合开发者的预期。

  • 构建工件和测试结果应当易于查找和查看。

当我们为 Android 项目编写测试时,我们希望利用持续集成。为了实现这一点,我们想要创建一个与传统 IDE 环境和 Android 构建工具共存模型,这样无论在 CI 环境箱、IDE 还是在手动环境下,我们都能运行和安装我们的应用。

在本章中,我们将讨论以下内容:

  • 自动化构建过程

  • 引入版本控制系统到流程中

  • 使用 Jenkins 进行持续集成

  • 自动化测试

在本章之后,你将能够将持续集成应用到你的项目中,无论项目规模大小,无论是雇佣数十名开发人员的中大型软件项目,还是你一个人编程。

注意

关于持续集成的原始文章是由 Martin Fowler 在 2000 年撰写的(www.martinfowler.com/articles/continuousIntegration.html),描述了在一个大型软件项目上实施持续集成的经验。

使用 Gradle 手动构建 Android 应用程序

如果我们的目标是将在开发过程中整合持续集成,那么第一步将是手动构建 Android 应用程序,因为我们可以将集成机器与手动构建技术相结合来自动化这一过程。

通过这种方式,我们打算保持项目与 IDE 和命令行构建过程兼容,这就是我们将要做的。自动化构建是一个很大的优势,它通过立即构建并最终显示项目中可能存在的错误,从而加快开发过程。在编辑生成中间类的资源或其他文件时,CI 是一个无价的工具;否则,在构建过程中一些简单的错误可能会发现得太晚。遵循“经常失败,快速失败”的格言是推荐的做法。

幸运的是,Android 支持使用现有工具进行手动构建,并且在同一项目中合并手动 IDE 构建和自动 CI 构建并不需要太多努力。在这种情况下,支持在 IDE 中使用 Gradle 手动构建。然而,像 Ant 这样不再默认支持的选项,以及 Maven 或 Make 等不支持开箱即用的选项也存在。

注意

Gradle 是构建自动化的演进。Gradle 将 Ant 的强大和灵活性以及 Maven 的依赖管理和约定融合成更有效的构建方式。

更多信息可以在其主页上找到,gradle.org/

在撰写本文时,基于 Android Gradle 的项目至少需要 Gradle 2.2 或更新版本。

值得注意的是,整个 Android 开源项目并非由 Gradle 构建,而是由极其复杂的 make 文件结构构建,这种方法甚至用于构建平台中包含的应用程序,如计算器、联系人、设置等。

使用 Android Studio 创建新项目时,模板项目已经使用 Gradle 进行构建。这意味着你从命令行手动构建项目。在项目根目录执行 ./gradlew tasks 将提供可以运行的所有任务列表。最常用的任务如下表所示:

目标 描述
build 组装并测试此项目
clean 删除构建目录
tasks 显示可以从根项目 x 运行的任务(其中一些显示的任务可能属于子项目)
installDebug 安装调试版本
installDebugTest 为调试版本安装测试版本
connectedAndroidTest 在连接的设备上为构建调试安装并运行测试
uninstallDebug 卸载调试版本

前缀为 ./gradlew 的命令使用的是实际上包含在项目源代码中的 Gradle 安装。这称为 gradle 包装器。因此,你不需要在本地机器上安装 Gradle!但是,如果你在本地安装了 Gradle,所有使用包装器的命令都可以替换为 ./gradle。如果有多台设备或模拟器连接到构建机器,这些命令将在它们上面全部运行/安装。这对于我们的 CI 设置来说非常棒,意味着我们可以在所有提供的设备上运行我们的测试,以便处理多种配置和 Android 版本。如果你出于其他原因只想在其中一个上安装,通过设备提供商 API 是可以实现的,但这超出了本书的范围。我鼓励你在 tools.android.com 阅读更多内容,并查看广泛可用的 Gradle 插件,以帮助你完成这些工作。

现在我们可以运行这个命令来安装我们的应用程序:

$ ./gradlew installDebug

这是生成的输出开始和结束的部分:

Configuring > 3/3 projects
…
:app:assembleDebug 
:app:installDebug
Installing APK 'app'-debug.'apk' on 'emulator-5554'Installing APK 'app'-debug.'apk'on 'Samsung'Galaxy 'S4'
Installed on 2 devices.

BUILD SUCCESSFUL
Total time: 11.011 secs

运行前述提到的命令后,将执行以下步骤:

  • 编译源代码,包括资源、AIDL 和 Java 文件

  • 将编译的文件转换为原生的 Android 格式

  • 打包和签名

  • 安装到给定的设备或模拟器上

一旦我们安装了 APK,并且现在所有操作都从命令行进行,我们甚至可以启动如 EspressoActivity 的活动。使用 am start 命令和一个使用 MAIN 动作和我们感兴趣启动的活动作为组件的 Intent,我们可以创建如下命令行:

adb -s emulator-5554 shell am start -a android.intent.action.MAIN -n com.blundell.tut/.EspressoActivity

活动已经启动,你可以在模拟器中验证。现在要做的下一件事是安装我们应用程序的测试项目,然后使用命令行运行这些测试(如前几章所述)。最后,当它们完成后,我们应该卸载应用程序。如果你仔细阅读了命令列表,可能会注意到幸运的是,connectedAndroidTest Gradle 任务已经为我们完成了这些操作。

运行命令后,我们将获得测试结果。如果通过,输出如下所示:

:app:connectedAndroidTest
BUILD SUCCESSFUL
Total time: 9.812 secs

然而,如果它们失败了,输出将更加详细,并提供一个链接到文件,你可以查看完整的堆栈跟踪以及每个测试失败的原因:

:app:connectedAndroidTest
com.blundell.tut.ExampleEspressoTest > testClickingButtonShowsImage[emulator-5554]FAILED 
android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
 at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:6024)
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:connectedAndroidTest.
> There were failing tests. See the report at: file:///AndroidApplicationTestingGuide/app/build/outputs/reports/androidTests/connected/index.html
…
BUILD FAILED
Total time: 15.532 secs.

我们通过调用一些简单的命令从命令行完成了一切操作,这正是我们想要做的,以便将这个过程引入持续集成流程。

Git – 快速版本控制系统

Git 是一个免费且开源的分布式版本控制系统,旨在以速度和效率处理从小型到非常大型的各种项目。它非常容易设置,因此我强烈建议即使是个人项目也使用它。没有任何一个项目简单到无法从这个工具的应用中受益。你可以在 http://git-scm.com/ 找到信息和下载。

版本控制系统或版本控制系统(VCS)(也称为源代码管理或SCM)是涉及多个开发人员开发项目的不可避免元素,即使单人编码也是最佳实践。此外,尽管在没有 VCS 的情况下也可以应用持续集成(因为 VCS 不是 CI 的必要条件),但避免这样做并不是一个合理或推荐的做法。

在版本控制系统领域还有其他一些可能更传统的选择(见遗留系统),如 Subversion 或 CVS,如果你觉得更舒适,可以自由使用。否则,Git 被广泛应用于 Android 项目,托管谷歌自己的代码和示例,因此至少花时间了解基础知识是值得的。

说到这里,考虑到这是一个非常广泛的主题,足以证明其自身需要一本书(确实有一些好书关于这个主题),我们在这里讨论的是最基本的话题,并提供示例,以帮助那些还没有开始实践的人入门。

创建本地 Git 仓库

这些是最简单的命令,用于创建本地仓库并用我们项目的初始源代码填充它。在这个例子中,我们再次使用之前章节创建和使用的 AndroidApplicationTestingGuide 项目。我们复制了之前手动构建时使用的代码:

$ mkdir AndroidApplicationTestingGuide
$ cd AndroidApplicationTestingGuide
$ git init
$ cp -a <path/to/original>/AndroidApplicationTestingGuide/
$ gradlew clean
$ rm local.properties
$ git add .
$ git commit -m "Initial commit"

我们创建新的项目目录,初始化 Git 仓库,复制初始内容,清理并删除我们之前自动生成的文件,移除 local.properties 文件,将所有内容添加到仓库,并提交。

提示

local.properties 文件绝不能被检入版本控制系统,因为它包含特定于你本地配置的信息。你可能还想看看创建一个 .gitignore 文件。这个文件允许你定义哪些文件不被检入(例如自动生成的文件)。.gitignore 文件的示例可以在 github.com/github/gitignore 找到。

在这一点上,我们的项目仓库包含了我们应用程序的初始源代码以及所有的测试。我们没有改变结构,所以项目仍然与我们 IDE 和 Gradle 兼容,以便我们继续本地开发、构建和持续集成。

下一步是让我们的项目在我们每次提交源代码更改后自动构建和测试。

使用 Jenkins 进行持续集成

Jenkins 是一款开源、可扩展的持续集成服务器,具有构建和测试软件项目或监控外部作业执行的能力。Jenkins 安装和配置简单,因此被广泛使用。这使得它成为学习持续集成的理想示例。

安装和配置 Jenkins

我们提到 Jenkins 的优点之一是易于安装,而且安装过程确实简单。从 jenkins-ci.org/ 下载你选择操作系统的原生包。所有主要的服务器和桌面操作系统都有原生包。在以下示例中,我们将使用版本 1.592。下载 .war 文件后,我们将运行它,因为这不需要管理员权限。

完成后,将 war 文件复制到选择的目录 ~/jenkins,然后运行以下命令:

$ java -jar ~/jenkins/jenkins-1.592.war

这将展开并启动 Jenkins。

默认配置使用 8080 端口作为 HTTP 监听端口,因此将你的浏览器指向 http://localhost:8080 应该会显示 Jenkins 主页。如果需要,你可以通过访问 管理 Jenkins 屏幕来验证和更改 Jenkins 的操作参数。我们应该在此配置中添加所需的插件,以实现 Git 集成、使用 Gradle 构建、检查测试结果以及在构建过程中支持 Android 模拟器。这些插件分别是 Git 插件Gradle 插件JUnit 插件Android Emulator 插件

下面的屏幕截图展示了你可以通过 Jenkins 插件管理页面上的链接获取的插件信息:

安装和配置 Jenkins

安装并重新启动 Jenkins 后,这些插件就可以使用了。我们的下一步是创建构建项目所需的任务。

创建任务

让我们从 Jenkins 主页上的 新建项目 开始创建 AndroidApplicationTestingGuide 任务。以项目命名。可以创建不同类型的任务;在这种情况下,我们选择 自由风格项目,允许你将任何 SCM 连接到任何构建系统。

点击 确定 按钮后,你将看到具体的任务选项,如下表所述。这是任务属性页面顶部的如下内容:

创建任务

新建项目屏幕中的所有选项都有关联的帮助文本,因此这里我们只解释需要输入的部分:

选项 描述
项目名称 给项目赋予的名称。
描述 可选描述。
丢弃旧构建 这可以帮助你通过管理构建记录(如控制台输出、构建工件等)的保留时间来节省磁盘消耗。
此构建是参数化的 这允许你配置传递给构建过程的参数,以创建参数化构建,例如,使用 $ANDROID_HOME 而不是硬编码路径。
源代码管理 也称为 VCS,项目的源代码在哪里?在这种情况下,我们使用 Git 和一个仓库,该仓库的 URL 是我们之前创建的仓库的绝对路径。例如,/git-repo/AndroidApplicationTestingGuide
构建触发器 如何自动构建这个项目。在这种情况下,我们希望源代码的每次更改都触发自动构建,所以我们选择轮询 SCM。另一个选项是使用定时构建。这个特性主要用于将 Jenkins 作为cron的替代,对于持续构建软件项目来说并不理想。当人们第一次开始持续集成时,他们常常习惯于定期的构建,如每晚/每周,因此使用这个特性。然而,持续集成的要点是在做出更改后立即开始构建,以便为更改提供快速的反馈。这个选项可以用于长时间运行的构建,比如测试套件,可能在构建运行 1 小时时测试性能(将其配置为在午夜运行)。它还可以用于每晚或每周发布新版本。
计划 这个字段遵循Cron的语法(有一些小的差异)。具体来说,每行由五个由制表符或空格分隔的字段组成:分钟 小时 天 月 星期几。例如,如果我们想在每个小时的 30 分钟进行持续轮询,指定为:30 * * * *。查看文档以获取所有选项的完整解释。
构建环境 这个选项允许你为构建环境和可能在进行构建时运行的 Android 模拟器指定不同的选项。
构建 这个选项描述了构建步骤。我们选择调用 Gradle 脚本,因为我们重现了之前手动构建和测试项目的步骤。我们将选择使用 Gradle 包装器,这样我们的项目就不依赖于内置在 Gradle 版本的 CI 机器。然后,在任务框中,我们希望输入 clean connectedAndroidTest
构建后操作 这些是在构建完成后我们可以执行的一系列操作。我们希望保存 APK 文件,因此我们启用归档工件,然后定义它们的路径为要归档的文件;在这个具体的情况下,它是 **/*-debug.apk
保存 保存我们刚刚做的更改并完成构建任务创建。

既然我们的持续集成(CI)构建已经设置好了,有以下两个选项:

  • 你可以使用立即构建来强制构建。

  • 或者对源代码进行一些更改,使用 Git 提交,并等待它们被我们的轮询策略检测到。

无论哪种方式,我们都能构建我们的项目,并准备好工件以供其他用途,例如依赖项目或质量保证(QA)。不幸的是,如果你运行了 CI 构建,它会因为未连接设备而彻底失败。你可以选择连接一个真实设备,或者使用我们刚刚安装的 Android 模拟器插件。我们使用插件。在 Jenkins 中,转到我们刚刚创建的任务并点击配置

选项 描述
构建环境 我们的目标是在模拟器上安装并运行测试。因此,对于我们的构建环境,我们使用 Android Emulator 插件 提供的设施。如果你希望在构建步骤执行之前自动启动你选择的 Android 模拟器,并在构建完成后停止模拟器,这将非常方便。你可以选择启动预定义的、现有的 Android 模拟器实例(AVD)。或者,插件可以自动在构建从机上创建一个新的模拟器,并在此处指定属性。在任何情况下,logcat 输出都将自动捕获并归档。选择 使用属性运行模拟器。然后,选择 4.4 作为 Android OS 版本320 DPI 作为 屏幕密度,以及 WQVGA 作为 屏幕分辨率。请随意实验并选择更适合你需求的选项。
常见模拟器选项 我们希望在启动时重置模拟器状态以清除用户数据并禁用显示模拟器窗口,这样就不会显示模拟器窗口。

配置并构建此项目后,我们将在目标模拟器上安装 APK 并运行测试。

获取 Android 测试结果

测试运行后,结果将保存为 XML 文件,位于项目构建文件夹内的 /AndroidApplicationTestingGuides/app/build/outputs/androidTest-results/connected/

它们在那里对我们没有好处。如果我们能在 Jenkins 中读取我们的测试结果,并以漂亮的 HTML 格式显示它们,那就太好了;另一个 Jenkins 插件来拯救。JUnit 插件启用了一个构建后操作,询问你的 JUnit 报告存储在哪里,并将它们检索出来,以便在 Jenkins 的项目屏幕上轻松查看测试结果。在这种情况下,我们在作业配置页面也使用了构建后操作。 |

完成之前描述的所有步骤后,只剩下强制构建以查看结果。选项 描述
发布 JUnit 测试结果报告 当配置了此选项时,Jenkins 上的 JUnit 插件可以提供有关测试结果的实用信息,如历史测试结果趋势、用于查看测试报告的 Web UI、跟踪失败等。它需要一个正则表达式来查找 JUnit 结果文件。我建议使用 **/TEST*.xml。这个正则表达式应该匹配所有的 JUnit 测试结果,包括 Android 连接测试的结果;这里的研究赞誉归功于 Adam Brown。如果你更改了正则表达式,确保不要将任何非报告文件包含在此模式中。运行几个带有测试结果的构建后,你应该会开始看到一些趋势图表,显示测试的发展演变。

点击 立即构建,几分钟后,你将看到你的测试结果和统计数据以类似以下截图的方式显示:

获取 Android 测试结果

从这里,我们可以轻松了解项目状态。点击最新测试结果,你可以看到有多少测试失败以及原因。你可以搜索失败的测试,还可以找到详尽的错误信息堆栈跟踪选项。

通过评估不同的趋势来了解项目的演变也确实很有帮助,而 Jenkins 能够提供这类信息。每个项目都使用类似天气的图标展示当前趋势,从阳光明媚(项目健康度提高 80%)到雷暴(健康度低于 20%)。此外,对于每个项目,测试成功与失败比例的趋势演变也会在图表中显示。下面是失败的测试图表:

获取 Android 测试结果

在这个例子中,我们可以看到在构建 9 时,有四个测试失败了,其中三个在构建 10 中修复,最后一个在构建 11 中修复。

为了看到项目状态如何通过强制失败而改变,让我们添加一个如下所示的失败测试。别忘了推送你的提交,以触发 CI 构建,如下所示:

  public final void testForceFailure() {
    fail("fail test is fail");
  }

另一个非常有趣且值得一提的功能是 Jenkins 能够保存和显示时间轴和构建时间趋势,如下面的截图所示:

获取 Android 测试结果

这个页面展示了带有链接的构建历史,你可以通过这些链接查看每个特定构建的详细信息。现在我们不必担心太多,每当开发团队的成员将变更提交到仓库时,我们知道这些变更将立即集成,整个项目将被构建和测试。如果我们进一步配置 Jenkins,我们甚至可以通过电子邮件接收状态。为此,请在作业配置页面启用电子邮件通知,并输入所需的收件人

总结

本章通过实际应用介绍了持续集成的概念,提供了有价值的信息,以便你尽快将其应用到项目中,无论项目规模大小,无论你是独立开发还是大型公司团队的一员。

所介绍的技术关注于 Android 项目的特性,维护并支持广泛使用的开发工具,如 Android Studio 和 Android Gradle 插件。

我们引入了现实世界中的示例和工具,这些工具来自丰富的开源武器库。我们使用 Gradle 自动化构建过程,使用 Git 创建一个简单的版本控制系统仓库来存储我们的源代码和管理变更,最后安装并配置了 Jenkins 作为我们选择的持续集成工具。

在 Jenkins 中,我们详细介绍了创建作业的过程,以自动化创建我们的 Android 应用程序及其测试,并强调了持续集成框与其设备/模拟器之间的关系。

最后,我们意识到了与安卓相关的测试结果,并实施了一项策略,以获得一个吸引人的界面来监视测试的运行、它们的结果和现有的趋势。

下一章将带领我们走上测试驱动开发的道路;你最终将开始理解为什么我在迄今为止的所有示例中都在谈论温度,这对于一个真实的项目来说非常重要。因此,建立持续集成设置非常完美,可以帮助我们编写优秀的代码,并相信我们的持续集成构建的 APK 已经准备好发布。

第六章:实践测试驱动开发

本章介绍了测试驱动开发TDD)的纪律。我们将从广义上的 TDD 实践开始,随后转移到与 Android 平台更相关的概念和技术。

这是一个代码密集的章节,所以准备好边阅读边输入,这将帮助你从提供的示例中获得最大收益。

在本章中,我们将学习以下主题:

  • 引入并解释测试驱动开发(Test-driven Development)。

  • 分析其优点。

  • 引入一个现实生活中的例子。

  • 通过编写测试来理解项目需求。

  • 通过应用 TDD 在项目中不断发展。

  • 获得一个完全符合要求的程序。

开始使用 TDD。

简而言之,测试驱动开发是边开发边编写测试的策略。这些测试用例是在预期将满足它们的代码之前编写的。

我们首先编写一个测试,然后编写满足这个测试编译所需的代码,接着实现测试规定的应有的行为。我们持续编写测试和实现,直到测试检查完所有期望行为的完整集合。

这与其他开发过程方法形成对比,那些方法是在所有编码完成后的末期编写测试。

提前编写满足它们的代码的测试具有以下优点:

  • 测试以这样或那样的方式编写,而如果将测试留到开发末期,很可能会永远写不出来。

  • 当开发者在编写代码时需要考虑测试时,他们会对自己工作的质量承担更多责任。

设计决策分小步进行,之后通过重构改进满足测试的代码。记住,这需要在测试运行的情况下进行,以确保预期行为没有回归。

测试驱动开发通常通过以下类似的图表来解释,以帮助我们理解这个过程:

开始使用 TDD

以下各节将详细展开与 TDD 相关的红、绿、重构循环的个别行动。

编写一个测试用例。

我们从编写一个测试用例开始开发过程。这显然是一个简单的过程,会在我们脑海中启动一些机制。毕竟,如果我们对问题领域及其细节没有清晰的理解,就不可能编写一些代码,测试它或不测试。通常,这一步会让你直接面对你不理解的问题方面,如果你想建模和编写代码,就需要掌握这些方面。

运行所有测试。

编写测试后,下一步是运行它,以及到目前为止我们编写的所有其他测试。在这里,拥有内置测试环境支持的 IDE 的重要性可能比其他情况下更加明显,可以大幅缩短开发时间。我们预期,首先,我们新编写的测试会失败,因为我们还没有编写任何代码。

为了完成我们的测试,我们编写额外的代码并做出设计决策。编写的额外代码是最少的,以使我们的测试能够编译。在这里请注意,不能编译就是失败。

当我们让测试编译并运行,如果测试失败了,那么我们会尝试编写最少的代码以使测试成功。这在目前听起来可能有些别扭,但本章接下来的代码示例将帮助你理解这个过程。

可选地,你可以先只运行新增加的测试,以节省一些时间,因为有时在模拟器上运行测试可能会相当慢。然后再运行整个测试套件,以验证一切是否仍然正常工作。我们不想在添加新功能时破坏代码中已有的任何功能。

重构代码

当测试成功时,我们会重构添加的代码,以保持其整洁、干净,并且是可维护和可扩展应用程序所需的最小代码量。

我们再次运行所有测试,以验证重构是否破坏了任何功能,如果测试再次通过且无需进一步重构,那么我们就完成了任务。

重构后运行测试是这种方法提供的一个非常安全的保障。如果我们重构算法时犯了错误,提取变量,引入参数,改变签名,或者无论重构机制是什么,这个测试基础设施都会发现问题。此外,如果某些重构或优化对每个可能的情况都不适用,我们可以通过作为测试用例表达的应用程序中的每个案例来验证它。

TDD 的优点

就我个人而言,到目前为止看到的主要优点是它能快速让你专注于编程目标,而且不容易分心或急躁,在软件中实施那些永远不会被使用的选项(有时被称为镀金)。这种实施不必要功能的做法是浪费你宝贵的发展时间。正如你可能已经知道的,谨慎地管理这些资源可能是成功完成项目与否则之间的区别。

另一个优点是,你始终有一个安全网来保护你的更改。每次你更改一段代码时,只要有关联的测试验证条件没有改变,你就可以完全确定系统的其他部分没有受到影响。

请记住,TDD 不能随意应用于任何项目。我认为,像任何其他技术一样,你应该运用你的判断力和专业知识来识别它适用的地方和不适用的地方。始终记住:没有银弹

理解需求

要能够编写关于任何主题的测试,我们首先应该了解被测试的主题,这意味着要分解你试图实现的要求。

我们提到,其中一个优点是你可以快速关注一个目标,而不是围绕需求这个庞大而难以克服的整体旋转。

将需求翻译成测试,并相互参照,可能是理解需求最佳方式,以确保所有需求都有实现和验证。此外,当需求发生变化(这在软件开发项目中非常常见)时,我们可以更改验证这些需求的测试,然后更改实现以确保所有内容都被正确理解和映射到代码中。

创建示例项目 - 温度转换器

你可能已经从迄今为止的一些代码片段中猜到了,我们的 TDD 示例将围绕一个极其简单的 Android 示例项目展开。它不试图展示所有花哨的 Android 功能,而是专注于测试,并逐步从测试中构建应用程序,应用之前学到的概念。

假设我们收到了一个开发 Android 温度转换应用程序的需求列表。虽然过于简化,但我们将按照正常步骤来开发此类应用程序。但是,在这种情况下,我们将在过程中引入测试驱动开发技术。

需求列表

通常(让我们诚实一点),需求列表非常模糊,有很多细节没有完全覆盖。

举个例子,假设我们收到了这个列表:

  • 该应用程序将温度从摄氏度转换为华氏度,反之亦然。

  • 用户界面提供了两个输入温度的字段;一个用于摄氏度,另一个用于华氏度。

  • 当在一个字段中输入温度时,另一个字段会自动更新为转换后的值。

  • 如果有错误,应向用户显示,最好使用相同的字段。

  • 用户界面应保留一些空间用于屏幕键盘,以便在进行多次转换输入时简化应用程序的操作。

  • 输入字段应从空开始

  • 输入的值是带有两位小数的十进制值

  • 数字右对齐

  • 应用程序暂停后,最后输入的值应保持不变。

用户界面概念设计

假设我们从用户界面设计团队收到了这个概念性的用户界面设计(我现在就为我的缺乏想象力和技巧向所有设计师道歉):

用户界面概念设计

创建项目

我们的第一步是创建项目。现在,由于我们已经为前五个章节做过这个,我认为我不需要为你提供一步一步的指导。只需通过 Android Studio 新项目向导,选择带有你的包名的 Android 移动项目,加上其他样板文件,不要 Activity 模板。Android Studio 会自动为你创建一个示例AndroidApplicationTestCase。记住,如果你遇到困难,可以参考本书的代码附录。创建后,它应该看起来像这样:

创建项目

现在,让我们快速创建一个名为TemperatureConverterActivity的新 Activity(我们没有使用模板生成器,因为它添加了很多现在不需要的代码),不要忘记将 Activity 添加到你的AndroidManifest文件中。狂热的 TDD 实践者现在可能正激动地挥舞着拳头,因为实际上你应该在测试中需要时才创建这个 Activity,但我同时也在尝试用一些熟悉的内容引导你。

创建一个 Java 模块

在这个模板项目之上,我们想要添加另一个代码模块。这将是一个仅 Java 的模块,并将作为我们主 Android 模块的依赖或库。

这里的想法有两方面。首先,它允许你将仅 Java 的代码(不依赖于 Android)分离出来,在一个大项目中,这可以是你的核心领域;运行你的应用程序的业务逻辑,重要的是你要模块化这部分,这样你就可以在不考虑 Android 的情况下工作。

其次,正如我们之前所说,拥有一个仅 Java 的模块,在测试时可以让你调用 Java 作为一门成熟编程语言的丰富历史。Java 模块的测试快速、简单、便捷。你可以为 JVM 编写 JUnit 测试,并在几毫秒内运行它们(我们将会这样做!)。

在 Android Studio 中,导航到文件 | 新建模块,这将弹出创建新模块对话框。在更多模块下,选择Java 库,然后点击下一步。给你的库命名为core,确保包名与你的 Android 应用程序相同,然后点击完成。最后一个界面应该看起来像这样:

创建一个 Java 模块

创建后,你需要从你的 Android :app模块添加单向依赖到:core模块。在/app/build.gradle中,添加对核心模块的依赖:

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:appcompat-v7:21.0.2'

    compile project(':core')
}

这允许我们从 Android 应用程序内部引用核心模块中的文件。

创建TemperatureConverterActivityTests

通过选择主测试包名com.blundell.tut.继续创建第一个测试。这在 AndroidStudio 项目视图中的src/androidTest/Java下,或者在 AndroidStudio Android 视图中的app/java/(androidTest)下。然后在这里右键点击,选择新建 | Java 类,将其命名为TemperatureConverterActivityTests

创建类之后,我们需要将其转变为一个测试类。我们应该根据要测试的内容和方式来选择我们的超类。在第二章《理解使用 Android SDK 的测试》中,我们回顾了可用的选择。在尝试决定使用哪个超类时,可以将其作为参考。

在这个特定情况下,我们正在测试单个 Activity 并使用系统基础设施,因此,我们应该使用ActivityInstrumentationTestCase2。还请注意,由于ActivityInstrumentationTestCase2是一个泛型类,我们还需要模板参数。这是我们测试的 Activity,在我们的例子中,是TemperatureConverterActivity

我们现在注意到,在运行之前需要修复类中的一些错误。否则,这些错误将阻止测试运行。

我们需要修复的问题在第二章《理解使用 Android SDK 的测试》中的无参数构造函数部分已经描述过。根据此模式,我们需要实现:

  public TemperatureConverterActivityTests() {
    this("TemperatureConverterActivityTests");
  }

  public TemperatureConverterActivityTests(String name) {
    super(TemperatureConverterActivity.class);
    setName(name);
  }

到目前为止,我们执行了以下步骤:

  • 我们添加了一个无参数构造函数TemperatureConverterActivityTests()。从这个构造函数中,我们调用了需要名称作为参数的构造函数。

  • 最后,在这个给定名称的构造函数中,我们调用了超构造函数并设置名称。

为了验证是否一切已经设置好并就位,你可以通过右键点击类,选择运行 | 测试类的名称来运行测试。目前还没有测试可运行,但至少我们可以验证支持我们测试的基础设施已经就位。它应该会以未找到测试警告失败。以下是运行测试类的步骤,以防你错过了:

创建 TemperatureConverterActivityTests 类

创建夹具

我们可以通过向setup()方法中添加测试所需的元素来开始创建测试夹具。在这种情况下,几乎不可避免的是要使用待测试的 Activity,因此让我们为此情况做好准备,并将其添加到夹具中:

@Override  
public void setUp() throws Exception {
    super.setUp();
    activity = getActivity();
}

引入上述代码后,使用 AndroidStudio 的重构工具创建activity字段以节省时间。(F2查看下一个错误,Alt + Enter快速修复,Enter创建字段,Enter再次确认字段类型,完成!)

ActivityInstrumentationTestCase2.getActivity()方法有一个副作用。如果测试的活动没有运行,它将被启动。如果我们在测试中多次将getActivity()作为简单的访问器,并且由于某种原因活动在测试完成前结束或崩溃,这可能会改变测试的意图。我们将会无意中重新启动活动,这就是为什么在测试中我们不鼓励使用getActivity(),而倾向于在夹具中拥有它,这样我们隐式地为每个测试重新启动活动。

创建用户界面

回到我们的测试驱动开发轨道,从我们简洁的需求列表中可以看出,分别有两个条目用于摄氏度和华氏度温度。因此,让我们将它们添加到我们的测试夹具中。

它们尚未存在,我们甚至还没有开始设计用户界面布局,但我们知道肯定需要有两个这样的条目。

这是你应该添加到setUp()方法中的代码:

celsiusInput = (EditText)
  activity.findViewById(R.id.converter_celsius_input);
fahrenheitInput = (EditText)
  activity.findViewById(R.id.converter_fahrenheit_input);

有一些重要的事情需要注意:

  • 我们选择名称converter_celsius_input,因为converter_是此字段(在TemperatorConverter活动中)的位置,celsius_是字段代表的内容,最后 input 是字段的行为方式。

  • 我们使用EditText为我们的夹具定义字段

  • 我们使用之前创建的活动通过 ID 查找视图

  • 即使这些 ID 不存在,我们仍然在主项目中使用R

测试用户界面组件的存在

一旦我们在setUp()方法中添加了它们,如前一部分所示,我们可以编写我们的第一个测试并检查视图的存在:

  public final void testHasInputFields() {
    assertNotNull(celsiusInput);
    assertNotNull(fahrenheitInput);
  }

我们还不能运行测试,因为我们必须先解决一些编译问题。我们应该修复R类中缺失的 ID。

创建了引用我们尚未拥有的用户界面元素和 ID 的测试夹具后,测试驱动开发范式要求我们添加所需的代码以满足我们的测试。我们应该做的第一件事是让测试编译,这样如果我们有测试未实现功能的测试,它们将失败。

获取定义的 ID

我们首先要定义用户界面元素在R类中的 ID,这样引用未定义常量R.id.converter_celsius_inputR.id.converter_fahrenheit_input产生的错误就会消失。

作为经验丰富的 Android 开发者,你将知道如何操作。不管怎样,我会为你提供一个复习。在布局编辑器中创建一个activity_temperature_converter.xml布局,并添加所需的用户界面组件,以得到类似于之前在用户界面概念设计部分介绍的设计,如下代码所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="vertical">

  <TextView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginBottom="@dimen/margin"
    android:text="@string/message" />

  <<TextView
    android:id="@+id/converter_celsius_label"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="@dimen/margin"
    android:text="@string/celsius" />

  <EditText
    android:id="@+id/converter_celsius_input"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_margin="@dimen/margin"  />

  <TextView
    android:id="@+id/converter_fahrenheit_label"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="@dimen/margin"
    android:text="@string/fahrenheit"  />

  <EditText
    android:id="@+id/converter_fahrenheit_input"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_margin="@dimen/margin"  />
</LinearLayout>

这样做,我们让测试编译(别忘了添加字符串和尺寸),运行测试,它们通过了吗?不,它们不应该!你需要挂接你的新活动布局(我敢打赌你已经领先一步了):

public class TemperatureConverterActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_temperature_converter);
    }
}

再次运行测试,你应该得到以下结果:

  • testHasInputFields测试成功

  • 现在一切变绿了

测试输出的结果如下所示:

获取定义的 ID

这清楚地意味着我们正在按照 TDD 的路线进行。

你可能也注意到了,我们在用户界面中添加了一些装饰性非功能性项目,我们没有测试(比如填充),主要是为了让我们的示例尽可能简单。在实际场景中,你可能还想为这些元素添加测试。

将需求翻译为测试

测试具有双重特性。它们验证我们代码的正确性,但在有时,特别是在 TDD 中,它们可以帮助我们理解设计并消化我们正在实现的内容。为了能够创建测试,我们需要了解我们正在处理的问题,如果我们不了解,我们至少应该有一个大致的问题轮廓,以便我们可以开始处理它。

在很多情况下,用户界面背后的需求并没有明确表达,但你应该能够从线框 UI 设计中理解它们。如果我们假设这是这种情况,那么我们可以通过首先编写我们的测试来抓住设计。

空字段

从我们的一项需求中,我们得到:输入字段应该从空开始。

为了在测试中表达这一点,我们可以编写以下内容:

    public void testFieldsShouldStartEmpty() {
        assertEquals("", celsiusInput.getText().toString());
        assertEquals("", fahrenheitInput.getText().toString());
    }

在这里,我们只需将字段的初始内容与空字符串进行比较。

这个测试立即通过,太好了!尽管 TDD 的一个原则是从红色测试开始,你可能想要做一个快速的正确性检查,为EditText的 XML 添加一些文本并运行测试,当它在你删除添加的文本后再次变红变绿时,你知道你的测试正在验证你期望的行为(而不是因为你不期望的副作用而变绿)。我们成功地将一个需求转换为测试,并通过获取测试结果来验证它。

视图属性

同样,我们可以验证组成我们布局的视图的其他属性。我们可以验证其他事项,例如:

  • 字段(如预期出现在屏幕上)

  • 字体大小

  • 边距

  • 屏幕对齐

让我们先验证字段是否在屏幕上:

    public void testFieldsOnScreen() {
        View origin = activity.getWindow().getDecorView();

        assertOnScreen(origin, celsiusInput);
        assertOnScreen(origin, fahrenheitInput);
    }

如前所述,我们从这里使用一个断言:ViewAssertsassertOnScreen

注意

静态导入及其最佳使用方式在第二章中进行了说明,理解使用 Android SDK 的测试。如果你之前没有做过,现在是时候了。

assertOnScreen()方法需要一个起点来寻找其他视图。在这种情况下,因为我们要从最高级别开始,我们使用getDecorView(),它获取包含标准窗口框架和装饰的顶级窗口视图,以及客户端内容。

通过运行这个测试,我们可以确保输入字段出现在屏幕上,正如 UI 设计所规定的那样。在某种程度上,我们已经知道具有这些特定 ID 的视图存在。也就是说,我们通过将视图添加到主布局中,使装置得以编译,但我们并不确定它们是否真的出现在屏幕上。因此,仅需要这个测试的存在,以确保将来不会改变这个条件。如果我们因为某些原因移除了其中一个字段,这个测试会告诉我们它缺失了,不符合 UI 设计。

继续我们的需求列表,我们应该测试视图是否按照我们期望的方式在布局中排列:

    public void testAlignment() {
        assertLeftAligned(celsiusLabel, celsiusInput);
        assertLeftAligned(fahrenheitLabel, fahrenheitInput);
        assertLeftAligned(celsiusInput, fahrenheitInput);
        assertRightAligned(celsiusInput, fahrenheitInput);
    }

我们继续使用ViewAssert中的断言——在这种情况下,使用assertLeftAlignedassertRightAligned。这些方法验证指定视图的对齐方式。为了运行这个测试,我们必须在setUp()方法中为标签 TextView 添加两个查找:

celsiusLabel = (TextView)
  activity.findViewById(R.id.converter_celsius_label);
fahrenheitLabel = (TextView)
  activity.findViewById(R.id.converter_fahrenheit_label);

我们默认使用的LinearLayout类以我们期望的方式排列字段。再次强调,虽然我们不需要向布局中添加任何东西以满足测试,但这将作为一个保护条件。

一旦我们验证它们正确对齐,我们应该验证它们是否覆盖了整个屏幕宽度,如原理图所指定。在这个例子中,只需验证LayoutParams具有正确的值就足够了:

    public void testCelciusInputFieldCoversEntireScreen() {
     LayoutParams lp;
     int expected = LayoutParams.MATCH_PARENT;
     lp = celsiusInput.getLayoutParams();  
     assertEquals("celsiusInput layout width is not MATCH_PARENT", expected, lp.width);
    }

    public void testFahrenheitInputFieldCoversEntireScreen() {
     LayoutParams lp;
     int expected = LayoutParams.MATCH_PARENT;
     lp = fahrenheitInput.getLayoutParams();
     assertEquals("fahrenheitInput layout width is not MATCH_PARENT", expected, lp.width);
    }

我们使用自定义信息以便在测试失败时轻松识别问题。

运行这个测试,我们得到以下信息,表明测试失败了:AssertionFailedError: celsiusInput 布局宽度不是 MATCH_PARENT,预期:<-1> 但实际:<-2>

这引导我们到布局定义。我们必须将layout_width从 Celsius 和 Fahrenheit 字段更改为match_parent

<EditText
    android:id="@+id/converter_celsius_input"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="@dimen/margin"
    android:gravity="end|center_vertical" /> 

Fahrenheit 字段也是如此——更改完成后,我们重复这个循环,并再次运行测试,以验证现在它是否成功。

我们的方法开始显现。我们创建测试用以验证需求中描述的条件。如果条件不满足,我们就改变问题产生的原因,并再次运行测试,验证最新的更改是否解决了问题,或许更重要的是,这个更改没有破坏现有的代码。

接下来,让我们验证字体大小是否符合我们的要求:

    public void testFontSizes() {
        float pixelSize = 24f;
        assertEquals(pixelSize, celsiusLabel.getTextSize());
        assertEquals(pixelSize, fahrenheitLabel.getTextSize());
    }

在这种情况下,获取字段使用的字体大小就足够了。

默认字体大小不是24px,因此我们需要将其添加到我们的布局中。一个好的做法是将相应的尺寸添加到资源文件中,然后在布局中需要的地方使用它。所以,让我们在res/values/dimens.xml中添加label_text_size,值为24sp。然后在标签celsius_labelfahrenheit_labelText大小属性中引用它。

现在,测试可能通过也可能不通过,这取决于你使用的设备或模拟器的分辨率。这是因为我们在测试中断言像素大小,但在dimens.xml中我们声明使用sp(与缩放无关的像素)。让我们加强这个测试。为了解决这个问题,我们可以在测试类中将px转换为sp,或者在测试中使用sp值。我选择在测试中使用sp,尽管你也可以为另一种方法争论:

    public void testFontSizes() {
        float pixelSize = getFloatPixelSize(R.dimen.label_text_size);

        assertEquals(pixelSize, celsiusLabel.getTextSize());
        assertEquals(pixelSize, fahrenheitLabel.getTextSize());
    }

    private float getFloatPixelSize(int dimensionResourceId) {
        return getActivity().getResources()
                 .getDimensionPixelSize(dimensionResourceId);
    }

最后,让我们验证边距是否按照用户界面设计中的描述进行了解释:

    public void testCelsiusInputMargins() {
        LinearLayout.LayoutParams lp =
(LinearLayout.LayoutParams) celsiusInput.getLayoutParams();

        assertEquals(getIntPixelSize(R.dimen.margin), lp.leftMargin);
        assertEquals(getIntPixelSize(R.dimen.margin), lp.rightMargin);
    }

    public void testFahrenheitInputMargins() {
        LinearLayout.LayoutParams lp =
(LinearLayout.LayoutParams) fahrenheitInput.getLayoutParams();

        assertEquals(getIntPixelSize(R.dimen.margin), lp.leftMargin);
        assertEquals(getIntPixelSize(R.dimen.margin), lp.rightMargin);
    }

这与之前的案例类似(我跳过了测试原始像素值的步骤)。我们需要在布局中添加边距。让我们在资源文件中添加边距尺寸,然后在布局中需要的地方引用它。在res/values/dimens.xml中将margin尺寸设置为8dp。然后,在celsiusfahrenheit两个字段的layout_margin_start属性以及标签的start margin中引用它。

获取从资源dimen中整数像素大小的helper方法,只是包装了之前讨论过的float方法:

    private int getIntPixelSize(int dimensionResourceId) {
        return (int) getFloatPixelSize(dimensionResourceId);
    }

还有一件事是剩下的,就是验证输入值的对齐(调整)。我们很快就会验证输入,只允许输入允许的值,但现在让我们关注对齐。意图是让小于整个字段的值右对齐并垂直居中:

public void testCelsiusInputJustification() {
  int expectedGravity = Gravity.END | Gravity.CENTER_VERTICAL;
  int actual = celsiusInput.getGravity();
  String errorMessage = String.format(
"Expected 0x%02x but was 0x%02x", expectedGravity, actual);
  assertEquals(errorMessage, expectedGravity, actual);
}

public void testFahrenheitInputJustification() {
  int expectedGravity = Gravity.END | Gravity.CENTER_VERTICAL;
  int actual = fahrenheitInput.getGravity();
  String errorMessage = String.format(
"Expected 0x%02x but was 0x%02x", expectedGravity, actual);
  assertEquals(errorMessage, expectedGravity, actual);
}

在这里,我们像往常一样验证gravity值。然而,我们使用自定义消息来帮助我们识别可能出错的值。由于Gravity类定义了几个常量,其值以十六进制表示更容易识别,我们在消息中将这些值转换为这种基数。

如果这个测试因为字段使用的默认重力而失败,那么剩下的就是改变它。转到布局定义并更改这些gravity值,以便测试成功。

这正是我们需要添加的内容:

android:gravity="end|center_vertical"

屏幕布局

现在,我们希望验证是否已经满足规定屏幕空间应保留足够空间以显示键盘的要求。

我们可以编写如下测试:

   public void testVirtualKeyboardSpaceReserved() {
        int expected = getIntPixelSize(R.dimen.keyboard_space);
        int actual = fahrenheitInput.getBottom();
String errorMessage = 
  "Space not reserved, expected " + expected + " actual " + actual;
        assertTrue(errorMessage, actual <= expected);
    }

这验证了屏幕上最后一个字段fahrenheitInput的实际位置是否不低于建议值。

我们可以再次运行测试,验证一切是否再次变绿。运行你的应用程序,你应该会有一个由测试支持的完整用户界面,如下截图所示:

屏幕布局

添加功能

用户界面已经就位。现在,我们可以开始添加一些基本功能。这些功能将包括处理实际温度转换的代码。

温度转换

从需求列表中,我们可以得到这个声明:在一个字段中输入一个温度时,另一个字段应自动更新为转换后的温度。

按照我们的计划,我们必须实现这个测试以验证正确功能的存在。我们的测试可能看起来像这样:

@UiThreadTest
public void testFahrenheitToCelsiusConversion() {
  celsiusInput.clear();
  fahrenheitInput.clear();
  fahrenheitInput.requestFocus();
  fahrenheitInput.setText("32.5");
  celsiusInput.requestFocus();
  double f = 32.5;
  double expectedC = TemperatureConverter.fahrenheitToCelsius(f);
  double actualC = celsiusInput.getNumber();
  double delta = Math.abs(expectedC - actualC);
  String msg = "" + f + "F -> " + expectedC + "C but was " 
    + actualC + "C (delta " + delta + ")";
  assertTrue(msg, delta < 0.005);
}

让我们一步一步地执行这个操作:

  1. 首先,正如我们已经知道的,为了与 UI 交互并更改其值,我们应该在 UI 线程上运行测试,因此由于我们使用EditText.setText,测试被注解为@UiThreadTest

  2. 其次,我们使用一个专门的类来替换EditText,提供一些便捷方法,如clear()setNumber()。这将改善我们的应用设计。

  3. 接下来,我们调用一个名为TemperatureConverter的转换器,这是一个工具类,提供不同的方法来转换不同的温度单位,并使用不同的温度值类型。

  4. 最后,由于我们将截断结果以在用户界面中以合适的格式呈现,我们应该与一个增量比较来断言转换值的准确性。

这样创建测试将迫使我们按照计划路径执行。我们的第一个目标是添加所需的方法和代码以使测试能够编译,然后满足测试的需求。

EditNumber 类

在我们的主包中(不是在测试包中,也不是在/androidTest/下的那个),我们应该创建一个继承EditTextEditNumber类,因为我们需要扩展其功能。创建类后,我们需要更改测试类成员类型的字段类型:

public class TemperatureConverterActivityTests extends ActivityInstrumentationTestCase2<TemperatureConverterActivity> {

  private TemperatureConverterActivity activity;  
  private EditNumber celsiusInput;
  private EditNumber fahrenheitInput;
  private TextView celsiusLabel;
  private TextView fahrenheitLabel;

然后,更改测试中存在的任何强制类型转换。你的 IDE 会高亮这些代码;按下F2在类中找到它们。

在能够编译测试之前,我们还需要解决两个问题:

  • 我们在EditNumber中仍然没有clear()setNumber()方法

  • 我们还没有TemperatureConverter工具类

在测试类的内部,我们可以使用 IDE 来帮助我们创建方法。再次按下F2,你应被带到无法解析方法 clear()的错误处。现在按下Alt + Enter,在EditNumber类型中创建clear()方法。getNumber()方法同理。

最后,我们必须创建TemperatureConverter类。这个类将包含摄氏度和华氏度的数学转换,不包含任何 Android 代码。因此,我们可以在/core/模块内创建此包。如先前讨论的,它将位于相同的包结构下,只是这个模块不知道 Android,因此我们可以编写运行速度更快的 JVM 测试。

提示

确保在核心模块中创建它,并且与你的主代码位于同一包下,而不是测试包。

下面是在核心模块中创建该类,以及我们应用程序当前状态的方法:

EditNumber 类

完成此操作后,在我们的测试中,创建了fahrenheitToCelsius方法。

这解决了我们之前的问题,并引导我们进行一个现在可以编译和运行的测试。是的,你将会看到红色的 Lint 错误,但这些并不是"编译"错误,因此测试仍然可以运行。(AndroidStudio 的智能程度实在是太高了。)

出乎意料的是,当我们运行测试时,它们会因为异常而失败:

java.lang.ClassCastException:
android.widget.EditText cannot be cast to com.blundell.tut.EditNumber
at com.blundell.tut.TemperatureConverterActivityTests.setUp(
TemperatureConverterActivityTests.java:36)
at android.test.AndroidTestRunner.runTest(
AndroidTestRunner.java:191)

这是因为我们更新了所有的 Java 文件以包含我们新创建的EditNumber类,但忘记了更改布局 XML。

让我们继续更新我们的 UI 定义:

<com.blundell.tut.EditNumber
    android:id="@+id/converter_celsius_input"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="@dimen/margin"
    android:gravity="end|center_vertical" />

也就是说,我们用扩展原始EditText类的com.blundell.tut.EditNumber视图替换了原来的EditText类。

现在,我们再次运行测试,发现所有测试都通过了。

但等一下;我们在新的EditNumber类中还没有实现任何转换或处理值的方法,所有测试都顺利通过了。是的,它们通过了,因为我们的系统没有足够的限制,现有的限制仅仅相互抵消了。

在继续之前,让我们分析一下刚才发生了什么。我们的测试调用了fahrenheitInput.setText("32.5")方法来设置华氏度字段输入的温度,但我们的EditNumber类在输入文本时什么也不做,功能尚未实现。因此,摄氏度字段保持为空。

expectedC的值——预期的摄氏度温度,是通过调用TemperatureConverter.fahrenheitToCelsius(f)计算的,但这是一个空方法。在这种情况下,因为我们知道方法的返回类型,所以我们让它返回一个常数0。因此,expectedC变成了0

然后,从用户界面获取转换的实际值。在这种情况下,从EditNumber调用getNumber()。但这个方法是自动生成的,为了满足其签名所施加的限制,它必须返回一个值,即0

Δ值再次为0,由Math.abs(expectedC - actualC)计算得出。

最后,我们的断言assertTrue(msg, delta < 0.005)true,因为delta=0满足了条件,测试通过。

那么,我们的方法有缺陷吗,因为它无法检测到这种情况?

不,完全不是,这里的问题是我们的限制不够,而这些限制被自动生成方法使用的默认值满足了。一种替代方法可能是对所有自动生成的方法抛出异常,比如RuntimeException("尚未实现"),以检测在未实现时使用它的情况。我们将在系统中增加足够的限制,以便轻松捕捉这种双重零条件。

温度转换器的单元测试

从我们之前经验来看,似乎默认实现的转换总是返回0,因此我们需要更健壮的东西。否则,我们的转换器只有在参数取 32F(32F == 0C)的值时才会返回有效的结果。

TemperatureConverter 类是一个与 Android 基础架构无关的实用类,因此一个标准的单元测试就足以测试它。

由于这是我们即将编写的第一个核心测试,我们需要进行一些设置。首先,从项目视图开始;在你的项目结构中,通过选择 新建 | 目录 并使用名称 test/core/src 下创建一个 test 文件夹。在这个文件夹内,通过选择 新建 | 目录 并使用名称 java 创建一个 java 文件夹。由于 Gradle 的神奇之处,它会明白这是你想要添加测试的地方,文件夹应该会变成绿色(绿色表示该文件夹是测试类路径的一部分)。现在添加一个新的包,从技术上来说并不是新的,因为我们将再次使用 com.blundell.tut,通过选择 新建 | 并使用名称 com/blundell/tut

现在,在我们新的文件夹和包中创建我们的测试。我们通过选择 新建 | Java 类 并将其命名为 TemperatureConverterTests 来创建测试。你的项目现在应该看起来像这样:

温度转换器单元测试

让我们创建第一个测试,在 TemperatureConverterTests 内,按 Ctrl + Enter 弹出 生成 菜单,如下面的截图所示:

温度转换器单元测试

选择 测试方法 测试,然后选择 JUnit4 将为我们生成我们想要的测试模板方法,将其命名为 testFahrenheitToCelsius()。记住这个快捷方式,因为它在创建新测试时非常有用。一旦你生成了这个测试,你会注意到我们在 JUnit 4 导入的代码行上有编译错误。哎呀!我们忘记将 JUnit 库添加到我们核心模块的测试类路径中。打开 /core/build.gradle 文件,并添加 JUnit 依赖。你的核心 build.gradle 现在应该看起来像这样:

apply plugin: 'java''java'

dependencies {
    compile fileTree(dir: 'libs''libs', include: [''''*.jar'])

    testCompile 'junit'junit:junit:4.+''''
}

注意

注意,这里我们从 JUnit3 跳到了 JUnit4,主要区别在于我们现在可以使用注解来告诉我们的测试运行器,类中的哪些方法是测试方法。因此,从技术上讲,我们不再需要像 testFooBar() 那样以 test 开头的方法名,但为了在我们两者之间切换时保持清醒,我们还是会这样做(Android 对 JUnit4 的支持即将到来!)。

通过选择 项目同步 来进行项目同步,我们现在可以编译并准备编码。让我们开始编写测试:

@Test
public void testFahrenheitToCelsius() {
    for (double knownCelsius : conversionTable.keySet()) {
        double knownFahrenheit = conversionTable.get(knownCelsius);

        double resultCelsius =
TemperatureConverter.fahrenheitToCelsius(knownFahrenheit);

        double delta = Math.abs(resultCelsius - knownCelsius);
        String msg = knownFahrenheit + "F -> " + knownCelsius + "C"+ "but is " + resultCelsius;
        assertTrue(msg, delta < 0.0001);
     }
}

创建一个带有不同温度转换值的转换表,我们知道从其他来源驱动这个测试是一个好方法:

Map<Double, Double> conversionTable = new HashMap<Double, Double>() {
  // initialize (celsius, fahrenheit) pairs
  put(0.0, 32.0);
  put(100.0, 212.0);
  put(-1.0, 30.20);
  put(-100.0, -148.0);
  put(32.0, 89.60);
  put(-40.0, -40.0);
  put(-273.0, -459.40);
}};

要在核心模块中运行测试,我们可以右键点击项目视图中的文件,并选择 运行。正如截图也显示的那样,你可以使用快捷键 Cmd + Shift + F10

温度转换器单元测试

当这个测试运行时,我们验证它失败,并给我们留下这条轨迹:

java.lang.AssertionError: -40.0F -> -40.0C but is 0.0
 at org.junit.Assert.fail(Assert.java:88)
 at org.junit.Assert.assertTrue(Assert.java:41)
 at com.blundell.tut.TemperatureConverterTests.testFahrenheitToCelsius(TemperatureConverterTests.java:31).

注意

看看这些核心测试运行得多快!尽量将应用程序逻辑移到核心模块中,这样在进行测试驱动开发时,你可以利用这个速度。

好吧,这是我们预料之中的事情,因为我们的转换总是返回0。实现我们的转换时,我们发现我们需要一个ABSOLUTE_ZERO_F常量:

    private static final double ABSOLUTE_ZERO_F = -459.67d;

    private static final String ERROR_MESSAGE_BELOW_ZERO_FMT =       "Invalid temperature: %.2f%c below absolute zero";

    private TemperatureConverter() {
        // non-instantiable helper class
    }

    public static double fahrenheitToCelsius(double fahrenheit) {
        if (fahrenheit < ABSOLUTE_ZERO_F) {
            String msg = String.format(ERROR_MESSAGE_BELOW_ZERO_FMT,               fahrenheit, 'F''F');
            throw new InvalidTemperatureException(msg);
        }
        return ((fahrenheit - 32) / 1.8d);
    }

绝对零度是熵达到最小值的理论温度。根据热力学定律,要达到这个绝对零度的状态,系统应该与宇宙的其余部分隔离。因此,这是一个无法达到的状态。然而,按照国际协议,绝对零度定义为开尔文量表上的 0K,摄氏量表上的-273.15°C,或者华氏量表上的-459.67°F。

我们正在创建一个自定义异常InvalidTemperatureException,以指示在转换方法中提供有效温度失败。这个异常与 Android 无关,因此也可以放在我们的核心模块中。通过扩展RuntimeException来创建它:

public class InvalidTemperatureException extends RuntimeException {

  public InvalidTemperatureException(String msg) {
    super(msg);
  }

}

再次运行核心测试,我们发现testFahrenheitToCelsius测试成功了。因此,我们回到 Android 测试,运行这些测试发现testFahrenheitToCelsiusConversion测试失败了。这告诉我们,现在转换器类正确处理了转换,但 UI 处理这个转换仍然存在一些问题。

注意

不必对运行两个单独的测试类感到绝望。对你来说,选择运行哪些测试是常见的;这在进行 TDD 时部分是学习到的技能。但是,如果你愿意,可以编写自定义测试运行器来运行所有的测试。此外,使用 Gradle 运行build connectedAndroidTest将一次性运行所有测试,这建议在你认为完成了一个功能或想要提交到上游版本控制时执行。

仔细查看testFahrenheitToCelsiusConversion失败的追踪信息,可以发现有些地方在不应返回0的情况下仍然返回了0

这提醒我们,我们仍然缺少一个合适的EditNumber实现。在继续实现上述方法之前,让我们创建相应的测试来验证我们正在实现的内容是否正确。

EditNumber测试

从前一章可以确定,我们自定义视图测试的最佳基类是AndroidTestCase,因为我们需要一个模拟的Context类来创建自定义视图,但我们不需要系统基础结构。

创建EditNumber的测试,我们称之为EditNumberTests,并扩展AndroidTestCase。提醒一下,这位于应用模块下的androidTest路径中。

我们需要添加构造函数以反映我们之前识别的给定名称模式:

public EditNumberTests() {
 this("EditNumberTests");
 }

 public EditNumberTests(String name) {
 setName(name);
    }

下一步是创建测试夹具。在这种情况下,这是一个简单的EditNumber类,我们将对其进行测试:

    @Override
    protected void setUp() throws Exception {
        super.setUp();

        editNumber = new EditNumber(mContext);
        editNumber.setFocusable(true);
    }

模拟上下文是从AndroidTestCase类中受保护的字段mContext获取的(developer.android.com/reference/android/test/AndroidTestCase.html#mContext)。

setUp方法的最后,我们将editNumber设置为可聚焦的视图,这意味着它将能够获得焦点,因为它将参与许多模拟可能需要显式请求其焦点的 UI 的测试。

接下来,我们测试testClear()方法中所需clear()功能的正确实现:

@UiThreadTest
public void testClear() {
String value = "123.45";
          editNumber.setText(value);

          editNumber.clear();

          assertEquals("", editNumber.getText().toString());
} 

运行测试,我们验证它确实失败了:

junit.framework.ComparisonFailure: expected:<[]> but was:<[123.45]>
at com.blundell.tut.EditNumberTests.testClear(EditNumberTests.java:31)
at java.lang.reflect.Method.invokeNative(Native Method)
at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:191)

我们需要正确实现EditNumber.clear()

这是一个简单的案例,只需将此实现添加到EditNumber中,我们就可以满足测试:

  public void clear() {
    setText("");
  }

运行测试并继续。我们将在EditNumber中添加一个新方法。这里,我们已经有了getNumber(),我们现在添加setNumber()以便稍后使用。现在让我们完成testSetNumber()实现的编写:

           public void testSetNumber() {

        editNumber.setNumber(123.45);

        assertEquals("123.45", editNumber.getText().toString());
    }

除非我们实现了类似于以下实现的EditNumber.setNumber(),否则会失败:

    private static final String DEFAULT_FORMAT = "%."%.2f";";

    public void setNumber(double number) {
        super.setText(String.format(DEFAULT_FORMAT, number));
    }

我们使用了一个常量DEFAULT_FORMAT来保存转换数字所需的格式。这可以稍后转换为属性,也可以在字段的 XML 布局定义中指定。

同样适用于testGetNumber()getNumber()这一对:

      public void testGetNumber() {

        editNumber.setNumber(123.45);

        assertEquals(123.45, editNumber.getNumber());
    }

getNumber()方法如下所示:

    public double getNumber() {
        String number = getText().toString();
        if (TextUtils.isEmpty(number)) {
            return 0D;
        }
        return Double.valueOf(number);
    }

这些测试成功了,所以运行你的其他测试来看我们进行到哪一步;我在命令行中运行了gradlew build cAT命令来做到这一点。这运行了我们到目前为止编写的所有测试;但testFahrenheitToCelsiusConversion()失败了。我们已经有很多经过良好测试的代码,退一步,反思一下。

以下是我们 Android 测试的结果:

EditNumber 测试

以下是我们核心的 Java 测试结果:

EditNumber 测试

如果仔细分析testFahrenheitToCelsiusConversion()测试用例,你就能发现问题所在。

明白了吗?

我们的测试方法期望当焦点发生变化时自动进行转换,正如我们需求列表中指定的那样:“在一个字段中输入一个温度时,另一个字段会自动更新为转换后的值”。

记住,我们没有按钮或任何其他东西来转换温度值,所以一旦输入了值,转换应该是自动进行的。

这让我们回到了TemperatureConverterActivity类,以及它处理转换的方式。

TemperatureChangeWatcher 类

实现不断更新另一个温度值所需行为的一种方式,是在原始值发生变化时通过TextWatcher。从文档中,我们可以理解TextWatcher是附加到Editable的类型的一个对象;当文本更改时,将调用其方法(developer.android.com/reference/android/text/TextWatcher.html)。

这似乎是我们需要的。

我们将这个类实现为TemperatureConverterActivity的内部类。这样做的想法是,因为我们直接作用于 Activity 的 Views,将其作为内部类显示了这种关系,并且如果有人想要更改此 Activity 的布局,这会使关系变得清晰。如果你实现了最小的TextWatcher,你的 Activity 将如下所示:

public class TemperatureConverterActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_temperature_converter);
    }

    /**
     * Changes fields values when the text changes; applying the correlated conversion method.
     */
    static class TemperatureChangedWatcher implements TextWatcher {

        @Override
        public void beforeTextChanged(CharSequence s, int start, int count, int after) {

        }

        @Override
        public void onTextChanged(CharSequence s, int start, int before, int count) {

        }

        @Override
        public void afterTextChanged(Editable s) {
        }
    }
}

现在我们对最近创建的类进行一些添加后的代码如下:

/**
 * Changes fields values when the text changes;
 * applying the correlated conversion method.
 */
static class TemperatureChangedWatcher implements TextWatcher {

private final EditNumber sourceEditNumber;
private final EditNumber destinationEditNumber;
private final Option option;

private TemperatureChangedWatcher(Option option,
EditNumber source,
EditNumber destination) {
this.option = option;
   this.sourceEditNumber = source;
   this.destinationEditNumber = destination;
}

static TemperatureChangedWatcher newCelciusToFehrenheitWatcher(EditNumber source, EditNumber destination) {
return new TemperatureChangedWatcher(Option.C2F, source, destination);
}

static TemperatureChangedWatcher newFehrenheitToCelciusWatcher(EditNumber source, EditNumber destination) {
return new TemperatureChangedWatcher(Option.F2C, source, destination);
}

@Override
public void onTextChanged(CharSequence input, int start, int before, int count) {
if (!destinationEditNumber.hasWindowFocus()
|| destinationEditNumber.hasFocus()
|| input == null) {
       return;
}

   String str = input.toString();
   if ("".equals(str)) {
       destinationEditNumber.setText("");
          return;
}

   try {
      double temp = Double.parseDouble(str);
      double result = (option == Option.C2F)
? TemperatureConverter.celsiusToFahrenheit(temp)
: TemperatureConverter.fahrenheitToCelsius(temp);
    String resultString = String.format("%.2f", result);
    destinationEditNumber.setNumber(result);
    destinationEditNumber.setSelection(resultString.length());
   } catch (NumberFormatException ignore) {
      // WARNING this is generated whilst 
 // numbers are being entered,
 // for example just a '-' 
 // so we don''t want to show the error just yet
   } catch (Exception e) {
     sourceEditNumber.setError("ERROR: " + e.getLocalizedMessage());
   }
}

@Override
public void afterTextChanged(Editable editable) {
// not used
}

@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
// not used
}
}

我们将使用相同的TemperatureChangeWatcher实现,用于摄氏度和华氏度这两个字段;因此我们保留了作为源和目标字段以及更新它们值的操作的引用。为了指定此操作,我们引入了enum,它是纯 Java,因此可以放入核心模块中:

/**
 * C2F: celsiusToFahrenheit
 * F2C: fahrenheitToCelsius
 */
public enum Option {
    C2F, F2C
}

此操作在创建工厂方法中指定,并根据需要选择源和目标EditNumber。这样我们可以为不同的转换使用相同的观察者。

我们感兴趣的TextWatcher接口的方法是onTextChanged。只要文本发生变化,就会调用它。起初,我们避免潜在的循环,检查谁具有焦点,并在条件不满足时返回。

如果源为空,我们也应将目标字段设置为空字符串。

最后,我们尝试将调用相应转换方法得到的结果值设置到目标字段。我们根据需要标记错误,避免在转换被部分输入的数字调用时显示过早的错误。

我们需要在TemperatureConverterActivity.onCreate()中设置输入字段的监听器:

@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_temperature_converter);
  EditNumber celsiusEditNumber =
  (EditNumber) findViewById(R.id.converter_celsius_input); 
  EditNumber fahrenheitEditNumber =
  (EditNumber) findViewById(R.id.converter_fahrenheit_input);
  celsiusEditNumber
  .addTextChangedListener(
newCelciusToFehrenheitWatcher(celsiusEditNumber, fahrenheitEditNumber));

fahrenheitEditNumber
 .addTextChangedListener(
 newFehrenheitToCelciusWatcher(fahrenheitEditNumber, 
 celsiusEditNumber));
}

为了能够运行测试,我们应该编译它们。要编译,我们至少需要定义尚未定义的celsiusToFahrenheit()方法。

更多温度转换器测试

我们需要实现celsiusToFahrenheit,像往常一样,我们从测试开始。

这与其他转换方法fahrenheitToCelsius相当等效,我们可以使用在创建此测试时设计的基础架构:

@Test
    public void testCelsiusToFahrenheit() {
        for (double knownCelsius : conversionTable.keySet()) {
            double knownFahrenheit = conversionTable.get(knownCelsius);

            double resultFahrenheit = 
TemperatureConverter.celsiusToFahrenheit(knownCelsius);

            double delta = Math.abs(resultFahrenheit - knownFahrenheit);
            String msg = knownCelsius + "C -> " + knownFahrenheit + "F"
+ " but is " + resultFahrenheit;
            assertTrue(msg, delta < 0.0001);
        }
    }

我们使用转换表通过不同的转换来练习该方法,并验证误差小于预定义的增量。

然后,TemperatureConverter类中的相应转换实现如下:

    static final double ABSOLUTE_ZERO_C = -273.15d;

    public static double celsiusToFahrenheit(double celsius) {
        if (celsius < ABSOLUTE_ZERO_C) {
            String msg = String.format(
ERROR_MESSAGE_BELOW_ZERO_FMT, celsius, 'C');
            throw new InvalidTemperatureException(msg);
        }
        return (celsius * 1.8d + 32);
    }

现在,所有测试都通过了,但我们仍然没有测试所有常见条件。我的意思是,到目前为止我们只检查了正常路径。你应该检查是否正确生成了错误和异常,除了我们到目前为止创建的所有正常情况。

创建这个测试,以检查在转换中使用绝对零度以下的温度时,是否正确生成了异常:

    @Test(expected = InvalidTemperatureException.class)
    public void testExceptionForLessThanAbsoluteZeroF() {
        TemperatureConverter.fahrenheitToCelsius(ABSOLUTE_ZERO_F - 1);
    }

在这个测试中,我们递减绝对零度温度,以获得更小的值,然后尝试转换。我们在核心模块中编写了此测试,因此使用了 JUnit4,它允许我们使用注解来断言我们期望抛出异常。如果你想在 JUnit3 中做同样的事情,你不得不使用 try catch 块,并且如果代码没有进入 catch 块,则测试失败:

    @Test(expected = InvalidTemperatureException.class)
    public void testExceptionForLessThanAbsoluteZeroC() {
        TemperatureConverter.celsiusToFahrenheit(ABSOLUTE_ZERO_C - 1);
    }

同样地,我们测试当尝试转换涉及低于绝对零的摄氏度温度时,是否抛出了异常。

输入过滤器测试

另一个错误要求可能是:我们希望过滤掉转换工具接收到的输入,这样不会有垃圾到达这个点。

EditNumber类已经过滤了有效输入,否则将生成异常。我们可以通过在TemperatureConverterActivityTests中创建一个新测试来验证这个条件。我们选择这个类,因为我们是像真实用户一样向输入字段发送键:

public void testInputFilter() throws Throwable {
        runTestOnUiThread(new Runnable() {
            @Override
            public void run() {
                celsiusInput.requestFocus();
            }
        });
        getInstrumentation().waitForIdleSync();

        sendKeys("MINUS 1 PERIOD 2 PERIOD 3 PERIOD 4");
        double number = celsiusInput.getNumber();

        String msg = "-1.2.3.4 should be filtered to -1.234 " 
          + "but is " + number;
        assertEquals(msg, -1.234d, number);
    }

这个测试使用之前回顾的模式,请求将焦点移到摄氏度字段。这允许我们在 UI 线程中运行测试的一部分,并向视图发送键输入。发送的键是一个包含多个点的无效序列,这对于一个格式良好的十进制数是不被接受的。预期当过滤器启用时,这个序列将被过滤,只有有效字符到达字段。断言celsiusInput.getNumber()返回的值,在过滤后是我们所期望的。

要实现这个过滤器,我们需要向EditNumber添加InputFilter。因为应该将其添加到所有构造函数中,所以我们创建了一个额外的init()方法,从每个构造函数中调用它。为了实现我们的目标,我们使用了DigitsKeyListener的实例,接受数字、符号和十进制点如下:

   public EditNumber(Context context) {
        super(context);
        init();
   }
   public EditNumber(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
   }

   public EditNumber(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        init();
   }

   private void init() {
    // DigistKeyListener.getInstance(true, true)
    // returns an instance that accepts digits, sign and decimal point
    InputFilter[] filters =
      new InputFilter[]{DigitsKeyListener.getInstance(true, true)};
       setFilters(filters);
   }

这个init方法从每个构造函数中调用,这样如果这个视图是程序化使用或从 XML 中使用,我们仍然有我们的过滤器。

重新运行测试,我们可以验证所有测试都已通过,现在一切又都变绿了。

查看我们的最终应用程序

干得好!现在我们有了满足所有要求的应用程序。

在以下屏幕截图中,我们展示了这些要求中的一个,即检测尝试转换低于摄氏度绝对零温度(-1000.00C)的温度的企图:

查看我们的最终应用程序

UI 遵循提供的指南;可以通过在相应单位字段中输入温度来进行转换。

回顾一下,这是我们已实现的需求列表:

  • 应用程序可以在摄氏度和华氏度之间转换温度

  • 用户界面提供了两个输入温度的字段,一个用于摄氏度,另一个用于华氏度

  • 当在一个字段中输入一个温度时,另一个字段会自动更新为转换后的温度

  • 如果有错误,应该向用户显示,可能使用相同的字段

  • 用户界面中应保留一些空间用于屏幕键盘,以便在输入多个转换时简化应用程序的操作

  • 输入字段应从空开始

  • 输入的值是小数点后两位的十进制值

  • 数字右对齐

更重要的是,我们现在可以确信应用程序不仅满足了需求,而且没有明显的问题或错误。我们通过分析测试结果,一步步解决问题,确保任何发现的错误一旦经过测试和修复,就不会再次出现。

总结

我们介绍了测试驱动开发,解释了其概念,并在一个潜在的实际问题中逐步应用它们。

我们从一个简洁的需求列表开始,描述了温度转换应用程序。

我们按照测试代码的顺序实现了每一个测试,以此满足需求。通过这种方式,我们实现了应用程序的行为及其展示,进行测试以确保我们设计的 UI 遵循规范。

由于有了测试,我们分析了运行它们的不同可能性。在上一章的基础上,现在我们的持续集成机器可以运行测试,以确保团队的任何更改仍然会产生一个经过良好测试的应用程序。

下一章将介绍行为驱动开发,并继续我们的目标:无错误的、经过良好测试的代码,这次的重点是行为和团队间的共识,即需求在整个团队中的意义。

第七章:行为驱动开发

行为驱动开发BDD)可以理解为测试驱动开发TDD)和验收测试的发展和融合。这两种技术在前面的章节中都有讨论,所以在继续之前,您可能想回顾一下第一章,开始测试,以及第六章,实践测试驱动开发

行为驱动开发(BDD)引入了一些新概念,例如使用通用词汇描述测试,以及在软件开发项目中包含业务参与者,如产品所有者或业务分析师。

我们之前已经接触过测试驱动开发,我们专注于将低级需求转换为可以推动我们开发过程的测试。行为驱动开发迫使我们关注更高级别的需求,通过使用特定的词汇,我们可以以可以进一步分析或评估的方式表达这些需求。有些人认为 BDD 只是正确执行的 TDD 的哲学。

我们将通过实例来探索这些概念,以便您可以得出自己的结论。

给定、当、然后

Given/When/Then 这些词汇是跨越业务和技术之间鸿沟的通用语言,正如在 behaviour-driven.org 所描述的,它们也可以被称为行为驱动开发无处不在的语言。该框架基于以下三个核心原则,我们在这里逐字重现:

  • 业务和技术应该以相同的方式指代同一个系统。

  • 任何系统都应该对业务有一个明确且可验证的价值。

  • 前期的分析、设计和规划,都会有一个递减的回报。

行为驱动开发依赖于使用这个特定的词汇。此外,预先确定的需求表达格式允许工具解释和执行它们:

  • 给定:这是描述在外部刺激接收之前的初始状态。

  • :这是为了描述用户执行的关键动作。

  • 然后:这是为了分析行动的结果。为了可观察,所执行的动作应该有一种结果。

FitNesse

FitNesse 是一个软件开发协作工具,可用于管理 BDD 场景。严格来说,FitNesse 是一组工具,如下所述:

  • 作为软件测试工具,FitNesse 是一个轻量级、开源的框架,允许团队协作。

  • 它还是一个 Wiki,您可以轻松创建、编辑页面并共享信息。

  • 一个网络服务器,因此它不需要额外的配置或管理权限来设置或配置。

www.fitnesse.org下载 FitNesse 发行版。该发行版是一个 JAR 文件,在首次运行时自动安装。在这些示例中,我们使用了 FitNesse 独立版 20140901,但较新版本也应该可以工作。

从命令行运行 FitNesse

默认情况下,FitNesse 运行时监听 80 端口,因此要在非特权模式下运行,你应该在命令行上更改端口。在此示例中,我们使用8900

$ java -jar fitnesse.jar -p 8900

这是我们运行命令时获得的输出:

Bootstrapping FitNesse, the fully integrated standalone wiki and acceptance testing framework.
root page: fitnesse.wiki.fs.FileSystemPage at ./FitNesseRoot#latest
logger: none
authenticator: fitnesse.authentication.PromiscuousAuthenticator
page factory: fitnesse.html.template.PageFactory
page theme: fitnesse_straight
Starting FitNesse on port: 8900

一旦运行,你可以将浏览器直接指向本地 FitNesse 服务器主页(http://localhost:8900/FrontPage),你将看到以下内容:

从命令行运行 FitNesse

创建 TemperatureConverterTests 子维基

一旦 FitNesse 启动并运行,我们可以通过创建一个子维基来组织我们的测试。你可能已经熟悉维基概念。如果不是,维基是一个允许用户编辑和创建网页的网站。这种编辑过程是在浏览器内完成的,并使用一种大大简化过程的标记语言。

注意

你可以在可能是最著名的维基en.wikipedia.org/wiki/Wiki上了解更多关于维基的信息。

尽管这种子维基组织不是强制性的,但强烈建议这样做,特别是如果你打算在多个项目中使用 FitNesse 进行验收测试。

最简化的过程之一是超链接创建,只需使用驼峰命名维基词即可完成;即以大写字母开头并至少包含一个大写字母的单词。这个维基词将被转换成同名页面的超链接。

要创建TemperatureConverterTests子维基,我们只需点击 FitNesse 标志右侧的编辑按钮,编辑主页,添加以下内容:

| '''My Tests''' |
| TemperatureConverterTests | ''Temperature Converter Tests'' |

这通过使用"|"标记作为第一个字符和分隔列,向页面添加了一个新表格。

我们还添加了一列带有描述性注释的测试。这个注释通过使用两个单引号('')包围变成斜体。这段文本将创建一个名为TemperatureConverterTests的维基链接。

按下保存,页面将被修改。

一旦页面显示,我们可以验证TemperatureConverterTests后面是否跟着一个[?](问号),因为页面尚未创建,在我们点击它时将会创建。现在点击它,这将直接把我们带入新页面的编辑模式。我们可以添加一些注释以清楚地标识这个新创建的子维基主页:

!contents -R2 -g -p -f -h

This is the !-TemperatureConverterTests SubWiki-!.

这里,文本TemperatureConverterTests SubWiki通过使用!--!进行转义,以防止它被转换成另一个页面链接。

再次按下保存

向子维基添加子页面

现在,我们通过使用页面标题旁边的[添加]链接来添加一个新子页面。

创建子页面有不同的选项,我们可以选择:

  • 静态:这是一个普通的维基页面

  • 套件:这是一个包含其他测试构成套件的页面

  • 测试:这是一个包含测试的页面

我们将选择添加一个套件页面,并将其命名为TemperatureConverterTestSuite,如下截图所示:

向子维基添加子页面

点击保存后,这个页面就会被创建,并且自动作为链接添加到TemperatureConverterTests子维基中。

让我们跟随这个新创建的链接到达测试套件页面。

到这里后,使用[添加] | [测试页面]再添加一个子页面。这添加了一个测试页面,我们将它命名为TemperatureConverterCelsiusToFahrenheitFixture,因为这将会包含我们的夹具。这里的命名只是按照约定来组织我们的维基。

点击保存来完成操作。

添加验收测试夹具

直到现在,我们一直在创建维基页面。这并没有什么令人兴奋的!现在,我们将直接把验收测试夹具添加到页面中。确保你已经导航到新添加的页面TemperatureConverterCelsiusToFahrenheitFixture,点击编辑,并将<test page>替换为以下内容:

!contents

!|TemperatureConverterCelsiusToFahrenheitFixture          |
|celsius|fahrenheit?                                      |
|0.0    |~= 32                                            |
|100.0  |212.0                                            |
|-1.0   |30.2                                             |
|-100.0 |-148.0                                           |
|32.0   |89.6                                             |
|-40.0  |-40.0                                            |
|-273.0 |~= -459.4                                        |
|-273   |~= -459.4                                        |
|-273   |~= -459                                          |
|-273   |~= -459.40000000000003                           |
|-273   |-459.40000000000003                              |
|-273   |-459.41 < _ < -459.40                            |
|-274.0 |Invalid temperature: -274.00C below absolute zero|

这个表格为我们的测试特性定义了几个项目:

  • TemperatureConverterCelsiusToFahrenheitFixture:这是表格标题和测试夹具名称。

  • celsius:这是我们提供给测试作为输入值的列名。

  • fahrenheit?:这是我们期望作为转换结果的值的列名。问号表示这是一个结果值。

  • ~=:这表示结果大约是这个值。

  • < _ <:这表示预期值在这个范围内。

  • Invalid temperature:低于绝对零度-274.00 摄氏度是失败的转换预期的值。

通过点击保存来保存这些内容。

添加支持测试类

如果我们按下测试按钮,这个按钮位于 FitNesse 标志下方(详细情况见下截图),我们将收到一个错误。在某种程度上这是预期的,因为我们还没有创建支持测试夹具。测试夹具将是一个非常简单的类,调用TemperatureConverter类的方法。

FitNesse 支持以下两种不同的测试系统:

  • fit:这是两种方法中较旧的一种,使用 HTML,在调用夹具之前解析

  • slim:这是较新的方法;所有的表格处理都在 FitNesse 内的 slim 运行器中完成

关于这些测试系统的更多信息可以在以下链接找到:fitnesse.org/FitNesse.UserGuide.WritingAcceptanceTests.TestSystems

在这个例子中,我们通过在同一个页面内设置变量TEST_SYSTEM来使用 slim:

!define TEST_SYSTEM {slim}

现在,我们将要创建 slim 测试夹具。请记住,测试夹具是一个简单的类,它允许我们从 FitNesse 测试套件运行已经写好的温度转换代码。我们在现有项目TemperatureConvertercore模块内创建一个名为com.blundell.tut.fitnesse.fixture的新包。我们将在该包内创建夹具。

接下来,我们必须创建一个TemperatureConverterCelsiusToFahrenheitFixture类,这是我们在验收测试表中定义的:

public class TemperatureConverterCelsiusToFahrenheitFixture {
    private double celsius;
    public void setCelsius(double celsius) {
        this.celsius = celsius;
    }
    public String fahrenheit() throws Exception {
        try {
            double fahrenheit = TemperatureConverter
.celsiusToFahrenheit(celsius);
            return String.valueOf(fahrenheit);
        } catch (RuntimeException e) {
            return e.getLocalizedMessage();
        }
    }
}

作为提醒,完成后的样子应类似如下:

添加支持测试类

这个夹具应该委托给我们的真实代码,而不应该自己执行任何操作。我们决定从fahrenheit()返回String,这样我们就可以在同一个方法中返回Exception消息。

在此阶段,运行核心模块测试以确保你没有破坏任何东西(同时编译新创建的类以备后用)。

在 FitNesse 测试页面上,我们还应该定义测试所在的包。这允许在 FitNesse 中编写的测试能找到我们在 Android 项目中编写的测试夹具。在我们仍在编辑的同一页面中,添加:

|import|
|com.blundell.tut.fitnesse.fixture|

现在,我们将 Android 项目类文件添加到 FitNesse 测试的路径中。这允许 FitNesse 使用我们新编写的测试夹具和我们的TemperatureConverter;即测试中的代码。

!path /Users/blundell/AndroidApplicationTestingGuide/core/build/classes/test
!path /Users/blundell/AndroidApplicationTestingGuide/core/build/classes/main

注意

这应该适应你的系统路径。这里的重点是/core/之后的路径。这指向的是你的测试应用程序下编译的*.class文件的存放位置。注意,我们需要分别添加测试源和项目源。

完成这些步骤后,我们可以点击测试按钮运行测试,以下截图将反映结果:

添加支持测试类

我们可以通过它们的绿色来判断每个成功的测试,以及通过红色来判断失败的测试。在这个例子中,我们没有失败,所以一切都是绿色的。注意,它还显示了我们所声明的classpathTEST_SYSTEM变量。

FitNesse 还有一个有用的功能,测试历史。所有测试运行和一定数量的结果会被保存一段时间,这样你可以在以后查看结果并进行比较,从而分析你的更改的演变。

你可以通过点击顶部菜单中工具下的列表底部的测试 历史来访问此功能。

在以下屏幕截图中,我们可以看到最近 3 次测试运行的结果,其中 2 次失败,1 次成功。同时,通过点击+(加号)或-(减号)标志,你可以展开或折叠视图以显示或隐藏有关测试运行的详细信息:

添加支持测试类

GivWenZen

GivWenZen 是一个框架,基于 FitNesse 和 Slim,允许用户利用行为驱动开发技术的表达方式,使用 给定-当-那么 词汇来描述测试。这些测试描述也使用 FitNesse 维基功能创建,即以纯文本表格形式在维基页面上表达测试。

这个想法简单直接,与我们之前使用 FitNesse 的工作一脉相承,但这次我们不是通过提供一个值表来编写验收测试,而是将使用行为驱动开发的三个神奇词汇 给定-当-那么 来描述我们的场景。

首先,让我们安装带有 GivWenZen 的 FitNesse。从其下载列表页面 goo.gl/o3Hlpo 下载完整发行版。解压后,GivWenZen JAR 的启动方式与 FitNesse 完全相同(因为它只是在顶部的一个修改):

$ java -jar /lib/fitnesse.jar -p 8900

更多阅读、综合说明和示例可以在维基上找到,地址是 github.com/weswilliams/GivWenZen/wiki。我们在这些示例中使用了 GivWenZen 1.0.3,但新版本应该也能工作。

GivWenZen 完整发行版包括了所有需要的依赖,包括 FitNesse,因此如果你之前运行过 FitNesse 示例,最好停止它,因为你必须使用不同的 JAR 文件来运行 GivWenZen。

启动后,将浏览器指向主页,你会看到一个熟悉的 FitNesse 首页,或者如果你像之前那样配置了端口,可以查看 http://localhost:8900/GivWenZenTests 的一些测试。你可以花些时间探索其中包含的示例。

创建测试场景

让我们为我们的温度转换器创建一个简单的场景,这样我们可以更好地理解给定-当-那么。

用纯英文句子表达,我们的场景将是:

给定 我正在使用温度转换器, 我在摄氏度字段输入 100,那么 我在华氏度字段得到 212。

通过将这段内容添加到维基页面,其值将直接转换成 GivWenZen 场景:

-|script|
|given|I'm using the !-TemperatureConverter-!|
|when |I enter 100 into the Celsius field|
|then |I obtain 212 in the Fahrenheit field|

翻译非常直接。表格标题必须是 script,在这种情况下,前面有一个破折号 (-) 来隐藏它。然后每个 给定-当-那么 场景都放在一列中,谓词放在另一列。

在运行这个脚本之前,当整个页面被执行时,我们需要通过运行另一个脚本来初始化 GivWenZen。你通过将其添加到维基页面的顶部来实现这一点。

|import|
|org.givwenzen|
-|script|
|start|giv wen zen for slim|

我们还需要初始化类路径并为所有脚本添加相应的导入。通常,这在一个 SetUp 页面中完成,该页面在运行每个测试脚本之前执行(就像 JUnit 测试中的 setUp() 方法),但为了简单起见,我们将初始化添加到这个相同的页面:

!define TEST_SYSTEM {slim}

!path ./target/classes
!path ./target/examples
!path ./lib/clover-2.6.1.jar
!path ./lib/commons-logging.jar
!path ./lib/commons-vfs-1.0.jar
!path ./lib/dom4j-1.6.1.jar
!path ./lib/fitnesse.jar
!path ./lib/guava-18.0.jar
!path ./lib/javassist.jar
!path ./lib/log4j-1.2.9.jar
!path ./lib/slf4j-simple-1.5.6.jar
!path ./lib/slf4j-api-1.5.6.jar
!path ./givwenzen-20150106.jar
!path /Users/blundell/AndroidApplicationTestingGuide/core/build/classes/test
!path /Users/blundell/AndroidApplicationTestingGuide/core/build/classes/main

你需要更改最后两个路径变量以匹配你的 TemperatureConverter 项目;稍后你会明白为什么需要这些。

如果此时你点击测试按钮运行测试,你会收到以下信息:

__EXCEPTION__:org.givwenzen.DomainStepNotFoundException:

在表格的第二列中,我们的测试大纲保存了领域步骤,因此,出现了DomainStepNotFound异常。你需要一个步骤类,其中有一个带注解的方法与这个模式匹配:"我正在使用 TemperatureConverter"。

这种错误的典型原因如下:

  • StepClass丢失了:这是我们的错误

  • StepClass缺少了@DomainSteps注解

  • StepMethod缺少了@DomainStep注解

  • StepMethod注解中有一个正则表达式,它与你当前编写的测试步骤不匹配

一个示例步骤类可能是这样的:

 @DomainSteps
 public class StepClass {
 @DomainStep("I'm using the TemperatureConverter")
 public void domainStep() {
 // TODO implement step by invoking your own code
 }
}

步骤类应该放在bdd.steps包或其子包中,或者你也可以定义自己的自定义包。这个包将位于我们应用程序的/core/test/模块中。如果你注意到了,在setUp页面上方,我们将我们的应用程序添加到了路径上,因此在我们构建项目后,可以找到这个 DomainStep。

为了在我们的项目中使用@DomainStep(s)注解,我们需要在项目的测试路径上放置 GivWenZen JAR。这可以通过将givwenzen.jar文件复制到/core/libs中来实现,或者更好的方式是在build.gradle中将其作为远程依赖添加:

testCompile 'com.github.bernerbits:givwenzen:1.0.6.1'

提示

你会注意到,这个testCompile依赖并不是官方的 GivWenZen 发布版本,而是有人复制了代码并上传了。目前这对我们来说没有影响,因为我们只使用了两个注解类(我知道在这个版本中它们是相同的),但值得记住,如果它作为远程依赖发布,我们应该恢复到原始的 GivWenZen 库。

根据小轮廓示例,在我们特定的案例中,StepClass的实现将是:

package bdd.steps.tc;

import com.blundell.tut.TemperatureConverter;

import org.givwenzen.annotations.DomainStep;
import org.givwenzen.annotations.DomainSteps;

@DomainSteps
public class TemperatureConverterSteps {

    private static final String CELSIUS = "Celsius";
    private static final String FAHRENHEIT = "Fahrenheit";
    private static final String UNIT_NAME 
= "(" + CELSIUS + "|" + FAHRENHEIT + ")";
    private static final String ANY_TEMPERATURE 
= "([-+]?\\d+(?:\\.\\d+)?)";

    private double inputTemperature = Double.NaN;

    @DomainStep("I(?: a|')m using the TemperatureConverter")
    public void createTemperatureConverter() {
        // do nothing
    }

    @DomainStep("I enter " + ANY_TEMPERATURE 
 + " into the " + UNIT_NAME + " field")
    public void setField(double inputTemperature, String unitName) {
        this.inputTemperature = inputTemperature;
    }

    @DomainStep("I obtain " + ANY_TEMPERATURE 
  + " in the " + UNIT_NAME + " field")
    public boolean verifyConversion(double expectedTemperature, String unitName) {
        double outputTemperature = convertInputInto(unitName);
        return Math.abs(outputTemperature - expectedTemperature) < 0.01D;
    }

    private double convertInputInto(String unitName) {
        double convertedInputTemperature;
        if (CELSIUS.equals(unitName)) {
            convertedInputTemperature = getCelsius();
        } else if (FAHRENHEIT.equals(unitName)) {
            convertedInputTemperature = getFahrenheit();
        } else {
            throw new RuntimeException("Unknown conversion unit" + unitName);
        }
        return convertedInputTemperature;
    }

    private double getCelsius() {
        return TemperatureConverter.fahrenheitToCelsius(inputTemperature);
    }

    private double getFahrenheit() {
        return TemperatureConverter.celsiusToFahrenheit(inputTemperature);
    }
}

在这个例子中,我们使用了bdd.steps的子包,因为默认情况下,GivWenZen 会在这个包层次结构中搜索步骤的实现。否则,需要额外的配置。

实现步骤的类应该用@DomainSteps注解,每个步骤的方法用@DomainStep注解。每个步骤方法注解接收一个字符串正则表达式作为参数。GivWenZen 使用这个正则表达式来匹配步骤。

例如,在我们的场景中,我们定义了这一步骤:

I enter 100 into the Celsius field

我们的注解如下:

@DomainStep("I enter " + ANY_TEMPERATURE 
  + " into the " + UNIT_NAME + " field")

这将匹配,并且由ANY_TEMPERATUREUNIT_NAME定义的正则表达式组值将被获取,并提供给方法的参数valueunitName

public void setField(double inputTemperature, String unitName) 

请记住,在上一章中我建议复习正则表达式,因为它们可能很有用。好吧,这可能是它们极其有用的地方之一。它允许灵活使用英语。在这里使用了 I(?: a|'m) 来允许 I amI'm。在 ANY_TEMPERATURE, 中,我们匹配每个可能的温度值,包括可选的符号和小数点。因此 UNIT_NAME 匹配单位名称;即摄氏度或华氏度。

这些正则表达式用于构建 @DomainStep 注解参数。这些正则表达式中由 () 括号分隔的组被转换为方法参数。这就是 setField() 获取其参数的方式。

然后,我们有一个 verifyConversion() 方法,根据实际转换与预期转换是否在两位小数的差异内匹配,返回 true 或 false。

最后,我们有一些实际调用 TemperatureConverter 类中的转换方法的方法。

再次运行测试后,所有测试都通过了。我们可以通过分析输出消息来确认这一点:

Assertions: 1 right, 0 wrong, 0 ignored, 0 exceptions.

我们不仅应该为正常情况创建场景,还应该涵盖异常条件。比如,用纯文本来说,我们的场景是这样的:

注意

假设我正在使用温度转换器,当我将 -274 输入到摄氏度字段中时,我会得到一个 无效温度:-274.00C 低于绝对零度 的异常。

它可以转换成如下所示的 GivWenZen 表:

-|script|
|given|I am using the !-TemperatureConverter-!             |
|when |I enter -274 into the Celsius field                 |
|then |I obtain 'Invalid temperature: -274.00C below absolute zero' exception|

通过添加一个单一的支持步骤方法,我们将能够运行它。步骤方法可以这样实现:

    @DomainStep("I obtain '(Invalid temperature: " + ANY_TEMPERATURE + " C|F below absolute zero)' exception")
    public boolean verifyException(String message, String value, String unit) {
        try {
          if ( "C".equals(unit) ) {
            getFahrenheit();
          } else {
            getCelsius();
          }
        } catch (RuntimeException ex) {
          return ex.getMessage().contains(message);
        }
        return false;
      }

此方法从正则表达式中获取异常消息、温度值和单位。然后将其与实际的异常消息进行比较,以验证是否匹配。

注意

当你将 Java 代码添加到 StepClass 注解中时,不要忘记你需要重新编译该类,以便 FitNesse 可以使用新代码。这样做的一种方式是从 IDE 中运行你的 Java 测试,强制重新编译。

此外,我们可以创建其他场景,在这种情况下,将由现有的步骤方法支持。这些场景可能是:

-|script|
|given |I'm using the !-TemperatureConverter-!   |
|when  |I enter -100 into the Celsius field      |
|then  |I obtain -148 in the Fahrenheit field    |

-|script|
|given |I'm using the !-TemperatureConverter-!   |
|when  |I enter -100 into the Fahrenheit field   |
|then  |I obtain -73.33 in the Celsius field     |

-|script|
|given|I'm using the !-TemperatureConverter-!          |
|when |I enter -460 into the Fahrenheit field          |
|then |I obtain 'Invalid temperature: -460.00F below absolute zero' exception|

因为 GivWenZen 基于 FitNesse,我们可以自由地结合这两种方法,并将之前会话中的测试包含在同一个套件中。这样做,我们可以从套件页面运行整个套件,获得以下总体结果:

创建测试场景

总结

在本章中,我们发现了行为驱动开发作为测试驱动开发(我们在前几章中检查过)的演变。

我们讨论了行为驱动开发背后的推动力。我们分析了作为基础的概念,探索了 Given-When-Then 词汇表的想法,并介绍了 FitNesse 和 Slim 作为部署测试的有用工具。

我们介绍了 GivWenZen,这是一个基于 FitNesse 的工具,它使我们能够创建近乎英文的、散文式场景,并对它们进行测试。

我们将这些技术和工具引入到了我们的示例 Android 项目中。然而,我们的测试对象仍然局限于可以在 JVM 下测试的,避免使用特定于 Android 的类和用户界面。我们将在第九章,替代测试策略中探索一些方法来克服这一限制。

下一章将讨论测试的另一面,专注于性能和剖析,这在我们应用程序按预期运行并符合我们的测试规范后,是一个自然而然的步骤。

第八章:测试和性能分析

在前面的章节中,我们研究和开发了针对 Android 应用程序的测试。这些测试让我们能够根据规范评估合规性,并通过二进制的判断确定软件是否根据这些规则正确或错误地行为。如果所有测试用例通过,意味着我们的软件表现如预期。如果其中一个测试用例失败,则软件需要修复。

在许多其他情况下,主要在我们验证软件符合所有这些规范后,我们希望向前迈进,了解这些标准是如何满足的。同时,我们还想了解系统在不同情况下的表现,以分析其他属性,如可用性、速度、响应时间和可靠性。

根据 Android 开发者指南(developer.android.com/),在设计应用程序时以下是最佳实践:

  • 性能设计

  • 响应性设计

  • 无缝设计

遵循这些最佳实践并从一开始的设计中考虑性能和响应性至关重要。由于我们的应用程序将在计算能力有限的 Android 设备上运行,因此在构建应用程序(至少是部分构建)后确定优化目标,并应用性能测试(我们将在后面讨论)可以为我们带来更大的收益。

多年前唐纳德·克努特普及了这一观点:

"过早优化是万恶之源"。

基于猜测、直觉甚至迷信的优化,往往会在短期内影响设计,并在长期内影响可读性和可维护性。相反,微优化基于识别需要优化的瓶颈或热点,应用更改,然后再次进行基准测试以评估优化的改进。因此,我们在这里关注的是测量现有性能和优化替代方案。

本章将介绍一系列与基准测试和性能分析相关的概念,如下:

  • 传统的日志语句方法

  • 创建 Android 性能测试

  • 使用性能分析工具

  • 使用 Caliper 进行微基准测试

旧日志方法

有时,这对于现实生活场景来说过于简单,但我不想说它在某些情况下可能没有帮助,主要是因为其实施只需要几分钟,你只需要logcat文本输出就可以分析案例。在希望自动化流程或应用持续集成的场景中,这很方便,如前几章所述。

这种方法包括对方法(或其一部分)进行计时,在它前后各进行一次时间测量,并在最后记录差值:

private static final boolean BENCHMARK_TEMPERATURE_CONVERSION = true;

@Override
public void onTextChanged(CharSequence input, int start, int before, int count) {
if (!destinationEditNumber.hasWindowFocus() 
  || destinationEditNumber.hasFocus() || input == null) {
     return;
}

String str = input.toString();
if ("".equals(str)) {
    destinationEditNumber.setText("");
    return;
}

long t0;
if (BENCHMARK_TEMPERATURE_CONVERSION) {
 t0 = System.currentTimeMillis();
}

try {
    double temp = Double.parseDouble(str);
    double result = (option == Option.C2F)
         ? TemperatureConverter.celsiusToFahrenheit(temp)
         : TemperatureConverter.fahrenheitToCelsius(temp);
    String resultString = String.format("%.2f", result);
    destinationEditNumber.setNumber(result);
    destinationEditNumber.setSelection(resultString.length());
} catch (NumberFormatException ignore) {
    // WARNING this is generated whilst numbers are being entered,
    // for example just a '-' 
    // so we don't want to show the error just yet
} catch (Exception e) {
    sourceEditNumber.setError("ERROR: " + e.getLocalizedMessage());
}

if (BENCHMARK_TEMPERATURE_CONVERSION) {
 long t = System.currentTimeMillis() - t0;
 Log.v(TAG, "TemperatureConversion took " + t 
 + " ms to complete.");
 }
}

这非常直观。我们记录时间差。为此,我们使用Log.v()方法,并在运行应用程序时通过 logcat 查看输出。你可以通过设置你在外面定义的BENCHMARK_TEMPERATURE_CONVERSION常量为truefalse来控制此基准测试的执行。

当我们设置BENCHMARK_TEMPERATURE_CONVERSION常量为true启动活动时,在 logcat 中,每次转换发生时,我们都会收到这样的消息:

TemperatureConversion took 5 ms to complete.
TemperatureConversion took 1 ms to complete.
TemperatureConversion took 5 ms to complete.

定时记录器

现在,比这更好的是android.util.TimingLogger安卓类。TimingLogger对象可以帮助你计时方法调用,而无需自己维护这些时间变量。它也比System.currentTimeMillis()有更高的准确度:

private static final String TAG = "TemperatureTag";
@Override
public void onTextChanged(CharSequence input, int start, int before, int count) {
if (!destinationEditNumber.hasWindowFocus() 
|| destinationEditNumber.hasFocus() || input == null) {
           return;
        }

       String str = input.toString();
       if ("".equals(str)) {
         destinationEditNumber.setText("");
             return;
        }

 TimingLogger timings = new TimingLogger(TAG, "onTextChanged");
 timings.addSplit("starting conversion");

   try {
         double temp = Double.parseDouble(str);
  double result = (option == Option.C2F)
      ? TemperatureConverter.celsiusToFahrenheit(temp)
      : TemperatureConverter.fahrenheitToCelsius(temp);
  String resultString = String.format("%.2f", result);
         destinationEditNumber.setNumber(result);
         destinationEditNumber.setSelection(resultString.length());
} catch (NumberFormatException ignore) {
// WARNING this is generated whilst numbers are being entered,
       // for example just a '-' 
// so we don't want to show the error just yet
} catch (Exception e) {
sourceEditNumber.setError("ERROR: " + e.getLocalizedMessage());
}
timings.addSplit("finish conversion");
 timings.dumpToLog();
}

如果你现在启动应用程序,你会注意到 logcat 中没有输出。这是因为TimingLogger需要你显式打开你定义的标签的日志记录。否则,方法调用将什么都不做。从终端运行以下命令:

adb shell setprop log.tag.TemperatureTag VERBOSE

提示

您可以使用getprop命令检查您的日志标签设置在什么级别:

adb shell getprop log.tag.TemperatureTag

你可以使用这个命令列出设备中的所有其他属性:

adb shell getprop

现在,当我们启动应用程序时,每次转换完成,我们都会收到这样的消息:

onTextChanged: begin
onTextChanged:      0 ms, starting conversion
onTextChanged:      2 ms, finish conversion
onTextChanged: end, 2 ms

你应该考虑的是,这些启用基准测试的常量不应该在生产版本中启用,就像使用其他常见常量,如DEBUGLOGD一样。为了避免错误,你应该在用于自动化构建的构建过程中集成这些常量值的验证,例如 Gradle。此外,我个人会在构建发布到生产之前,删除所有基准测试或验证日志——不是注释掉,而是删除。记住,你总是可以在你的版本控制系统、历史记录或分支中找到它。

这样记录代码执行速度很简单,但对于更复杂的性能问题,你可能想要使用更详细(尽管更复杂)的技术。

安卓 SDK 的性能测试

如果之前添加日志声明的方法不适合你,还有其他方法可以从我们的应用程序获取性能测试结果。这种方法被称为分析。

当运行检测代码(就像我们的安卓检测测试用例)时,没有标准的方法从安卓应用程序获取性能测试结果,因为安卓测试使用的类在安卓 SDK 中是隐藏的,仅对系统应用程序可用,即作为主构建或系统映像的一部分构建的应用程序。这个策略对我们不可用,因此我们不会朝这个方向深入挖掘。相反,我们将关注其他可用的选择。

启动性能测试

这些测试基于我们刚才讨论的方法,Android 使用它们来测试系统应用程序。这个想法是扩展android.app.Instrumentation以提供性能快照,自动创建一个框架,我们甚至可以扩展它以满足其他需求。让我们通过一个简单的例子来更好地理解这意味着什么。

创建 LaunchPerformanceBase 检测

我们的第一步是扩展Instrumentation以提供我们需要的功能。我们使用了一个名为com.blundell.tut.launchperf的新包来组织我们的测试:

public class LaunchPerformanceBase extends Instrumentation {

    private static final String TAG = "LaunchPerformanceBase";

    protected Bundle results;
    protected Intent intent;

    public LaunchPerformanceBase() {
        this.results = new Bundle();
        this.intent = new Intent(Intent.ACTION_MAIN);
        this.intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        setAutomaticPerformanceSnapshots();
    }

    /**
     * Launches intent {@link #intent}, 
  * and waits for idle before returning.
     */
    protected void launchApp() {
        startActivitySync(intent);
        waitForIdleSync();
    }

    @Override
    public void finish(int resultCode, Bundle results) {
        Log.v(TAG, "Test results = " + results);
        super.finish(resultCode, results);
    }
}

我们在这里扩展了Instrumentation。构造函数初始化了此类中的两个字段:resultsintent。最后,我们调用了setAutomaticPerformanceSnapshots()方法,这是创建此性能测试的关键。

launchApp()方法负责启动所需的 Activity 并在返回前等待。

finish()方法记录收到的结果,然后调用 Instrumentation 的finish()

创建 TemperatureConverterActivityLaunchPerformance 类

这个类设置了 Intent 以调用TemperatureConverterActivity,并提供LaunchPerformanceBase类提供的架构以测试启动我们 Activity 的性能:

public class TemperatureConverterActivityLaunchPerformance 
extends LaunchPerformanceBase {

    @Override
    public void onCreate(Bundle arguments) {
      super.onCreate(arguments);
      String className = "com.blundell.tut.TemperatureConverterActivity";
      intent.setClassName(BuildConfig.APPLICATION_ID, className);
      start();
    }

    @Override
    public void onStart() {
      super.onStart();
      launchApp();
      finish(Activity.RESULT_OK, results);
    }
}

在这里,onCreate()按照 Android 生命周期调用super.onCreate()。然后设置了 Intent,指定了类名和包名。然后调用Instrumentation的一个方法start()。它创建并启动了一个新的线程以运行检测。这个新线程将调用onStart(),你可以在其中实现检测功能。

然后onStart()的实现遵循,调用launchApp()finish()

运行测试

app/build.gradle:
defaultConfig {
        // other code

        testInstrumentationRunner "com.blundell.tut.launchperf.TemperatureConverterActivityLaunchPerformance"
    }

一切准备就绪后,我们就可以开始运行测试了。

首先,安装包含这些更改的 APK。然后,正如我们在前面的章节中回顾的那样,我们有多种运行测试的选择。在这种情况下,我们使用命令行,因为这是获取所有细节的最简单方法。如果你只有一个设备连接,使用这个:

$ adb shell am instrument -w com.blundell.tut.test/com.blundell.tut.launchperf.TermeratureConverterActivityLaunchPerformance

注意

如果你曾经想知道你的设备上安装了哪些Instrumentation测试运行器,你可以使用这个命令:

adb shell pm list instrumentation

我们通过标准输出接收了此测试的结果集:

IINSTRUMENTATION_RESULT: other_pss=7866
INSTRUMENTATION_RESULT: global_alloc_count=4009
INSTRUMENTATION_RESULT: java_allocated=7271
INSTRUMENTATION_RESULT: execution_time=347
INSTRUMENTATION_RESULT: gc_invocation_count=0
INSTRUMENTATION_RESULT: native_pss=0
INSTRUMENTATION_RESULT: received_transactions=-1
INSTRUMENTATION_RESULT: other_shared_dirty=7128
INSTRUMENTATION_RESULT: native_shared_dirty=0
INSTRUMENTATION_RESULT: java_free=4845
INSTRUMENTATION_RESULT: java_size=12116
INSTRUMENTATION_RESULT: global_freed_size=155012
INSTRUMENTATION_RESULT: java_pss=1095
INSTRUMENTATION_RESULT: pre_sent_transactions=-1
INSTRUMENTATION_RESULT: java_private_dirty=884
INSTRUMENTATION_RESULT: pre_received_transactions=-1
INSTRUMENTATION_RESULT: other_private_dirty=6228
INSTRUMENTATION_RESULT: native_private_dirty=0
INSTRUMENTATION_RESULT: cpu_time=120
INSTRUMENTATION_RESULT: sent_transactions=-1
INSTRUMENTATION_RESULT: native_allocated=10430
INSTRUMENTATION_RESULT: java_shared_dirty=8360
INSTRUMENTATION_RESULT: global_freed_count=1949
INSTRUMENTATION_RESULT: native_free=14145
INSTRUMENTATION_RESULT: native_size=10430
INSTRUMENTATION_RESULT: global_alloc_size=372992
INSTRUMENTATION_CODE: -1

我们突出了我们感兴趣的两组值:execution_timecpu_time。它们分别表示总执行时间和使用的 CPU 时间。

在模拟器上运行此测试可能会增加测量不准确的可能性,因为宿主计算机正在运行其他进程,这些进程也占用 CPU,而模拟器并不一定能代表真实硬件的性能。

不用说,在这种情况下以及任何其他随时间变化的情况中,你应该使用测量策略并多次运行测试以获得不同的统计值,如平均值或标准差。

使用 Traceview 和 dmtracedump 平台工具

Android SDK 在其众多工具中包含了两个专门用于分析性能问题和配置文件,并可能确定优化目标的工具。Android 还为我们提供了 Dalvik 调试监控服务DDMS),它将所有这些工具集中在一个地方。DDMS 可以通过 Android Studio 导航到 工具 | Android | 设备监控器 打开,或者通过命令行使用 monitor 命令打开。你可以在 DDMS 中使用方便的 GUI 快捷方式使用 Traceview 和其他工具。然而,在这里,我们将使用命令行选项,以便你了解 GUI 背后的工具。

这些工具相较于其他替代品有一个优势:通常,对于简单的任务,无需修改源代码。然而,对于更复杂的情况,我们需要进行一些简单的添加,这一点我们很快就会看到。

如果你不需要精确控制追踪的开始和停止,可以通过命令行或 Android Studio 来操作。例如,要从命令行开始追踪,可以使用以下命令。如果连接了多个设备,记得使用 -s 添加序列号:

$ adb shell am start -n com.blundell.tut/.TemperatureConverterActivity
$ adb shell am profile com.blundell.tut start /mnt/sdcard/tc.trace

进行一些操作,比如在摄氏度字段中输入一个温度值以强制转换,然后运行以下命令:

$ adb shell am profile com.blundell.tut stop
$ adb pull /mnt/sdcard/tc.trace /tmp/tc.trace
7681 KB/s (1051585 bytes in 0.133s)

$ traceview /tmp/tc.trace

否则,如果你需要更精确地控制分析开始的时间,可以添加编程式的风格:

@Override
public void onTextChanged(CharSequence input, int start, int before, int count) {
  if (!destinationEditNumber.hasWindowFocus() 
           || destinationEditNumber.hasFocus() || input == null) {
     return;
}

String str = input.toString();
if ("".equals(str)) {
   destinationEditNumber.setText("");
   return;
}

if (BENCHMARK_TEMPERATURE_CONVERSION) {
Debug.startMethodTracing();
}

try {
double temp = Double.parseDouble(str);
   double result = (option == Option.C2F)
      ? TemperatureConverter.celsiusToFahrenheit(temp) 
      : TemperatureConverter.fahrenheitToCelsius(temp);
String resultString = String.format("%.2f", result);
   destinationEditNumber.setNumber(result);
   destinationEditNumber.setSelection(resultString.length());
} catch (NumberFormatException ignore) {
// WARNING this is generated whilst numbers are being entered,
   // for example just a '-' 
// so we don't want to show the error just yet
} catch (Exception e) {
   sourceEditNumber.setError("ERROR: " + e.getLocalizedMessage());
}

 if (BENCHMARK_TEMPERATURE_CONVERSION) {
 Debug.stopMethodTracing();
 }
}

这将创建一个追踪文件,默认名为 dmtrace.trace,在 SD 卡上通过调用 Debug.startMethodTracing() 来启动方法追踪,它使用默认的日志名称和缓冲区大小开始方法追踪。完成之后,我们调用 Debug.stopMethodTracing() 来停止分析。

注意

请记住,启用分析会大大减慢应用程序的执行速度,因此结果应该根据它们的相对权重来解释,而不是绝对值。

为了能够向 SD 卡写入数据,应用程序需要在清单文件中添加 android.permission.WRITE_EXTERNAL_STORAGE 权限。

使用 DDMS 进行 Traceview 时,数据流会通过 JDWP 连接直接发送到开发计算机,因此不需要该权限。

你需要运行应用程序以获取追踪文件。这个文件需要被拉取到开发计算机上,以便使用 traceview 进行进一步分析:

$ adb pull /mnt/sdcard/dmtrace.trace /tmp/dmtrace.trace
 8491 KB/s (120154 bytes in 0.013s)
$ traceview /tmp/dmtrace.trace

运行此命令后,Traceview 窗口会出现,显示所有收集到的信息,如截图所示:

使用 Traceview 和 dmtracedump 平台工具

窗口顶部显示了时间线面板以及每个方法的彩色区域。时间沿刻度向右增加。在彩色行下方还有小线条,显示了对选定方法的调用的范围。

我们对应用程序的一个小片段进行了分析,因此只有主线程在运行我们的进程。在其他线程在分析过程中运行的情况下,此信息也将显示。例如,这表明系统执行了一个 AsyncTask。

窗口底部显示了分析面板,执行了每个方法及其父子关系。我们将调用方法称为父方法,被调用的方法称为子方法。点击方法时,它会展开以显示其父方法和子方法。父方法显示为紫色背景,子方法显示为黄色背景。

此外,为方法选择的颜色(以循环方式完成)在方法名称之前显示。

最后,在底部有一个查找:字段,我们可以在其中输入筛选器以减少显示的信息量。例如,如果我们只想显示com.blundell.tut包中的方法,我们应该输入com/blundell/tut

点击列标题将根据该列以升序或降序设置列表的顺序。

此表显示了可用的列及其描述:

描述
名称 方法的名称,包括其包名,正如我们刚才描述的那样,使用/(斜杠)作为分隔符。同时,显示参数和返回类型。
包含 CPU 时间百分比 方法使用的包含时间占总时间的百分比。这包括其所有子方法。
包含 CPU 时间 特定方法的包含时间,以毫秒为单位。这包括该方法及其所有子方法。
独占 CPU 时间百分比 方法使用的独占时间占总时间的百分比。这排除其所有子方法。
独占 CPU 时间 独占时间,以毫秒为单位。这是特定方法所花费的总时间。它不包括所有子方法。
包含实时百分比 进程执行时的包含时间加上等待时间占总时间的百分比(等待 I/O)。
包含实时 进程执行时的包含时间加上等待时间。
独占实时百分比 独占时间加上进程执行时的等待时间占总时间的百分比(等待 I/O)。
独占实时 独占时间加上进程执行时的等待时间。
调用+递归调用/总数 这一列显示了特定方法的调用次数和递归调用次数。与该方法收到的总调用次数进行比较。
每次调用 CPU 时间 每次调用的毫秒数时间。

关于 Traceview 的最后提醒是:目前 Traceview 禁止 JIT 编译器运行,这可能导致 Traceview 将时间误归到代码块,而 JIT 可能能够赢回这些时间。因此,在根据 Traceview 数据做出更改后,你一定要确保在未使用 Traceview 的情况下运行的实际代码能够更快。

Dmtracedump

Dmtracedump 是 traceview 的替代品。它允许你使用已经收集的追踪文件,以替代格式生成追踪数据,包括 HTML,以及一个调用堆栈图。后者是树状结构,树的每个节点代表堆栈中的一个调用。

您可以使用我们从设备中提取的相同 traceview 文件,并使用以下新命令:

dmtracedump –t 40 –g dmtrace.png /tmp/dmtrace.trace 

要将你的追踪数据以 HTML 格式查看,请运行以下命令:

dmtracedump –h /tmp/dmtrace.trace > dmtrace.html

这个替代的 HTML 视图允许你以不同于原始 traceview GUI 的方式浏览追踪详情,并过滤每个调用的调用堆栈。

Dmtracedump

下表描述了您可以使用 dmtracedump 的额外命令行参数:

命令 描述
-d <trace-file-name> 与此追踪文件进行比较,并打印差异。
-g <graph-out-file-name.png> 在此文件中生成图表。从技术上来说,它可能不会生成 PNG 图片,但如果你将其命名为 something.png,你可以打开文件查看图表。
-h 开启 HTML 输出。这将在你的控制台以 HTML 代码形式打印,因此记得将此输出重定向到一个文件,例如 example.html
-o 输出追踪文件,而不是进行性能分析。
-s <trace-file-name> 可排序 JavaScript 文件的 URL 路径基础(我不确定这个参数的用途!code.google.com/p/android/issues/detail?id=53468)。
-t <percent> 在图表中包含子节点的最小阈值(子节点的包含时间占父节点包含时间的百分比)。如果未使用此选项,则默认阈值为 20%。

微基准测试

基准测试是运行计算机程序或操作的行为,以便以产生定量结果的方式比较操作,通常是对它们进行一系列测试和试验。

基准测试可以组织为以下两大类:

  • 宏基准测试

  • 微基准测试

宏基准测试作为一种比较不同平台在特定领域(如处理器速度、每单位时间的浮点运算数量、图形和 3D 性能等)的手段而存在。它们通常用于硬件组件,但也可以用于测试软件特定领域,如编译器优化或算法。

与这些传统的宏观基准测试相对,微基准测试试图测量非常小段代码的性能,通常是一个单独的方法。获得的这些结果用于在选择提供相同功能的不同实现方案时决定优化路径。

这里的风险是,您可能测量到的微基准测试与您认为正在测量的内容不同。这是在使用 JIT 编译器的情况下主要需要考虑的问题,Android 从 2.2 Froyo 版本开始使用 JIT 编译器。JIT 编译器可能会以与应用程序中相同代码不同的方式编译和优化您的微基准测试。因此,在做出决定时要谨慎。

这与上一节引入的剖析策略不同,因为这种方法不考虑整个应用程序,而是一次只考虑一个方法或算法。

Caliper 微基准测试

Caliper是谷歌的开源框架,用于编写、运行和查看微基准测试的结果。在code.google.com/p/caliper它的网站上有很多示例和教程。

developer.android.com上推荐了 Caliper,谷歌用它来衡量 Android 编程语言本身的性能。我们在这里探讨其基本用法,并在下一章介绍更多与 Android 相关的使用方法。

它的核心思想是基准测试方法,主要是为了了解它们的效率如何。我们可能会决定这是我们优化的目标,或许是在通过 Traceview 分析应用程序的剖析结果之后。

Caliper 基准测试使用注解来帮助您正确构建测试。基准测试的结构与 JUnit 测试类似。以前,Caliper 在约定上模仿 JUnit3;例如,测试必须以test为前缀,基准测试以time为前缀。在最新版本中,它类似于 JUnit4,JUnit 有@Test,Caliper 使用@Benchmark。每个基准测试都接受一个 int 参数,通常命名为reps,表示对方法内部代码进行基准测试的重复次数,该代码由一个循环包围,计算重复次数。

存在setUp()方法或@Before注解,用作@BeforeExperiment

基准测试温度转换器

让我们从在项目中创建一个新的 Java 模块开始。是的,这次不是一个 Android 模块——只是 Java。

为了保持一致性,使用com.blundell.tut包作为主包。

/benchmark/build.gradle文件中,将此模块的依赖项添加到您核心模块上。这样您就可以访问温度转换器代码:

compile project(':core').

同时,将Caliper库作为一个依赖项添加;这是托管在 Maven 中央的。然而,在撰写这本书的时候,谷歌发布的版本是 Caliper 1.0-beta-1,它不包括我们刚刚讨论的注解。我已经尝试在code.google.com/p/caliper/issues/detail?id=291上戳他们解决这个问题,如果你觉得有倾向,可以给那个问题加星。因此,与此同时,另一个开发者已经将 Caliper 发布到 Maven 中央的他的包下,以便我们可以使用注解。这是你需要导入的:

compile 'net.trajano.caliper:caliper:1.1.1'

创建一个包含我们基准测试的TemperatureConverterBenchmark类:

public class TemperatureConverterBenchmark {

  public static void main(String[] args) {
CaliperMain.main(CelsiusToFahrenheitBenchmark.class, args);
  }

  public static class CelsiusToFahrenheitBenchmark {

   private static final double MULTIPLIER = 10;
   @Param({"1", "10", "100"})
   int total;

   private List<Double> temperatures = new ArrayList<Double>();

   @BeforeExperiment
   public void setUp() {
      temperatures.clear();
      generateRandomTemperatures(total);
}

   private void generateRandomTemperatures(int total) {
       Random r = new Random(System.currentTimeMillis());
      for (int i = 0; i < total; i++) {
        double randomTemperature = MULTIPLIER * r.nextGaussian();
          temperatures.add(randomTemperature);
   }
   }

   @Benchmark
   public void timeCelsiusToFahrenheit(int reps) {
   for (int i = 0; i < reps; i++) {
         for (double t : temperatures) {
            TemperatureConverter.celsiusToFahrenheit(t);
         }
}
   }
   }
}

我们有一个类似于 JUnit 测试的setUp()方法,它使用@BeforeExperiment注解。它在运行基准测试之前执行。这个方法初始化一个用于转换基准测试的随机温度集合。这个集合的大小是一个字段,在这里用@Param注解,以便 Caliper 知道它的存在。当我们运行基准测试时,Caliper 将允许我们提供这个参数的值。但是,对于这个例子,我们给 param 一些默认值"1", "10", "100"。这意味着我们将至少有三个基准测试,分别是一个、然后是 10 个、最后是 100 个温度值。

我们使用高斯分布来生成伪随机温度,因为这可以是用户现实情况的一个很好的模型。

基准测试方法本身使用@Benchmark注解,以便 Caliper 可以识别并运行这个方法,在这个timeCelsiusToFahrenheit()实例中。在这个方法内部,我们根据传递给我们的方法参数进行循环,每次调用TemperatureConverter.celsiusToFahrenheit()转换方法,这是我们希望进行基准测试的方法。

运行 Caliper。

要运行 Caliper,请右键点击该类,从菜单中选择并运行TemperatureConverterBenchmark.main()。如果你想改变总参数的默认值1, 10, 100,请编辑运行配置,在程序参数字段中输入–Dtotal=5,50,500

无论哪种方式,这都会运行基准测试,如果一切顺利,我们将看到结果:

 Experiment selection: 
 Instruments:   [allocation, runtime]
 User parameters:   {total=[1, 10, 100]}
 Virtual machines:  [default]
 Selection type:    Full cartesian product

This selection yields 6 experiments.
Starting trial 1 of 6: {instrument=allocation, benchmarkMethod=timeCelsiusToFahrenheit, vm=default, parameters={total=1}}… Complete!
 bytes(B): min=32.00, 1st qu.=32.00, median=32.00, mean=32.00, 3rd qu.=32.00, max=32.00
 objects: min=1.00, 1st qu.=1.00, median=1.00, mean=1.00, 3rd qu.=1.00, max=1.00
….
Starting trial 6 of 6: {instrument=runtime, benchmarkMethod=timeCelsiusToFahrenheit, vm=default, parameters={total=100}}… Complete!
 runtime(ns): min=158.09, 1st qu.=159.52, median=161.16, mean=162.42, 3rd qu.=163.06, max=175.13
Execution complete: 1.420m.
Collected 81 measurements from:
 2 instrument(s)
 2 virtual machine(s)
 3 benchmark(s)
Results have been uploaded. View them at: https://microbenchmarks.appspot.com/runs/33dcd3fc-fde7-4a37-87d9-aa595b6c9224

为了帮助可视化这些结果,有一个托管在 Google AppEngine 上的服务(microbenchmarks.appspot.com),它接受你的结果数据,并让你以更好的方式可视化它。你可以在前面的输出中看到这个 URL,结果已经发布在那里。

如果你希望访问一组基准测试套件,或者随着时间的推移收集你的结果,你可以登录这个服务器并获得一个 API 密钥,以帮助汇总你的结果。一旦你获得了这个密钥,它应该被放在你主目录下的~/.caliper/config.properties文件中,下次你运行基准测试时,结果将与你登录的账户关联。

在您粘贴获得到的 API 密钥后,config.properties 文件将看起来像下面这段代码:

# Caliper config file
# Run with --print-config to see all of the options being applied
# INSTRUMENT CONFIG
# instrument.micro.options.warmup=10s
# instrument.micro.options.timingInterval=500ms
# instrument.micro.options.reportedIntervals=7
# instrument.micro.options.maxRuntime=10s
# VM CONFIG
vm.args=-Xmx3g -Xms3g
# See the Caliper webapp to get a key so you can associate results with your account
results.upload.options.key=abc123-a123-123a-b123-a12312312

结果将如下所示:

运行 Caliper

除了运行速度,生成的网站还会显示用于运行测试的 JVM 配置。蓝色和红色部分可以展开以查看更多属性,帮助您检测实际运行环境是否在影响报告的不同结果。

总结

在本章中,我们通过基准测试和代码剖析,剖析了可用于测试应用程序性能指标的可用替代方案。

在撰写本书时,Android SDK 应提供的某些选项是不可用的,而且由于 SDK 中隐藏了一些代码,无法实现 Android PerformanceTestCases

在这些替代方案中,我们发现可以使用简单的日志声明或更复杂的扩展了插桩的代码。

随后,我们分析了剖析的替代方案,并描述和示例了 traceviewdmtracedump 的使用。

最后,您了解了 Caliper,这是一个支持 Android 原生的微基准测试工具。然而,我们仅介绍了其最基本的使用方法,并将更具体的 Android 和 Dalvik VM 使用方法留待下一章介绍。

为了在下一章中能够量化您的测试工作,我们将对我们的代码执行覆盖报告。我们还将介绍替代测试方法,并讨论 Android 测试领域的新兴库和主题,希望这能给您提供一些探索和继续您测试旅程的起点。

第九章:替代测试策略

到目前为止,我们已经分析了在项目中实施测试的最常见和最易获取的策略。然而,在我们的拼图中还缺少一些部分,我们希望在这最后一章中涵盖。随着 Android Studio 和 Gradle 的出现,Android 生态系统一直在向前发展。测试工具箱也总是在增加。在这一领域,我们将研究一些第三方库,它们可以帮助我们扩展测试框架;例如在 JVM 上为 Android 测试的 Robolectric,以及像 Fork 这样的潜在前沿和未来开发;想象一下为你的测试提供线程。

在本章中,我们将涵盖以下主题:

  • Jacoco 代码覆盖率

  • Robotium

  • 在宿主 JVM 上进行测试

  • Robolectric

  • Fest

  • Spoon/Fork

代码覆盖率

或许 Android 的阿基里斯之踵就是文档的缺乏,以及为了找到完整版本的内容,你需要访问许多地方,或者更糟糕的是,在许多情况下,官方文档是错误的,或者没有更新以匹配当前版本。新 Gradle 构建系统的文档非常稀少,这正是大多数人尝试阅读代码覆盖率内容时的起点;那么,让我们点亮一些黑暗的角落。

代码覆盖率是软件测试中使用的一种度量,它描述了测试套件实际测试的源代码量,以及根据某些标准的程度。由于代码覆盖率直接检查代码,因此它是一种白盒测试形式。

注意

白盒测试(也称为透明盒测试、玻璃盒测试、结构测试),是一种测试软件的方法,它测试应用程序的内部结构或工作原理,而不是其功能(例如黑盒测试)。

在众多可用于提供 Java 代码覆盖率分析的工具体中,我们选择了 Jacoco,这是一个开源工具包,用于测量和报告 Java 代码覆盖率,并由 Android 项目提供支持。启动自己的项目使用它的基础设施已经就绪,因此,实现它所需的工作量最小化。Jacoco 取代了 EMMA 代码覆盖率工具,同时吸取了这一努力中学到的知识,并由同一团队构建。

Jacoco 通过独特的功能组合与其他工具区分开来;支持大规模企业软件开发,同时保持单个开发者的工作快速和迭代。这对于像 Android 这样规模的项目来说至关重要,Jacoco 在这方面表现得最为出色,为它提供了代码覆盖率。

Jacoco 特性

Java、Android Gradle 插件和 Gradle 构建系统,都对 Jacoco 有原生支持。从本书发布时最新的 Jacoco 版本来看,引用其文档,最独特的功能集如下:

  • Jacoco 可以在离线(加载之前)或动态(使用检测应用程序类加载器)为覆盖率对类进行检测。

  • 支持的覆盖率类型:类、方法、行、分支和指令。Jacoco 可以检测到单个源代码行是否仅被部分覆盖。

  • 覆盖率统计数据在方法、类、包和“所有类”级别进行汇总。

  • 输出报告类型:纯文本、HTML、XML。所有报告类型都支持根据用户控制的详细深度进行下钻。HTML 报告支持源代码链接。

  • 输出报告可以突出显示低于用户提供的阈值的覆盖率项。

  • 在不同的检测或测试运行中获取的覆盖率数据可以合并在一起。

  • Jacoco 不需要访问源代码,并且随着输入类中可用的调试信息减少,其性能会优雅地降级。

  • Jacoco 相对较快;添加检测的开销很小(5 到 20%),字节码检测器本身非常快(主要受文件 I/O 速度限制)。每个 Java 类的内存开销是几百字节。

温度转换器代码覆盖率

Android Gradle 插件开箱即支持 Jacoco 代码覆盖率。设置涉及选择您想要获取覆盖率报告的构建版本,并选择您的 Jacoco 版本。我们希望对 debug 版本进行检测,这样可以在不影响发布代码的情况下获得覆盖率。

android {
  …
  buildTypes { 
        debug {
            testCoverageEnabled true
        }
      }

    jacoco {
        version = '0.7.2.201409121644'
    }
}

实际上,这里并不需要添加 Jacoco 的版本信息,但是目前随 Android 附带的 Jacoco 版本落后于最新版本。可以在他们的 GitHub 页面 github.com/jacoco/jacoco 或 Maven 中央仓库找到最新版本的 Jacoco 覆盖率库。因此,建议明确指定版本。

生成代码覆盖率分析报告

您需要让模拟器运行,因为 Jacoco 会检测您的 Android 测试,这些测试在设备上运行,因此使用模拟器是合适的。测试完成后,设备上会生成一个代码覆盖率报告,然后将其拉取到您的本地机器上。如果您选择使用真实设备而不是模拟器,那么设备需要获得 root 权限。否则,报告拉取会因 Permission Denied 异常而失败。

从命令行运行代码覆盖率如下:

$./gradlew build createDebugCoverageReport

如果您有多个构建版本,也可以使用以下命令:

$./gradlew build connectedCheck

以下信息验证了我们的测试已经运行,并且覆盖率数据已被检索:

:app:connectedAndroidTest 
:app:createDebugCoverageReport 
:app:connectedCheck 

BUILD SUCCESSFUL

这将在 /app/build/outputs/reports/coverage/debug/ 目录中创建报告文件。如果您使用多个构建版本,您的路径会略有不同。

在我们继续之前,如果您还没有意识到,我们不仅为 Android 应用模块生成了报告,我们的 Java core 模块中也有代码。我们也为这个模块创建一个报告。

由于 Gradle 支持 Jacoco,我们只需将 Jacoco 插件应用到我们的code/build.gradle文件中:

apply plugin: 'jacoco''jacoco''jacoco''jacoco'''

使用与我们 Android 模块相同的闭包,可以进行更多配置。可以在 Gradle Jacoco 插件网站找到可更改属性的详细信息,网址为gradle.org/docs/current/userguide/jacoco_plugin.html

现在,如果你运行./gradlew命令任务,你应该会看到一个新生成的 Gradle 任务,jacocoTestReport。运行此任务为我们的核心模块生成代码覆盖率:

$./gradlew jacocoTestReport 

这已在/core/build/reports/jacoco/test/目录内创建了报告文件。

太棒了!现在我们既有app代码的覆盖率报告,也有core代码的覆盖率报告。

注意事项

可以将这两个报告合并为一个文件。你可能需要处理 XML 输出才能完成此操作。这留给读者作为一个任务,但可以在 Jacoco 网站和 Gradle 插件网站上寻找提示(这已经有人做过了)。

让我们打开app模块的index.html来显示覆盖率分析报告。

生成代码覆盖率分析报告

报告中呈现的信息包括覆盖率指标,这种方式允许以自上而下的方式深入数据,从所有类开始,一直到单个方法和源代码行(在 HTML 报告中)。

Jacoco 中代码覆盖率的基本组成部分是基本块;所有其他类型的覆盖率都是从基本块覆盖率派生出来的。行覆盖率主要用于链接到源代码。

此表描述了 Jacoco 覆盖率报告中的重要信息:

标签 描述
元素 类或包的名称。
未覆盖指令,覆盖率 一个视觉指示器,显示未通过测试覆盖的指令数量(红色显示),以及通过测试覆盖的指令百分比。例如:if(x = 1 && y = 2)将是两个指令但一行代码。
未覆盖分支,覆盖率 一个视觉指示器,显示未通过测试覆盖的分支数量(红色显示),以及覆盖的分支百分比。将 if/else 语句视为两个分支。一个方法中的分支数量是衡量其复杂度的一个好指标。
未覆盖,Cxty 未覆盖的复杂路径(循环复杂度)数量,以及总的复杂度。一个复杂路径定义为一系列字节码指令,其中不包含任何跳转或跳转目标。在代码中添加一个分支(如一个if语句)将增加两个路径(真或假),因此复杂度会增加 1。然而,添加一个指令(如x = 1;)不会增加复杂度。
未覆盖,行数 任何测试未执行的行数,以及总行数。
未覆盖,方法 未覆盖的方法数量,以及总方法数量。这是一个由给定数量的基本路径组成的基本 Java 方法。
未覆盖,类 没有进行任何测试的类数量,以及总类数量。

我们可以从包深入到类,再到具体的方法,覆盖的行以绿色显示,未覆盖的行以红色显示,而部分覆盖的行以黄色显示。

这是对core/ TemperatureConverter类的报告示例:

生成代码覆盖率分析报告

在这份报告中,我们可以看到类TemperatureConverter的测试覆盖率并不是 100%。当我们查看代码时,发现是构造函数从未被测试过。

你知道为什么吗?请思考一下。

是的,因为私有构造函数从未被调用。这是一个不应该被实例化的工具类。

如果你能够想象创建一个只有一个静态方法的类,你通常不会创建私有构造函数;它会保留为不可见的默认公共构造函数。在这种情况下,我相当勤勉地编写了这个私有构造函数,因为当时我是一个好童子军(现在仍然是!)。

我们可以看到,这种分析不仅帮助我们测试代码和查找潜在的 bug,还可以改进设计。

一旦我们认为这个私有构造函数是一段不需要运行测试的合理代码,现在我们可以看到,尽管类还没有达到 100%的覆盖率,因此不是绿色的,但我们确信这个构造函数不会被其他任何类调用。

我认为这里的一个非常重要的教训是;100%的代码覆盖率不应该是你的目标。理解你的领域和应用程序的架构,可以让你对代码覆盖率的估计更加可达和现实。

  • 让你有信心改变代码而不会产生副作用。

  • 让你相信,你被要求交付的产品,是你已经创建的产品。

覆盖异常情况。

继续检查覆盖率报告,我们会发现另一个未被当前测试执行的代码块。这个问题出现在app/TemperatureConverterActivity中的以下 try-catch 块的最后一个 catch 中:

try {
   double temp = Double.parseDouble(str);
   double result = (option == Option.C2F)
? TemperatureConverter.celsiusToFahrenheit(temp)
: TemperatureConverter.fahrenheitToCelsius(temp);
   String resultString = String.format("%.2f",",("%.("%."","," result);
   destinationEditNumber.setNumber(result);
   destinationEditNumber.setSelection(resultString.length());
} catch (NumberFormatException ignore) {
// WARNING this is generated whilst numbers are being entered,
   // for example just a -''''''
   // so we don'tdon'tdon'tdon't' want to show the error just yet
} catch (Exception e) {
sourceEditNumber.setError("ERROR: " + e.getLocalizedMessage());
}

首先,我们为什么要捕获通用的Exception?让我们将其更具体地针对我们期望处理的错误。这样我们就不会处理那些我们不期望的异常,而且如果有人阅读代码,他们会明确知道我们在这里试图做什么。

现在我们知道了导致我们测试覆盖率不全的代码,我们知道要编写哪些测试来抛出这个异常,并更新我们的测试套件和 Jacoco 报告。

} catch (InvalidTemperatureException e) {
sourceEditNumber.setError("ERROR: " + e.getLocalizedMessage());
}

我们应该提供一个测试,或者更好的是,对于每个温度单位都有一对测试,给定一个无效温度,验证错误是否显示。以下是 TemperatureConverterActivityTests 中的针对摄氏度情况的测试,你可以轻松地将其转换为提供华氏度情况:

public void testInvalidTemperatureInCelsius() throws Throwable {
        runTestOnUiThread(new Runnable() {
            @Override
            public void run() {
                celsiusInput.requestFocus();
            }
        });
        getInstrumentation().waitForIdleSync();

        // invalid temp less than ABSOLUTE_ZERO_C
        sendKeys("MINUS 3 8 0");");");");"

        String msg = "Expected celsius input to contain an error.";.";.";.";"
        assertNotNull(msg, celsiusInput.getError());
}

我们请求对测试字段进行焦点定位。像之前一样,我们应该通过在 UI 线程上使用 Runnable 来实现这一点,否则我们将收到异常。

然后设置无效温度并获取错误信息以验证它不为空。再次运行端到端流程,我们可以证实现在该路径已覆盖,实现了我们预期的完全覆盖。

这是你应该遵循的迭代过程,尽可能将代码改为绿色。如前所述,只要代码行不是绿色的,只要你考虑过选项并且仍然自信在未测试的路径中改变其他代码,那么这是可以的。

介绍 Robotium

在众多新兴的机器人生态中,Robotium (robotium.org) 是一个旨在简化测试编写的测试框架,它要求对被测应用有最少的了解。Robotium 主要定位于为 Android 应用编写强大且健壮的自动黑盒测试用例。它可以覆盖功能测试、系统测试和验收测试场景,甚至可以自动跨越同一应用中的多个 Android 活动。Robotium 也可以用来测试我们没有源代码的应用,甚至是预装的应用。

让我们开始使用 Robotium 为 TemperatureConverter 创建一些新的测试。为了使我们的测试更有条理,我们在 TemperatureConverter 项目的 androidTest 目录下创建了一个名为 com.blundell.tut.robotium 的新包。我们最初将测试 TemperatureConverterActivity,将其命名为 TemperatureConverterActivityTests 是合理的,尽管我们在另一个包中已经有了一个同名且也扩展了 ActivityInstrumentationTestCase2 的类。毕竟,这个类也将包含对此同一 Activity 的测试。

添加 Robotium

让我们在项目中添加 Robotium,我们只会在测试用例中使用它,所以它应该放在 testcase 类路径上。在撰写本文时,Robotium 的最新版本是 5.2.1。在 app/build.gradle 中,我们添加以下内容:

dependencies {

    ...

    androidTestCompile('com.jayway.android.robotium:robotium-solo:5.2.1')
}

创建测试用例

从前面的章节中我们知道,如果我们正在为需要连接到系统基础设施的 Activity 创建测试用例,我们应该基于 ActivityInstrumentationTestCase2,这也是我们将要做的。

测试 Fahrenheit 到 Celsius 转换的 testFahrenheitToCelsiusConversion()

大多数测试用例的结构与其他基于 Instrumentation 的测试类似。主要区别在于我们需要在测试的 setUp() 中实例化 Robotium 的 Solo,并在 tearDown() 中清理 Robotium:

public class TemperatureConverterActivityTests extends 
ActivityInstrumentationTestCase2<TemperatureConverterActivity> {

    private TemperatureConverterActivity activity;
    private Solo solo;

    public TemperatureConverterActivityTests() {
        super(TemperatureConverterActivity.class);
    }

    @Override
    protected void setUp() throws Exception {
        super.setUp();
        activity = getActivity();
        solo = new Solo(getInstrumentation(), activity);
    }

    @Override
    protected void tearDown() throws Exception {
        solo.finishOpenedActivities();
        super.tearDown();
    }
}

要实例化 Solo,我们必须传递对 Instrumentation 类和被测 Activity 的引用。

另一方面,为了清理 Solo,我们应该调用 finishOpenedActivities() 方法。这将结束 Solo 并完成我们的 Activity,然后我们调用 super.tearDown()

Solo 提供了多种方法来驱动 UI 测试和一些断言。让我们从重新实现之前使用传统方法实现的 testFahrenheitToCelsiusConversion() 测试方法开始,但这次使用 Solo 的设施:

public void testFahrenheitToCelsiusConversion() {
solo.clearEditText(CELSIUS_INPUT);
   solo.clearEditText(FAHRENHEIT_INPUT);
   solo.clickOnEditText(FAHRENHEIT_INPUT);
   solo.enterText(FAHRENHEIT_INPUT, "32.5");
   solo.clickOnEditText(CELSIUS_INPUT);
   double f = 32.5;
   double expectedC = TemperatureConverter.fahrenheitToCelsius(f);
   double actualC = 
((EditNumber) solo.getEditText(CELSIUS_INPUT)).getNumber();
   double delta = Math.abs(expectedC - actualC);

   String msg = f + "F -> " + expectedC + "C "
     + """""but was " + actualC + "C (delta " + delta + ")";
   assertTrue(msg, delta < 0.005);
}

这非常相似,然而,你可能注意到的第一个不同之处是,在这种情况下,我们没有像之前在 setUp() 方法中那样获取 UI 元素的引用,使用 findViewById() 来定位视图。然而,我们正在使用 Solo 的最大优势之一,它使用某些标准为我们定位视图。在这种情况下,标准是 EditText 出现的顺序。solo.clearEditText(int index) 方法期望一个从 0 开始的屏幕位置的整数索引。因此,我们应该将这些常量添加到测试用例中,就像在我们的用户界面中,摄氏度字段在顶部,华氏度在下方:

  private static final int CELSIUS = 0;
  private static final int FAHRENHEIT = 1;

其他 Robotium 方法遵循相同的约定,我们在必要时提供这些常量。这个测试与 com.blundell.tut.TemperatureConverterActivityTest 中的测试非常相似,但你可能已经注意到有一个微妙的区别。Robotium 位于更高的层次,我们不必担心许多内部或实现细节;例如,在我们之前的测试中,我们调用 celciusInput.requestFocus() 来触发转换机制,而在这里,我们只需模拟用户的行为并发出 solo.clickOnEditText(CELSIUS)

我们合理地简化了测试,但使用 Solo 的最大优势尚未到来。

在活动之间进行测试

由于 Robotium 位于更高层次,并且我们不处理实现细节,如果点击 Android 小部件时启动了新的 Activity,这并不是我们的问题;我们仅从 UI 的角度处理这种情况。

在这里,我将从理论上讨论一些功能。这尚未创建,留给用户作为进一步步骤,如果你愿意的话。

既然我们已经有一个工作的温度转换器,如果我们能让用户决定他们想要看到的小数位数,那就太好了。通过 Android 对话框让用户更改此选项听起来是一个明智的选择。

我们的目的是将小数位数偏好值更改为五位,并验证更改确实发生了。由于 Robotium 的高层次,这个测试在没有实际实现功能的情况下也是可读和可理解的。这是实现此功能的一种 BDD 方法的例子。

下面的代码片段展示了测试的细节:

public final void testClickOptionMenuSavesDecimalPreference() {
int decimalPlaces = 5;
   String numberRegEx = "^[0-9]+$";

   solo.sendKey(Solo.MENU);
   solo.clickOnText("Preferences");
   solo.clickOnText("Decimal places");
   assertTrue(solo.searchText(numberRegEx));

   solo.clearEditText(DECIMAL_PLACES);
   assertFalse(solo.searchText(numberRegEx));

   solo.enterText(DECIMAL_PLACES, Integer.toString(decimalPlaces));
solo.clickOnButton("OK");
   solo.goBack();

   solo.sendKey(Solo.MENU);
   solo.clickOnText("Preferences");
   solo.clickOnText("Decimal places");
   assertTrue(solo.searchText(numberRegEx));

   int editTextDecimalPlaces = 
Integer.parseInt(solo.getEditText(DECIMAL_PLACES)
.getText().toString());
assertEquals(decimalPlaces, editTextDecimalPlaces);
}

关于共享首选项和选项菜单如何实现,这里没有详细的介绍。我们只测试它的功能性。我们从按下菜单键并点击首选项开始。

哇,我们刚刚指定了菜单项标题,就完成了!

新的活动已经启动,但我们不必担心那个实现细节。我们继续点击 小数位数

我们验证是否出现了一些包含数字的字段,即此首选项的前一个值。还记得我说的正则表达式吗?它们总会在某种程度上派上用场,来匹配任何十进制整数(任意数字后跟零个或多个数字)。然后,我们清除字段并验证它确实被清除了。

我们输入希望用作首选项的数字字符串,在这个例子中是 5。点击确定按钮,首选项即被保存。

最后,我们需要验证它实际上是否已经发生。使用相同的程序来获取菜单和字段。最后,我们验证实际的数字是否已经存在。

你可能会好奇 DECIMAL_PLACES 来自哪里。我们之前为屏幕上的字段定义了 CELSIUSFAHRENHEIT 索引常量,这也是同样的情况,因为这将是我们类中应该定义的第三个 EditText。

  private static final int DECIMAL_PLACES = 2;

测试可以根据你的喜好从 IDE 或命令行运行。

在宿主的 JVM 上进行测试

我们将这个主题留到了本章的末尾,因为这看起来像是 Android 平台的圣杯

Android 基于一个名为 Dalvik 的虚拟机,以冰岛的一个村庄命名,该虚拟机针对的是资源有限的移动设备,如内存和处理速度有限。因此,它代表了移动设备,但与我们的内存丰富、速度快的宿主计算机环境肯定大不相同,后者通常拥有充足的内存和处理速度来享受。

通常,我们会在模拟器或设备上运行我们的应用程序和测试。这些目标有一个更慢的真实或模拟 CPU。因此,运行我们的测试是一个耗时的活动,特别是当我们的项目开始增长时。应用测试驱动开发技术迫使我们运行数百个测试来验证我们引入的每一项更改。

注意

值得注意的是,这项技术只能在开发过程中作为权宜之计来提高速度,绝不能替代在真实平台上的最终测试,因为 Dalvik 和 JavaSE 运行时之间的不兼容可能会影响测试的准确性。

我们已经完成了核心模块创建的一半工作。现在我们处于 Java 世界,可以在 JVM 上自由运行我们的测试(很快就可以在附近的 Android 上使用 JUnit4)。app Android 模块对核心 Java 模块有一个单向依赖。这使得我们可以摆脱 Android 测试的束缚,在 core 模块中运行测试时不受其拖累。

后来,我们应该找出一种方法,允许我们拦截在模拟器或设备上标准编译-dexing-运行的序列,并能够直接在我们的主机电脑上运行安卓。

比较性能提升。

提醒一下,与安卓仪器测试相比,运行这些仅 Java 的测试所获得的速度提升。

区别很明显。没有模拟器的启动,或者任何设备通信,因此速度的提升是重要的。通过分析证据,我们可以找出这些差异。

在我的开发电脑上运行所有测试需要 0.005 秒;有些测试耗时如此之少,以至于它们甚至没有被计算在内,显示为 0.000 秒。

如果我将这些测试移动到我们的应用模块,并在模拟器上运行相同的测试,这将使巨大的差异变得明显。这些相同的测试运行了 0.443 秒,几乎慢了 100 倍,如果你考虑到每天要运行数百次测试,这是一个巨大的差异。

还值得注意的是,除了速度提升之外,还有其他优势,包括多年来的 Java 工具、库和插件创建,包括多个模拟框架和代码分析工具。

将安卓纳入考虑范围

我们有意将安卓排除在考虑范围之外。让我们分析一下,如果在core中包含一个简单的安卓测试会发生什么。请记住,为了编译来自 SDK 的android.jar,安卓测试也应该被添加到模块的库中。

下面是我们得到的结果:

java.lang.RuntimeException: Stub!
 at android.content.Context.<init>(Context.java:4)
 at android.content.ContextWrapper.<init>(ContextWrapper.java:5)
 at android.app.Application.<init>(Application.java:6)

注意

android.jar添加到 core 的类路径稍微有些不协调且冗长。这不是默认会做的事情。这是一个好事,因为它阻止我们在编写核心代码时意外使用安卓特定的类。

原因在于android.jar只提供了 API,而不是实现。所有方法都有相同的实现:

throw new RuntimeException("Stub!");

如果我们想要绕过这个限制,以在安卓操作系统之外测试某些类,我们应该创建一个模拟每个类的android.jar。然而,我们也会发现对于安卓类的子类,如TemperatureConverterApplication,存在问题。这将是一项艰巨的任务,并且需要大量的工作,所以我们应该寻找另一个解决方案。

引入 Robolectric

Robolectricrobolectric.org)是一个单元测试框架,它拦截安卓类的加载并重写方法体。Robolectric 重新定义了安卓方法,使它们返回默认值,如null0false。如果可能,它会将方法调用转发给影子对象,模仿安卓行为。

提供了大量的阴影对象,但这远未完全覆盖,然而,它正在不断改进。这也应该让你将其视为一个不断发展的开源项目,你应当准备好为使其更好而贡献,但也应谨慎依赖它,因为你可能会发现你测试所需的功能尚未实现。这绝不是为了贬低它现有的前景。

安装 Robolectric

可以通过使用 Maven 中央仓库中的最新 Robolectric JAR 来安装 Robolectric。在撰写本文时,最新的可用版本是 2.4:

testCompile 'org.robolectric:robolectric:2.4'

通常,添加依赖项只需这一行代码,但是,对于 Robolectric 来说,需要一些小技巧才能使其与 Gradle 构建类型一起工作。

首先,Robolectric 测试需要在其自己的模块中运行。这并不是什么新鲜事。创建一个新的 Java 模块,我们称之为 robolectric-tests。包名保持不变,始终为 com.blundell.tut。现在,我们必须修改 robolectric-tests/build.gradle,以便我们可以用 Robolectric 替换 android.jar

def androidModuleName = ":app";
def flavor = "debug"

evaluationDependsOn(androidModuleName)

apply plugin: 'java'

dependencies {
    def androidModule = project(androidModuleName)
    testCompile project(path: androidModuleName, 
configuration: "${flavor}Compile")

    def debugVariant = androidModule.android.applicationVariants
.find({ it.name == flavor })
    testCompile debugVariant.javaCompile.classpath
    testCompile debugVariant.javaCompile.outputs.files
    testCompile files(
androidModule.plugins.findPlugin("com.android.application")
.getBootClasspath())

    testCompile 'junit:junit:4.12'
    testCompile 'org.robolectric:robolectric:2.4'
}

这是一大块需要理解的配置,让我们将其分解成步骤。

首先,我们为我们的 Android 应用定义模块名称,然后命名我们将要测试的版本。

EvaluationDependsOn 类告诉 Gradle 在执行我们的测试之前确保我们的应用程序模块被评估,这避免了因执行顺序的怪癖而出现任何奇怪的错误。

接下来,我们按照 Java 项目的正常约定应用 java 插件。

依赖项闭包是我们将所有 Android 依赖项添加到我们的类路径中的地方。首先,我们添加模块的选定构建变体 debug,然后是类路径及其依赖项,同时确保我们有来自 Android 插件的系统依赖项。

最后,我们将 JUnit4 和 Robolectric 作为测试依赖项应用。

注意

请记住,如果你有多个产品版本和构建类型,那么这个配置需要将完整的构建变体添加到脚本中。修改这个构建脚本将会非常简单直接。

添加资源

当你运行测试时,Robolectric 尝试查找你的 AndroidManifest.xml 以便它能为你的应用程序找到资源,并了解你的目标 SDK 版本等其他属性。由于当前 Robolectric 版本和我们对单独模块的选择,Robolectric 无法找到你的资源或你的 Android 清单。即使没有这一可选步骤,你仍然可以编写测试并获得反馈,但当你访问使用资源的类时,可能会遇到一些奇怪的现象;例如,R.string.hello_world, 在你的控制台可能会看到这样的信息:

WARNING: No manifest file found at ./AndroidManifest.xml.Falling back to the Android OS resources only. To remove this warning, annotate your test class with @Config(manifest=Config.NONE).

通过使用@Config注解按其说明操作,或者创建一个自定义测试运行器指定清单位置,或者像我们在这里选择的做法一样,创建一个配置文件并将其添加到你的类路径中,可以解决这个问题。在robolectric-tests模块内,创建文件夹/src/test/resources,并创建一个文件org.robolectric.Config.properties。这将包含我们的 Android 清单位置;它还将包含我们的最小 SDK 版本,因为我们在清单中没有声明这一点。它将包含以下内容:

manifest=../app/src/main/AndroidManifest.xml
emulateSdk = 16

注意

Robolectric 尝试在AndroidManifest.xml中查找你的最小 SDK。但是,在使用 Gradle 构建系统时,你不在那里声明,而是在app/build.gradle中声明。

我们现在已经设置好并准备编写一些 Robolectric 测试!

编写一些测试

我们将通过复现之前编写的一些测试来熟悉 Robolectric。一个好的例子是重写EditNumber测试。让我们在新建的项目中创建一个新的EditNumberTests类,并从TemperatureConverterTest项目中的EditNumberTests类复制测试:

@RunWith(RobolectricTestRunner.class)
public class EditNumberTests {

  private static final double DELTA = 0.00001d;

  private EditNumber editNumber;
@RunWith annotation. Then we defined the editNumber field, to hold the reference to the EditNumber class:
    @Before
    public void setUp() throws Exception {
        editNumber = new EditNumber(Robolectric.application);
        editNumber.setFocusable(true);
    } 
comprises the usual setup() method. In the setUp() method, we created an EditNumber with an application context, and then we set it as focusable. The context is used to create the view, and Robolectric handles this for us:
    @Test
    public final void testClear() {
        String value = "123.45";";";";"
        editNumber.setText(value);

        editNumber.clear();

        assertEquals("",  editNumber.getText().toString());
    }
    @Test
    public final void testSetNumber() {
        editNumber.setNumber(123.45);

        assertEquals("123.45", editNumber.getText().toString());
    }

    @Test
    public final void testGetNumber() {
        editNumber.setNumber(123.45);

        assertEquals(123.45, editNumber.getNumber(), DELTA);
    }
EditNumber tests of our previous examples.

我们强调最重要的变化。第一个是指定测试运行器 JUnit 的注解@RunWith,这将委托测试处理。在这种情况下,我们需要使用RobolectricTestRunner.class作为运行器。然后我们使用 Robolectric 上下文创建一个EditText类,因为这是一个没有帮助就无法实例化的类。最后,在testGetNumber中指定了DELTA值,因为 JUnit 4 中的浮点数需要它。此外,我们添加了@Test注解以将方法标记为测试。

原始EditNumberTests中存在的其他测试方法无法实现,或者由于各种原因简单地失败。例如,正如我们之前提到的,Robolectric 类返回默认值,如null0false等,Editable.Factory.getInstance()也是如此,它返回 null 并导致测试失败;因为没有其他创建Editable对象的方法,我们陷入了僵局。

同样,EditNumber设置的InputFilter是非功能的。创建一个期望某些行为的测试是徒劳的。

这些缺点的替代方法是创建Shadow类,但这需要修改 Robolectric 源代码并创建Robolectric.shadowOf()方法。如果你有兴趣将这种方法应用于你的测试,可以遵循文档中描述的此过程。

识别出这些问题后,我们可以继续运行测试,它们将在宿主的 JVM 中运行,无需启动或与模拟器或设备通信。

谷歌对阴影的进军

由于某种原因,谷歌并不喜欢 Robolectric,他们从未承认它有效,也从未说过它是解决问题的方案。如果他们忽视这个解决方案,那么意味着缓慢运行的测试问题不存在,对吧。他们似乎觉得 Robolectric 有损于 Android,因此在某种程度上公开地对其冷淡。通过忽略其存在来暗中排挤它,直到现在。

谷歌创建了一个与我们之前所说的完全一样的 android.jar 文件,其中包含了默认方法实现。这意味着在访问方法时不再出现 stub! 错误。此外,他们已经从所有类中移除了 final 修饰符,让模拟框架有了很大的发挥空间。不幸的是,在撰写本文时,这个功能尚未记录在案。不足为奇!我不想给出使用步骤,因为虽然未记录在案,但这些内容会迅速变化。然而,我要说的是,如果谷歌做对了这件事,那么对于之前描述的测试场景,Robolectric 就不再需要,我们可以使用标准的 Android 测试 SDK。同样的原则将适用,因此我认为如果你了解 Robolectric 的工作原理,这仍然是有价值的。你可以将这种理解应用到未来,而我不能。

引入 Fest

我们测试武器库中的另一个工具是更好的测试断言。你有没有注意到,有时失败的测试堆栈跟踪非常不友好和/或神秘地错误?它们没有提供关于实际失败的有用信息,你最终会感到困惑,不得不阅读整个源代码以弄清楚如何解决问题。

以这个断言为例:

org.junit.Assert.assertEquals(3, myList.size());

我们断言在执行某个任务后,对象集合的大小为三个,看看测试失败时的错误信息:

java.lang.AssertionError: 
Expected :3
Actual   :2

好吧,这有点道理,但有点抽象。我们的列表中缺少哪一项?我得重新运行测试才能找出答案,或者我可以添加一个自定义错误信息:

assertEquals("List not the correct size " + myList, 
3, myList.size());

给我这样的错误信息:

java.lang.AssertionError: List not the correct size [A, B] 
Expected :3
Actual   :2

这个错误信息好多了。它显示我的列表中缺少了 C。但是,回顾一下这个断言,它的可读性越来越差。有时,一眼看去,我可能甚至会觉得我在尝试断言初始字符串是否等于其他变量,参数的顺序根本没有帮助。如果我有一个不容易实现 toString 的对象呢?我需要编写更多自定义代码来打印一个友好的错误信息,可能还会重复很多样板错误信息。

现在立刻停止所有担忧!看看我们如何使用 Fest 进行同样的断言:

org.fest.assertions.api.assertThat(myList).hasSize(3);

现在,我们的错误信息看起来像这样:

java.lang.AssertionError: expected size:<3> but was:<2> in:<['A', 'B'']>

很棒,我们没有额外付出努力,就得到了一个错误信息,它向我们展示了列表中的内容以及大小是如何区分的。回顾一下这个断言,它的可读性更强了,而且使用流畅的接口编码也变得更容易。这提高了我们测试代码的可读性,加快了调试和测试修复的速度。

在进行这个更改之后,我意识到我们实际上可能想要测试列表的内容,但由于 JUnit 断言的负担,我们没有这么做。再次,Fest 来拯救:

assertThat(myList).contains("A", ""B", ""C");
output: 
  java.lang.AssertionError: expecting:
<['A',]>
 to contain:
<['A', 'B', 'C']>
 but could not find:
<['C']>

想想如果我们用 JUnit 断言来做这件事,你就会更加欣赏 Fest 的强大。

Fest 提供了多种断言风格,适用于不同的库;这些包括前面展示的 Java 风格,以及允许你对 Android 组件(如视图和片段)进行流畅式界面断言的 Android 风格。以下是 JUnit 断言可见性的示例,然后是 Fest:

assertEquals(View.VISIBLE, layout.getVisibility());

assertThat(layout).isVisible();

将这些库添加到你的项目中,只需再添加一个 Gradle 依赖项,最新版本可以在 Maven 中央仓库找到。以下是 Java 版 Fest 的示例,截至撰写本文时的最新版本:

testCompile 'org'.easytesting:fest-assert-core:2.0M10'

注意

注意,Android Fest 已经更名为 Assert-J,并根据你的测试需求拆分为多个依赖项。断言的工作方式将完全相同。更多信息以及作为依赖项添加的说明可以在 github.com/square/assertj-android 找到。

介绍 Spoon

设备碎片化一直是 Android 的话题,这是你应该考虑的问题。不同设备和外形尺寸的数量意味着你真的需要确信你的应用程序在所有上述设备上都能良好运行。Spoon 正是为了帮助解决这个问题;Spoon (square.github.io/spoon) 是一个开源项目,提供了一个测试运行器,允许在所有连接的设备上并行运行仪器测试。它还允许你在测试运行时截图。这不仅加快了你的测试和反馈周期,还可能让你直观地看到测试出错的地方。

你可以通过添加以下依赖关系将 Spoon 加入到你的项目中:

testCompile com.squareup.spoon:spoon-client:1.1.2

然后,你可以在测试中截图,这样在断言行为的同时,你也可以看到应用程序的状态:

Spoon.screenshot(activity, "max_celcius_to_fahrenheit");

如果你在断言之前立即截图,可以利用这些截图帮助你确定失败的原因。另一个很酷的功能是,Spoon 会将一个测试中的所有截图合成为一张动画 GIF,这样你可以观看事件的发生顺序。

然后,你可以从命令行运行 Spoon,使用以下命令:

$java -jar spoon-runner-1.1.2-jar-with-dependencies.jar \
 --apk androidApplicationTestGuide.apk \
--test-apk androidApplicationTestGuideTests.apk

注意

你可以在 /build/ 文件夹中找到你的 APK 文件。如果你需要更多关于这种使用 APK 文件的方式以及从命令行测试的信息,请回顾 第七章,行为驱动开发

介绍 Fork

另一个带有幽默感的库名称,但读者请继续看下去,这种相似性并非巧合。在告诉了你 Spoon 如何通过在所有连接的设备上并行运行所有仪器测试来加速你的测试之后,现在来了 Fork,它告诉你这种天真的调度(他们的话,非我的)对你和你的 CI 来说是个负担。Fork 能让你更快地运行测试!

Fork 通过引入一个名为设备池的概念来提高你的测试速度。简单来说,想象一下你有两个完全相同的设备,都是运行 Android 5.0 的两台索尼 Xperia Z1。Fork 将获取你的测试套件并将其一分为二,在每个设备上运行一半的测试。因此,它能为你节省大约 50%的测试运行速度(大致不包括热身/设置时间)。

这些设备池有不同的类型,例如 api 级别、最小宽度、平板设备或手动池,你可以在其中声明你想使用的设备序列号。关于设备池和 fork 任务的定制参数的更多信息可以在goo.gl/cIm6GQ找到。

通过向你的构建脚本中添加插件并应用它,Fork 可以与 Gradle 一起使用:

buildscript {
    dependencies {
        classpath 'com'.shazam.fork:fork-gradle-plugin:0.10.0'
    }
}

apply plugin: 'fork'

现在,你可以使用以下命令运行 fork 测试,而不是你正常的仪器测试:

./gradlew fork

注意

如果你的项目中有多个 flavor,你可以使用以下命令查看可用的 fork 任务:./gradlew tasks | grep fork

Spoon 和 Fork 是强大的工具,现在结合你对仪器测试、单元测试、基准测试和代码分析的知识,你可以构建一个健壮、信息丰富且全面的测试套件,在编写 Android 应用程序时,这能给你信心和灵活性。

总结

这一章比之前的章节要深入一些,唯一目的是面对现实情况和最先进的 Android 测试技术。

我们首先通过 Jacoco 启用代码覆盖率,运行我们的测试,并获得详细的代码覆盖率分析报告。

然后我们使用这个报告来改进我们的测试套件。编写测试来覆盖我们之前没有意识到的未测试的代码。这使我们得到了更好的测试,有时也改进了被测试项目的设计。

我们引入了 Robotium,这是一个非常有用的工具,可以简化我们 Android 应用程序测试用例的创建,并且我们用它改进了一些测试。

然后我们分析了 Android 测试中一个热门话题;在开发主机 JVM 上进行测试,优化并显著减少运行测试所需的时间。当我们把测试驱动开发应用到我们的流程中时,这是非常可取的。在这个范围内,我们分析了 Robolectric,并创建了一些测试作为演示,让你开始掌握这些技术。

为了完善我们的知识,我们了解了 Fest 和一些餐具,它们可以帮助我们进行更有表现力的测试,改进反馈,并使整个测试套件更加强大。

我们已经到达了通过 Android 测试的可用的方法和工具的旅程的终点。你现在应该为将这些应用到您自己的项目中做好更充分的准备。一旦开始使用它们,效果就会立即显现。

最后,我希望您阅读这本书的乐趣与我写作它时一样多。

祝测试愉快!

posted @ 2024-05-23 11:08  绝不原创的飞龙  阅读(14)  评论(0编辑  收藏  举报