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

36 阅读1小时+

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

原文: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将运行所有带有此注解的测试。
@FlakyTestInstrumentationTestCase类的测试方法上使用此注解。当存在此注解时,如果测试失败,将重新执行测试方法。执行的总次数由容差指定,默认为 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 命令接受除了前面提到的<名称, 值>对之外的其它对:

名称
debugtrue。在代码中设置断点。
package这是测试应用中一个或多个完全限定包的名称。
class一个由测试运行器执行的完全限定测试用例类。可选地,这可以包括由哈希(#)与类名分隔的测试方法名称。
coveragetrue。运行 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 的强大功能。

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