安卓应用测试指南-全-
安卓应用测试指南(全)
原文:
zh.annas-archive.org/md5/42ce5f8b2b9113c00daf25a8577a1945译者:飞龙
前言
无论您在 Android 设计上投入多少时间,或者编程时多么小心,错误是不可避免的,bug 将会出现。本书将帮助您最小化这些错误对您的 Android 项目的影响,并提高您的开发效率。它将向您展示容易避免的问题,以帮助您快速进入测试阶段。
《Android 应用程序测试指南》是第一本也是唯一一本提供对最常用技术、框架和工具的实用介绍的书籍,旨在提高您的 Android 应用程序开发。清晰的、分步的指导说明如何为您的应用程序编写测试,并使用各种方法确保质量控制。
作者将应用测试技术应用于实际项目的经验使他能够分享创建专业 Android 应用程序的见解。
本书首先介绍了测试驱动开发(Test Driven Development),这是软件开发过程中的一个敏捷组件,也是一种早期解决 bug 的技术。从应用于示例项目的最基本单元测试到更复杂的性能测试,本书以食谱为基础的方法,详细描述了在 Android 测试领域最广泛使用的技巧。
作者在其职业生涯中在多个开发项目上拥有丰富的经验。所有这些研究和知识都有助于创建一本对任何在 Android 测试领域导航的开发者都非常有用的书籍。
本书涵盖内容
第一章,测试入门介绍了不同类型的测试及其在软件开发项目中的适用性,特别是 Android。
第二章,Android 上的测试涵盖了在 Android 平台上的测试、单元测试和 JUnit、创建 Android 测试项目以及运行测试。
第三章,Android SDK 的构建块开始深入挖掘,以识别可用于创建测试的构建块。它涵盖了断言(Assertions)、TouchUtils(用于测试用户界面)、模拟对象(Mock objects)、Instrumentation 和具有 UML 图的 TestCase 类层次结构。
第四章,测试驱动开发介绍了测试驱动开发的学科。它从一般复习开始,然后转向与 Android 平台紧密相关的概念和技术。这是一章代码密集的章节。
第五章,Android 测试环境提供了运行测试的不同条件。它从创建 Android 虚拟设备(AVD)开始,为测试中的应用提供不同的条件和配置,然后使用可用的选项运行测试。最后,它介绍了使用猴子作为生成用于测试的模拟事件的方法。
第六章,行为驱动开发介绍了行为驱动开发以及一些概念,例如使用通用词汇来表达测试以及将业务参与者纳入软件开发项目。
第七章,测试食谱提供了在应用之前描述的学科和技术时可能遇到的不同常见情况的实际示例。这些示例以食谱风格呈现,以便您可以根据自己的项目进行修改和使用。这些食谱涵盖了 Android 单元测试、活动、应用程序、数据库和内容提供者、本地和远程服务、用户界面、异常、解析器和内存泄漏。
第八章,持续集成介绍了这种旨在通过持续应用集成和测试来提高软件质量和减少集成更改所需时间的敏捷技术。
第九章,性能测试介绍了一系列与基准测试和配置文件相关的概念,从传统的日志语句方法到创建 Android 性能测试和使用剖析工具。本章还介绍了 Caliper 来创建微基准测试。
第十章,替代测试策略涵盖了从源代码构建 Android、使用 EMMA、Robotium 进行代码覆盖率测试、在主机上进行测试以及使用 Robolectric。
您需要这本书的什么
首先,您需要实际的 Android 开发经验,因为我们不会涵盖基础知识。假设您已经有一些 Android 应用程序,或者至少您熟悉 Android 开发指南(developer.android.com/guide/index.html)中广泛描述的主题。此外,跟随一些示例代码项目(developer.android.com/resources/browser.html?tag=sample)也非常有帮助,可能从 API 演示开始,然后转向其他更复杂的话题。这样,您将最大限度地利用这本书。
为了能够跟随不同章节中的示例,您需要安装一套通用的软件和工具,以及每个章节中特别描述的几个其他组件,包括它们的相应下载位置。
所有示例均基于:
-
Ubuntu 10.04.2 LTS (lucid) 64 位,完全更新
-
Java SE 版本 "1.6.0_24" (构建 1.6.0_24-b07)
-
Android SDK 工具,修订版 11
-
Android SDK 平台工具,修订版 4
-
SDK 平台 Android 2.3.1,API 9,修订版 2
-
Android 兼容性包,修订版 2
-
为 Java 开发者提供的 Eclipse IDE,版本:Helios Service Release 1 (3.6.1)
-
Android 开发工具包,版本:10.0.1.v201103111512-110841
-
Dalvik 调试监控服务,版本:10.0.1.v201103111512-110841
-
Apache Ant 版本 1.8.0,编译于 2010 年 4 月 9 日
-
Git 版本 1.7.0.4
-
Subversion 版本 1.6.6 (r40053),编译于 2011 年 3 月 23 日,13:08:34
书中展示的 UML 图形是使用 BOUML 版本 4.21 创建的。
截图使用 Shutter 0.86.3 拍摄和编辑。
手稿使用 OpenOffice.org 3.2.1 进行编辑。
本书面向对象
如果您是希望测试您的应用程序或优化应用程序开发过程的 Android 开发者,那么这本书适合您。不需要在应用程序测试方面有先前的经验。
惯例
在本书中,您将找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词按以下方式显示:“要调用am命令,我们将使用adb shell命令”。
代码块设置如下:
@VeryImportantTest
public void testOtherStuff() {
fail("Not implemented yet");
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
public class MyFirstProjectTests extends TestCase { public MyFirstProjectTests() {
this("MyFirstProjectTests");
}
任何命令行输入都按以下方式编写:
$ adb shell am instrument -w -e class com.example.aatg.myfirstproject.test.MyFirstProjectTests com.example.aatg.myfirstproject.test/android.test.InstrumentationTestRunner
任何命令行输出都按以下方式编写:
08-10 00:26:11.820: ERROR/AndroidRuntime(510): FATAL EXCEPTION: main
08-10 00:26:11.820: ERROR/AndroidRuntime(510): java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation
新术语和重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“选择测试项目,然后运行 | 运行配置”。
注意
警告或重要说明以如下框的形式出现。
小贴士
小技巧和技巧以如下形式出现。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大收益的标题非常重要。
如要向我们发送一般反馈,请简单地将电子邮件发送到 <feedback@packtpub.com>,并在邮件主题中提及书名。
如果您需要一本书并且希望我们出版,请通过 www.packtpub.com 上的建议标题表单或发送电子邮件到 `suggest@packtpub.com》。
如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南 www.packtpub.com/authors。
客户支持
现在您是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您在 www.PacktPub.com 的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.PacktPub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
错误清单
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问 www.packtpub.com/support,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误得到验证,您的提交将被接受,错误将被上传到我们的网站,或添加到该标题的错误清单部分。任何现有的错误清单都可以通过从 www.packtpub.com/support 选择您的标题来查看。
盗版
在互联网上侵犯版权材料是一个跨所有媒体持续存在的问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何我们作品的非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com> 与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们作者以及为我们带来有价值内容方面的帮助。
询问
如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。
第一章 开始测试
本章介绍了不同类型的测试及其在软件开发项目中的适用性,特别是针对Android的适用性。
我们将避免介绍 Android 和开放手机联盟(www.openhandsetalliance.com),因为它们已经在许多书中有所介绍,而且我倾向于相信,如果您正在阅读一本涵盖这个更高级主题的书,那么您在开始阅读之前就已经开始了 Android 开发。
然而,我们将回顾测试背后的主要概念和技术、框架和工具,以便在 Android 上部署您的测试策略。
简要历史
初始时,当 Android 在 2007 年底推出时,该平台对测试的支持非常有限,对于我们这些习惯于将测试作为与开发过程紧密耦合的组件的人来说,是时候开始开发一些框架和工具来允许这种做法了。
到那时,Android 已经对使用 JUnit(www.JUnit.org)进行单元测试提供了一些基本支持,但它并没有得到完全支持,甚至文档也很少。
在编写自己的库和工具的过程中,我发现 Phil Smith 的Positron(最初位于code.google.com/p/android-positron并现在更名为并迁移到code.google.com/p/autoandroid)是一个开源库,非常适合支持 Android 上的测试,因此我决定扩展他的优秀工作,并带来一些新的和缺失的部分。
测试自动化的某些方面没有被包括在内,我开始了一个补充项目来填补这个空白,因此它被命名为Electron。尽管正电子是电子的反粒子,并且它们在碰撞时会湮灭,但请相信这并不是初衷,而是更多地考虑到了能量的守恒以及一些可见光和波的产生。
后来,Electron 于 2008 年初参加了第一次Android 开发挑战赛(ADC1),尽管它在某些类别中获得了相当不错的分数,但在那次比赛中框架并没有得到应有的位置。如果您对 Android 测试的起源感兴趣,请在我的个人博客中查找一些已发布的文章和视频(dtmilano.blogspot.com/search/label/electron)。
到那时,单元测试可以在 Eclipse 上运行。然而,测试并不是在真实的目标设备上进行的,而是在本地开发计算机上的 JVM 上进行的。
Google 还通过Instrumentation类提供了应用程序的测试代码。当运行一个启用测试的应用程序时,这个类在你任何应用程序代码之前被实例化,让你能够监控系统与应用程序之间的所有交互。一个Instrumentation实现通过AndroidManifest.xml文件描述给系统。
在 Android 开发演变的早期阶段,我开始在我的博客中撰写一些文章,填补测试的空白。这本书是对那项工作的有序和可理解方式的演变和完成,以悖论的方式让你被 Android 测试的 bug 所咬。
软件 bug
无论你多么努力,投入多少时间在设计上,甚至编程时多么小心,错误是不可避免的,bug 总会出现。
Bug 和软件开发密切相关。然而,用来描述缺陷、错误或错误的术语bug在计算机发明之前几十年就已经在硬件工程中使用了。尽管有关于哈佛大学 Mark II 操作员创造“bug”一词的故事,托马斯·爱迪生在 1878 年给 Puskás Tivadar 的信中写道,这表明了术语的早期采用:
“在我的所有发明中,第一步是直觉,然后是一阵爆发,接着困难出现——这个玩意儿开始出问题,然后就是所谓的‘bug’——就像这样的小错误和困难——就会显现出来,在达到商业成功或失败之前,需要数月的紧张观察、研究和劳动。”
Bug 如何严重影响你的项目
Bug 会影响你的软件开发项目的许多方面,而且很明显,你越早在这个过程中找到并消除它们,情况就越好。无论是你开发一个简单的应用发布到 Android 市场,为运营商重新设计 Android 体验,还是为设备制造商创建一个定制的 Android 版本,bug 都会延误你的发货,并让你付出代价。
在所有软件开发方法和技巧中,测试驱动开发(Test Driven Development,TDD)作为软件开发过程中的一个敏捷组件,很可能是那个迫使你在开发早期就面对你的 bug 的方法,因此你也很可能会在早期解决更多的问题。
此外,与那些在开发周期结束时才编写测试的最佳情况相比,在项目中使用这种技术可以明显提高生产力。如果你参与过移动行业的软件开发,你会有理由相信,在这个阶段,所有的匆忙都不会发生。这很有趣,因为通常,这种匆忙是为了解决本可以避免的问题。
在美国国家标准与技术研究院(美国)于 2002 年进行的一项研究中,报告称软件错误每年给经济造成 595 亿美元。如果进行更好的软件测试,其中超过三分之一的成本可以避免。
但请,不要误解这个信息。软件开发中没有“银弹”,将使您提高项目生产力和可管理性的因素是应用这些方法和技术的纪律,以保持控制。
为什么、什么、如何以及何时进行测试
您应该明白,早期发现错误可以节省大量的项目资源并降低软件维护成本。这是为您的开发项目编写软件测试的最佳理由。生产力的提升将很快显现。
此外,编写测试将使您对需求和要解决的问题有更深入的理解。如果您不理解某个软件,将无法为其编写测试。
这也是编写测试以清楚地理解遗留或第三方代码,并能够自信地更改或更新它的原因。
您的测试覆盖的代码越多,您对发现隐藏错误的期望就越高。
如果在这次覆盖率分析中您发现代码的某些区域没有被测试到,应该添加额外的测试来覆盖这些代码。
这种技术需要特殊的仪器 Android 构建来收集探针数据,并且必须禁用于任何发布代码,因为对性能的影响可能会严重影响应用程序的行为。
为了填补这一空白,请访问 EMMA (emma.sourceforge.net/),这是一个开源的工具包,用于测量和报告 Java 代码覆盖率,并且可以离线对类进行覆盖率测试。它支持各种覆盖率类型:
-
类
-
方法
-
行
-
基本块
覆盖率报告也可以以不同的输出格式获得。EMMA 在一定程度上受到 Android 框架的支持,并且可以构建 Android 的 EMMA 仪器版本。
我们将在第十章 “替代测试策略”中分析 EMMA 在 Android 上的使用,以指导我们实现代码的全面测试覆盖。
这张截图显示了 EMMA 代码覆盖率报告在 Eclipse 编辑器中的显示方式,显示了代码已被测试的绿色线条,前提是已安装相应的插件。

不幸的是,该插件目前还不支持 Android 测试,因此现在您只能用它来运行 JUnit 测试。Android 覆盖率分析报告仅通过 HTML 提供。
测试应该自动化,并且每次你对代码进行更改或添加时,都应该运行一些或所有测试,以确保所有先前条件仍然满足,并且新代码仍然满足预期的测试。
这就引出了持续集成的介绍,它将在第八章持续集成中详细讨论。这依赖于测试和构建过程的自动化。
如果你没有使用自动化测试,实际上不可能将持续集成作为开发过程的一部分,并且很难确保更改不会破坏现有代码。
需要测试的内容
严格来说,你应该测试你代码中的每一个语句,但这也取决于不同的标准,可以简化为测试执行路径或只是某些方法。通常,没有必要测试那些无法破坏的内容,例如,通常没有必要测试获取器和设置器,因为你可能不会在自己的代码上测试 Java 编译器,编译器已经执行了自己的测试。
除了你应该测试的功能区域之外,还有一些特定的 Android 应用程序区域你应该考虑。我们将在以下章节中探讨这些内容。
活动生命周期事件
你应该测试你的活动是否正确处理生命周期事件。
如果你的活动应该在 onPause() 或 onDestroy() 事件期间保存其状态,并在稍后通过 onCreate(Bundle savedInstanceState) 恢复它,你应该能够重现和测试这些条件,并验证状态是否已正确保存和恢复。
配置更改事件也应该进行测试,因为这些事件中的一些会导致当前 Activity 重新创建,你应该测试正确处理事件以及新创建的 Activity 是否保留了先前状态。配置更改甚至由旋转事件触发,因此你应该测试你的应用程序处理这些情况的能力。
数据库和文件系统操作
应该测试数据库和文件系统操作是否得到正确处理。这些操作应该在较低的系统级别单独测试,在较高级别通过 ContentProviders 测试,以及从应用程序本身进行测试。
为了单独测试这些组件,Android 在 android.test.mock 包中提供了一些模拟对象。
设备的物理特性
在交付你的应用程序之前,你应该确保所有可以运行该应用程序的不同设备都得到支持,或者至少你应该检测这种情况并采取适当的措施。
在设备的其他特性中,你可能还会发现你应该测试:
-
网络能力
-
屏幕密度
-
屏幕分辨率
-
屏幕尺寸
-
传感器的可用性
-
键盘和其他输入设备
-
GPS
-
外部存储
在这方面,Android 虚拟设备扮演着重要的角色,因为实际上不可能访问所有可能的设备及其所有可能的功能组合,但您可以为几乎所有情况配置 AVD。然而,如前所述,请将您的最终测试保留在实际设备上,以便真实用户运行应用程序以了解其行为。
测试类型
测试可以在开发过程的任何时间点实现,具体取决于采用的方法。然而,我们将促进在开发努力的早期阶段进行测试,甚至在定义完整的需求和开始编码过程之前。
根据被测试的对象类型,有多种测试类型可供选择。无论其类型如何,测试都应该验证一个条件,并将此评估的结果作为一个单一的布尔值返回,以指示成功或失败。
单元测试
单元测试是由程序员为程序员编写的软件测试,它们应该隔离被测试的组件,并且能够以可重复的方式进行测试。这就是为什么单元测试和模拟对象通常放在一起。您使用模拟对象来隔离单元与其依赖项,以监控交互,并且能够重复测试任意次数。例如,如果您的测试从数据库中删除了一些数据,您可能不希望数据实际上被删除,并且在下次运行测试时找不到。
JUnit 是 Android 上单元测试的事实标准。它是一个简单的开源框架,用于自动化单元测试,最初由 Erich Gamma 和 Kent Beck 编写。
Android(直到 Android 2.3 Gingerbread)使用 JUnit 3。这个版本不使用注解,并使用反射来检测测试。
一个典型的 JUnit 测试看起来可能像这样(实际的测试被突出显示):
/**
* Android Application Testing Guide
*/
package com.example.aatg.test;
import JUnit.framework.TestCase;
/**
* @author diego
*/
public class MyUnitTests extends TestCase {
private int mFixture;
/**
* @param name test name
*/
public MyUnitTests(String name) {
super(name);
}
/* (non-Javadoc)
* @see JUnit.framework.TestCase#setUp()
*/
protected void setUp() throws Exception {
super.setUp();
mFixture = 1234;
}
/* (non-Javadoc)
* @see JUnit.framework.TestCase#tearDown()
*/
protected void tearDown() throws Exception {
super.tearDown();
}
/**
* Preconditions
*/ public void testPreconditions() {
}
/**
* Test method
*/ public void testSomething() {
fail("Not implemented yet");
}
}
小贴士
下载示例代码
您可以从您在www.PacktPub.com的账户中下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.PacktPub.com/support并注册,以便将文件直接通过电子邮件发送给您。
以下章节将详细解释构建我们的测试用例的组件。
测试用例
测试用例是众所周知的基线状态,用于运行测试,并由所有测试用例共享,因此在测试设计中起着基本的作用。
通常它被实现为一组成员变量,并且遵循 Android 约定,它们的名称将以 m 开头,例如 mActivity。然而,它也可以包含外部数据,例如数据库中的特定条目或文件系统中的文件。
setUp() 方法
此方法用于初始化测试用例。
覆盖它,你有机会创建将被测试使用的对象和初始化字段。值得注意的是,这种设置是在每次测试之前发生的。
tearDown() 方法
此方法用于最终确定测试环境。
覆盖它,你可以释放初始化或测试中使用的资源。再次强调,此方法是在每次测试之后调用的。
例如,你可以在这里释放数据库或网络连接。
JUnit 是这样设计的,即整个测试实例树在一次遍历中构建,然后在第二次遍历中执行测试。因此,测试运行器在整个测试执行期间都持有对所有 Test 实例的强引用。这意味着对于非常庞大且非常长的测试运行,其中包含许多 Test 实例,在整个测试运行结束之前,可能没有任何测试被垃圾回收。这在 Android 和在有限设备上进行测试时尤为重要,因为某些测试可能不是由于固有的问题,而是因为运行应用程序及其测试所需的内存超过了设备的限制。
因此,如果你在测试中分配外部或有限的资源,例如 Services 或 ContentProviders,你负责释放这些资源。例如,在 tearDown() 方法中显式地将对象设置为 null,允许它在整个测试运行结束之前被垃圾回收。
测试前提条件
通常没有方法可以测试前提条件,因为测试是通过反射发现的,它们的顺序可能会变化。因此,通常的做法是创建一个 testPreconditions() 方法来测试前提条件。尽管不能保证这个测试将以任何特定的顺序被调用,但为了组织上的目的,将这个测试和前提条件放在一起是一个好的做法。
实际测试
所有以 test 开头且名称为 public void 的方法都将被视为测试。与 JUnit 4 不同,JUnit 3 不使用注解来发现测试,而是使用反射来查找它们的名称。Android 测试框架上提供了一些注解,如 @SmallTest, @MediumTest 和 @LargeTest,但它们并不能将简单的方法转换为测试。相反,它们将它们组织成不同的类别。最终,你将能够使用测试运行器运行单个类别的测试。
按照惯例,使用名词和正在测试的条件来描述性地命名测试。
例如:testValues(), testConversionError(), testConversionToString() 都是有效的测试名称。
测试异常和错误值,而不仅仅是测试正面案例。
在测试执行过程中,某些条件、副作用或方法返回值应与预期进行比较。为了简化这些操作,JUnit 提供了一套完整的 assert* 方法,用于比较测试的预期结果与运行后的实际结果。如果条件不满足,则它们会抛出异常。然后测试运行器处理这些异常并显示结果。
这些方法支持不同的参数,包括:
-
assertEquals() -
assertFalse() -
assertNotNull() -
assertNotSame() -
assertNull() -
assertSame() -
assertTrue() -
fail()
除了这些 JUnit 断言方法之外,Android 还通过两个专用类扩展了 Assert,提供了额外的测试:
-
MoreAsserts -
ViewAsserts
模拟对象
模拟对象是代替调用真实领域对象使用的模仿对象,以便在隔离状态下测试单元。
通常,这样做是为了确保调用正确的方法,但正如提到的,这也有助于将测试与周围环境隔离开来,并使您能够独立且可重复地运行它们。
Android 测试框架支持几个模拟对象,当您编写测试时,您会发现它们非常有用,但您需要提供一些依赖项才能编译测试。
Android 测试框架在 android.test.mock 包中提供了几个类:
-
MockApplication -
MockContentProvider -
MockContentResolver -
MockContext -
MockCursor -
MockDialogInterface -
MockPackageManager -
MockResources
几乎可以创建与您的 Activity 交互的任何平台组件,通过实例化这些类之一。
然而,它们并不是真正的实现,而是存根,其中每个方法都会生成一个 UnsupportedOperationException,您可以扩展它们来创建真正的模拟对象。
UI 测试
最后,如果您的测试涉及 UI 组件,应特别考虑。如您所知,在 Android 中,只有主线程允许更改 UI。因此,使用特殊注解 @UIThreadTest 来指示特定测试应在该线程上运行,并且将具有更改 UI 的能力。另一方面,如果您只想在 UI 线程上运行测试的一部分,您可以使用 Activity.runOnUiThread(Runnable r) 方法,提供包含测试指令的相应 Runnable。
还提供了一个辅助类 TouchUtils,以帮助创建 UI 测试,允许生成发送到视图的事件,例如:
-
click
-
drag
-
long click
-
scroll
-
tap
-
touch
通过这些方法,您实际上可以从测试中远程控制您的应用程序。
Eclipse 和其他 IDE 支持
JUnit 完全由 Eclipse 支持,Android ADT 插件允许您创建 Android 测试项目。此外,您可以在不离开 IDE 的情况下运行测试并分析结果。
这也提供了一种更微妙的优势;能够从 Eclipse 运行测试允许您调试表现不正确的测试。
在屏幕截图中,我们可以看到 Eclipse 以 20.008 秒的时间运行了 18 个测试,其中检测到 0 个错误 和 0 个失败。每个测试的名称及其持续时间也显示出来。如果有失败,失败跟踪将显示相关信息。

其他 IDE,如 ItelliJ 和 Netbeans,在某种程度上集成了 Android 开发,但它们并未官方支持。
即使您不在 IDE 中开发,您也可以找到使用 ant 运行测试的支持(如果您不熟悉这个工具,请检查 ant.apache.org)。此设置是通过 android 命令使用子命令 create test-project 完成的,如帮助文本所述:
$ android --help create test-project
Usage:
android [global options] create test-project [action options]
Global options:
-v --verbose Verbose mode: errors, warnings and informational messages are printed.
-h --help Help on a specific command.
-s --silent Silent mode: only errors are printed out.
Action "create test-project":
Creates a new Android project for a test package.
Options:
-p --path The new project's directory [required]
-m --main Path to directory of the app under test, relative to the test project directory [required]
-n --name Project name
根据帮助信息,您至少应提供项目路径 (--path) 和主项目或测试项目的路径 (--main)。
集成测试
集成测试旨在测试各个组件协同工作的方式。那些已经独立进行单元测试的模块现在被组合在一起以测试集成。
通常,Android 活动(Activities)需要与系统基础设施集成,以便能够运行。它们需要 ActivityManager 提供的活动生命周期,以及访问资源、文件系统数据库。
同样的标准适用于需要与其他系统部分交互以实现其功能的其他 Android 组件,如 Services 或 ContentProviders。
在所有这些情况下,Android 测试框架都提供了专门的测试,以简化这些组件的测试创建。
功能或验收测试
在敏捷软件开发中,功能或验收测试通常由业务和质量管理(QA)人员创建,并使用业务领域语言表达。这些是高级测试,用于测试用户需求或功能的完整性和正确性。它们最好通过业务客户、业务分析师、QA、测试人员和开发人员之间的协作来创建。然而,业务客户(产品所有者)是这些测试的主要所有者。
一些框架和工具可以帮助在这个领域,最著名的是 FitNesse (www.fitnesse.org),它可以很容易地集成到 Android 开发过程中,并允许您创建验收测试并检查其结果。
注意
还请检查 Fit,fit.c2.com 和 Slim (Simple List Invocation Method),fitnesse.org/FitNesse.UserGuide.SliM,作为 Fit 的替代方案。

最近,一种名为 行为驱动开发 的新趋势已经获得了一些人气,并且可以用非常简短的方式来理解,即测试驱动开发的演变。它的目的是为了在业务和技术人员之间提供一种共同的语言,以便增加相互理解。
行为驱动开发可以表达为一个基于三个原则的活动框架(更多信息可以在[http://behaviour-driven.org]找到):](http://behaviour-driven.org))
-
业务和技术应该以相同的方式引用相同的系统。
-
任何系统都应该有一个对业务有可识别、可验证的价值。
-
前期的分析、设计和规划都有递减的回报。
为了应用这些原则,业务人员通常参与以高级语言编写测试用例场景,并使用一些工具,例如 jbehave (jbehave.org)。在以下示例中,这些场景被转换为代码,以编程语言表达相同的测试场景。
测试用例场景
作为这种技术的示例,这里有一个过于简化的例子。
这个场景是:
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
}
@When("I enter $celsius into Celsius field")
public void setCelsius(int celsius) {
mCelsius= celsius;
}
@Then("I obtain $fahrenheit in Fahrenheit field")
public void testCelsiusToFahrenheit(int fahrenheit) {
assertEquals(fahrenheit, TemperatureConverter.celsiusToFahrenheit (mCelsius));
}
性能测试
性能测试以可重复的方式衡量组件的性能特征。如果应用程序的某个部分需要性能改进,最佳方法是引入更改前后测量性能。
如众所周知,过早的优化弊大于利,因此最好清楚地了解您的更改对整体性能的影响。
在 Android 2.2 中引入的 Dalvik JIT 编译器改变了在 Android 开发中广泛使用的某些优化模式。如今,Android 开发者网站上关于性能改进的任何建议都有性能测试的支持。
系统测试
系统作为一个整体进行测试,并锻炼组件、软件和硬件之间的交互。通常,系统测试包括额外的测试类别,如:
-
GUI 测试
-
烟雾测试
-
性能测试
-
安装测试
Android 测试框架
Android 提供了一个非常先进的测试框架,它扩展了行业标准 JUnit,并增加了适合实现我们之前提到的所有测试策略和类型的特定功能。在某些情况下,需要额外的工具,但这些工具的集成在大多数情况下都是简单直接的。
Android 测试环境的关键特性包括:
-
Android 对 JUnit 框架的扩展,提供了访问 Android 系统对象的功能。
-
一个仪器化框架,允许测试控制和检查应用程序。
-
常用 Android 系统对象的模拟版本。
-
运行单个测试或测试套件的工具,带或不带仪器。
-
支持在 Eclipse 的 ADT 插件和命令行中管理测试和测试项目。
仪器化
instrumentation 框架是测试框架的基础。Instrumentation 控制被测试的应用程序,并允许注入应用程序运行所需的模拟组件。例如,你可以在应用程序启动之前创建模拟的 Context,并让应用程序使用它们。
使用这种方法,可以控制应用程序与周围环境的所有交互。你还可以在受限环境中隔离你的应用程序,以便能够预测结果,强制某些方法返回的值,或者对 ContentProvider、数据库或甚至文件系统内容进行模拟。
一个标准的 Android 项目,其测试在一个相关项目中,通常具有相同的项目名称,但以 Test 结尾。在这个 Test 项目内部,AndroidManifest.xml 声明了 Instrumentation。
作为一个示例,假设你的项目有一个如下所示的清单:
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.example.aatg.sample"
android:versionCode="1"
android:versionName="1.0">
<application android:icon="@drawable/icon"
android:label="@string/app_name">
<activity android:name=".SampleActivity"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name= "android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
<uses-sdk android:minSdkVersion="7" />
</manifest>
在这种情况下,相关 Test 项目将具有以下 AndroidManifest.xml:
<?xml version="1.0" encoding="utf-8"?>
<manifest
package="com.example.aatg.sample.test"
android:versionCode="1" android:versionName="1.0">
<application android:icon="@drawable/icon"
android:label="@string/app_name"> <uses-library android:name="android.test.runner" />
</application>
<uses-sdk android:minSdkVersion="7" /> <instrumentation
android:targetPackage="com.example.aatg.sample"
android:name="android.test.InstrumentationTestRunner"
android:label="Sample Tests" />
<uses-permission android:name=" android.permission.INJECT_EVENTS" />
</manifest>
在这里,Instrumentation 包与主应用程序相同,只是添加了 .test 后缀。
然后,声明 Instrumentation,指定目标包和测试运行器,在这种情况下是默认的自定义运行器 android.test.InstrumentationTestRunner。
注意,被测试的应用程序和测试都是 Android 应用程序,它们安装了相应的 APK。内部,它们将共享同一个进程,因此可以访问相同的功能集。
当你运行测试应用程序时,活动管理器 (developer.android.com/intl/de/reference/android/app/ActivityManager.html) 使用 instrumentation 框架来启动和控制测试运行器,该运行器反过来使用 instrumentation 来关闭主应用程序的任何运行实例,然后启动测试应用程序,并在同一个进程中启动主应用程序。这允许测试应用程序的各个方面直接与主应用程序协同工作。
测试目标
在你的开发项目演变过程中,你的测试会针对不同的设备。从在模拟器上测试的简单性、灵活性和速度,到不可避免地在特定设备上进行的最终测试,你应该能够在所有这些设备上运行。
也有一些中间情况,比如在开发计算机上的本地 JVM 虚拟机上运行测试,或者在 Dalvik 虚拟机或 Activity 上,具体取决于情况。
每种情况都有其优缺点,但好消息是,你可以使用所有这些替代方案来运行你的测试。
模拟器可能是最强大的目标,因为你可以从其配置中修改几乎每一个参数来模拟不同的测试条件。最终,你的应用程序应该能够处理所有这些情况,因此提前发现问题比应用程序交付后发现问题要好得多。
真实设备对于性能测试是必需的,因为从模拟设备中推断性能测量值有些困难。你只有在使用真实设备时才能发现真实用户体验。在交付应用程序之前,应该测试渲染、滚动、抛掷和其他情况。
摘要
我们回顾了测试的一般概念,特别是 Android 的测试。掌握这些知识将使我们开始我们的旅程,并开始在我们软件开发项目中利用测试的好处。
到目前为止,我们已经访问了以下主题:
-
我们回顾了 Android 测试的早期阶段,并提到了一些创建了当前替代方案的框架。
-
我们简要分析了测试背后的原因以及为什么、是什么、如何和何时进行测试。此外,从现在开始,我们将专注于探索如何进行测试,因为我们假设你已经被提出的论点所说服。
-
我们列举了你在项目中可能需要的不同和最常见的测试类型,描述了一些我们可以依赖的测试工具,并提供了一个 JUnit 单元测试的入门示例,以便更好地理解我们在讨论的内容。
我们还从 Android 的角度分析了这些技术,并提到了使用 Instrumentation 来运行我们的 Android 测试。
现在,我们将开始详细分析所提到的技术、框架和工具,以及它们的使用示例。
第二章. 在 Android 上进行测试
现在我们已经介绍了测试的原因和基本概念,是时候将它们付诸实践了。
在这一章中,我们将涵盖:
-
在 Android 上进行测试
-
单元测试和 JUnit
-
创建 Android 测试项目
-
运行测试
我们将创建一个简单的 Android 主项目及其配套的测试项目。主项目将几乎是空的,只是突出测试组件。
根据我的个人经验,我建议这一章对新开发者(没有 Android 测试经验)很有用。如果你对 Android 项目有一些经验,并且已经使用测试技术对它们进行过测试,你可能会将这一章作为复习或概念重申。
虽然这不是强制性的,但最佳实践规定测试应该存在于一个单独的相关项目中。现在 Android ADP 插件支持这一功能,但这并不总是如此。一些时间以前,我发表了一篇文章(dtmilano.blogspot.com/2008/11/android-testing-on-android-platf.html),描述了一种手动维护两个相关项目的方法——一个主项目和测试项目。
这个决定的优点可能不会立即显现,但其中我们可以列出:
-
测试代码可以从生产构建中轻松剥离,因为它不包括在主项目中,因此不在 APK 中
-
通过 Dev Tools 中的 Instrumentation 选项简化在模拟器上运行测试的方式
-
对于大型项目,如果它们是分开的,部署主包和测试包将花费更少的时间
-
鼓励在类似项目中重用代码
JUnit
在上一章中,我们已经对 JUnit 进行了概述,所以这里不需要介绍。值得一提的是,JUnit 测试框架是 Android 测试项目的默认选项,它由 Eclipse、Android ADT 插件以及 Ant 支持,即使你不是在 IDE 中开发。
因此,你可以自由地为每种情况选择最佳替代方案。
以下的大部分示例将基于 Eclipse,因为它是最常见的选项。所以,让我们打开 Eclipse,不进行任何前言。
创建 Android 主项目
我们将创建一个新的 Android 项目。这是通过 Eclipse 菜单文件 | 新建 | 项目... | Android | Android 项目来完成的。
在这个特定的情况下,我们使用以下值作为所需组件名称:
| 项目名称: | MyFirstProject |
|---|---|
| 构建目标: | Android 2.3.1 |
| 应用名称: | 我的第一个项目 |
| 包名: | com.example.aatg.myfirstproject |
| 创建活动: | MyFirstProjectActivity |
| 最小 SDK 版本: | 9 |
输入这些值后,你的项目创建对话框将看起来像这样:

创建 Android 测试项目
按下下一步按钮,将显示 Android 测试项目创建对话框。注意,一些值已经根据主项目中选择的相应值预先选择了。
注意
或者,要为现有的 Android 项目创建测试项目,您可以选中主项目,然后选择Android 工具 | 创建测试项目。在 测试目标 中选择现有项目的名称,所需值将自动填写。
这张图片显示了输入相应值后的 Android 测试项目创建对话框。所有值都已为我们填写,我们只需点击完成:

包资源管理器
创建了两个项目后,我们的包资源管理器应该看起来像下面的图片。我们可以注意到存在两个相关联的项目,每个项目都有独立的一组组件和项目属性。

现在我们已经建立了基本的基础设施,是时候开始添加一些测试了。
目前没有东西可以测试,但因为我们正在设置测试驱动开发学科的基石,所以我们添加了一个虚拟测试,以便熟悉这项技术。
MyFirstProjectTest项目的src文件夹是添加测试的完美位置。这不是强制性的,但是一种良好的实践。包应该与被测试组件的相应包相同。
目前,我们不是专注于测试,而是关注测试的概念和位置。
创建测试用例
如前所述,我们正在 Test 项目的src文件夹中创建测试用例。
在这个特定的情况下,我们正在使用 JUnit TestCase 创建单元测试。Eclipse 提供了一个向导来帮助我们(文件 | 新建... | JUnit 测试用例)。
我们正在选择主项目中的 Activity 作为要测试的类;然而,在这个示例中这并不相关。
创建测试用例时,我们应该输入以下值:
| JUnit: | JUnit 3 |
|---|---|
| 源文件夹: | MyFirstProjectTest/src |
| 包: | com.example.aatg.myfirstproject.test |
| 名称: | MyFirstProjectTests |
| 超类: | junit.framework.TestCase |
| 您想创建哪些方法存根? | setUp()、tearDown()、构造函数 |
| 要测试的类: | com.example.aatg.myfirstproject.MyFirstProjectActivity |
注意
严格来说,我们可以不选择setUp()、tearDown()和构造函数选项,我们创建的基本测试将不会受到影响,但在这里我们描述的是最通用的实践,我们将在许多实际场景中发现这些方法是必需的。
输入所有必需的值后,我们的 JUnit 测试用例创建对话框将看起来像这样:

我们测试的基本基础设施已经就绪;剩下的是添加一个虚拟测试来验证一切是否按预期工作。
Eclipse 还提供了一种为测试方法创建存根的方法。按下下一步 >后,将显示以下对话框,您可以选择要为哪些测试方法生成存根:

这些存根方法在某些情况下可能很有用,但您必须考虑测试应该是行为驱动而不是方法驱动的。
现在我们有了测试用例模板,下一步是开始完成它以满足我们的需求。要做到这一点,打开最近创建的用例类,并添加测试testSomething()。作为一个最佳实践,将测试添加到类的末尾。
我们应该有类似这样的内容:
/**
*
*/
package com.example.aatg.myfirstproject.test;
import android.test.suitebuilder.annotation.SmallTest;
import junit.framework.TestCase;
/**
* @author diego
*
*/
public class MyFirstProjectTests extends TestCase { public MyFirstProjectTests() {
this("MyFirstProjectTests");
}
/**
* @param name
*/
public MyFirstProjectTests(String name) {
super(name);
}
/* (non-Javadoc)
* @see junit.framework.TestCase#setUp()
*/
protected void setUp() throws Exception {
super.setUp();
}
/* (non-Javadoc)
* @see junit.framework.TestCase#tearDown()
*/
protected void tearDown() throws Exception {
super.tearDown();
} @SmallTest
public void testSomething() {
fail("Not implemented yet");
}
}
这个测试将始终失败,显示消息:尚未实现。为了做到这一点,我们使用了junit.framework.Assert类中的fail方法,该方法使用给定消息使测试失败。
注意
需要一个无参构造函数来从命令行运行特定的测试,如稍后使用am instrumentation解释的那样。
特殊方法
下表描述了在测试用例类中找到的特殊方法:
| 方法 | 描述 |
|---|---|
setUp |
设置固定装置。例如,打开网络连接或创建可能被测试需要的全局对象。在执行测试之前调用此方法。在这种情况下,我们只调用超类方法。有关详细信息,请参阅第一章,开始测试。 |
tearDown |
断开固定装置。例如,关闭网络连接。在执行测试之后调用此方法。在这种情况下,我们只调用超类方法。有关详细信息,请参阅第一章,开始测试。 |
testSomething |
一个简单的测试。为了被 JUnit 3 通过反射发现,测试方法应该以单词test开头。方法名的其余部分应清楚地标识要测试的功能。 |
测试注解
仔细查看测试定义,您可能会发现我们使用了@MediumTest注解来装饰测试。这是一种组织或分类我们的测试并单独运行它们的方法。
测试还可以使用其他注解,例如:
| 注解 | 描述 |
|---|---|
@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 |
在不应包含在测试套件中的测试类或测试方法上使用此注解。此注解可以在类级别使用,其中该类中的所有方法都不包含在测试套件中,或者在方法级别排除单个方法或一组方法。 |
|---|
现在我们已经设置了测试,是时候运行它们了,这就是我们接下来要做的。
运行测试
运行我们的测试有几种方法,我们将在下面分析它们。
此外,正如前一小节中提到的关于注解的内容,测试可以根据情况分组或分类并一起运行。
从 Eclipse 运行所有测试
如果您已经采用 Eclipse 作为您的开发环境,这可能是一种最简单的方法。这将运行包中的所有测试。
选择测试项目,然后运行方式 | Android JUnit 测试。
如果找不到合适的设备或模拟器,系统将自动启动一个。
然后运行测试,并在 Eclipse DDMS 视图中显示结果,您可能需要手动更改。

还可以在 Eclipse DDMS 视图中获得更详细的结果和执行期间产生的消息:

从 Eclipse 运行单个测试用例
如果需要,您可以从 Eclipse 运行单个测试用例。
选择测试项目,然后运行方式 | 运行配置。
然后创建一个新的配置,在测试下使用以下值:
| 运行单个测试: | 选中 |
|---|---|
| 项目: | MyFirstProjectTest |
| 测试类: | com.example.aatg.myfirstproject.test.MyFirstProjectTests |
当您以常规方式运行时,只有此测试将被执行。在我们的例子中,我们只有一个测试,所以结果将与之前展示的截图相似。
小贴士
在 Eclipse 编辑器中,你可以使用一个快捷键来执行此操作。选择方法名称,你可以按Shift+Alt+X T或右键单击它,然后选择运行方式 | JUnit 测试。
从模拟器运行
模拟器使用的默认系统镜像已安装了开发工具应用程序,提供了几个方便的工具和设置。在这些工具中,我们可以找到一个相当长的列表,如下面的截图所示:

我们现在对测试感兴趣,这是运行我们的测试的方式。此应用程序列出了所有安装的包,这些包在它们的AndroidManifest.xml中定义了instrumentation标签。默认情况下,包使用默认的测试运行器列出,这通常是android.test.InstrumentationTestRunner,如果你有多个包列表,这会成为一个问题。为了解决这个问题,你可以在清单中设置一个可选的标签,在测试标签页下,如下所示:

一旦完成此操作并重新显示仪器列表,我们的包将显示在这个新标签下,我们可以通过选择它来运行测试:

当以这种方式运行测试时,结果可以通过上一节中描述的日志猫查看。
注意
如前所述,如果你没有设置可选的标签,则多个测试将出现在相同的默认标签android.test.InstrumentationTestRunner下。
从命令行运行测试
最后,测试也可以从命令行运行。如果你想要自动化或脚本化这个过程,这很有用。
为了运行测试,我们使用am instrument命令(严格来说,是am命令和instrument子命令),它允许我们通过指定包名和一些其他选项来运行测试。
你可能会想知道am代表什么。它是活动管理器的缩写,是 Android 内部基础设施的主要组件,在启动过程中由系统服务器启动,负责管理活动和它们的生命周期。此外,正如我们所看到的,它还负责活动测试。
am instrument命令的一般用法是:
am instrument [flags] <COMPONENT>
-r: print raw results (otherwise decode REPORT_KEY_STREAMRESULT)
-e <NAME> <VALUE>: set argument <NAME> to <VALUE>
-p <FILE>: write profiling data to <FILE>
-w: wait for instrumentation to finish before returning
此表总结了最常见的选项:
| 选项 | 描述 |
|---|---|
-r |
打印原始结果。用于收集原始性能数据。 |
-e <NAME> <VALUE> |
通过名称设置参数。我们将在稍后检查其用法。这是一个通用的选项参数,允许我们设置<name, value>对。 |
-p <FILE> |
将分析数据写入外部文件。 |
-w |
在退出之前等待测试完成。通常用于命令中,虽然不是强制性的,但非常方便,否则你将无法看到测试结果。 |
要调用am命令,我们将使用adb shell命令,或者如果你已经在模拟器或设备上运行了一个 shell,你可以在 shell 命令提示符下直接发出am命令。
运行所有测试
此命令行将运行所有测试,除了性能测试:
diego@bruce:\~$ adb shell am instrument -w com.example.aatg.myfirstproject.test/android.test.InstrumentationTestRunner
com.example.aatg.myfirstproject.test.MyFirstProjectTests:
Failure in testSomething:
junit.framework.AssertionFailedError: Not implemented yet
at com.example.aatg.myfirstproject.test.MyFirstProjectTests.testSomething(MyFirstProjectTests.java:22)
at java.lang.reflect.Method.invokeNative(Native Method)
at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:169)
at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:154)
at android.test.InstrumentationTestRunner.onStart(InstrumentationTestRunner.java:430)
at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:1447)
Test results for InstrumentationTestRunner=.F
Time: 0.2
FAILURES!!!
Tests run: 1, Failures: 1, Errors: 0
从特定测试用例运行测试
要运行特定测试用例中的所有测试,你可以使用:
diego@bruce:\~$ adb shell am instrument -w -e class com.example.aatg.myfirstproject.test.MyFirstProjectTests com.example.aatg.myfirstproject.test/android.test.InstrumentationTestRunner
通过名称运行特定测试
此外,我们还可以在命令行中指定要运行的特定测试:
diego@bruce:\~$ adb shell am instrument -w -e class com.example.aatg.myfirstproject.test.MyFirstProjectTests\#testSomething com.example.aatg.myfirstproject.test/android.test.InstrumentationTestRunner
此测试不能以这种方式运行,除非我们在测试用例中有一个无参数的构造函数——这就是我们之前添加它的原因。
通过类别运行特定测试
正如我们之前提到的,可以使用注解(测试注解)将测试分组到不同的类别中,并且你可以运行该类别中的所有测试。
可以添加到命令行的以下选项:
-e unit true |
运行所有单元测试。这些测试不是从InstrumentationTestCase派生的(也不是性能测试)。 |
|---|---|
-e func true |
运行所有功能测试。这些测试是从InstrumentationTestCase派生的。 |
-e perf true |
包含性能测试。 |
| `-e size {small | medium |
-e annotation <annotation-name> |
运行带有此注解的测试。此选项与大小选项互斥。 |
在我们的例子中,我们使用@SmallTest注解了测试方法testSomething()。因此,这个测试被认为属于该类别,并且最终在指定测试大小为small时,与其他属于同一类别的测试一起运行。
此命令行将运行所有带有@SmallTest:注解的测试
diego@bruce:\~$ adb shell am instrument -w -e size small com.example.aatg.myfirstproject.test/android.test.InstrumentationTestRunner
创建自定义注解
如果你决定根据不同于大小的其他标准对测试进行排序,可以创建一个自定义注解,然后在命令行中指定它。
例如,如果我们想根据其重要性来安排它们,那么我们可以创建一个注解@VeryImportantTest。
package com.example.aatg.myfirstproject.test;
/**
* Annotation for very important tests.
*
* @author diego
*
*/
public @interface VeryImportantTest {
}
在此之后,我们可以创建另一个测试,并用@VeryImportantTest注解它。
@VeryImportantTest
public void testOtherStuff() {
fail("Not implemented yet");
}
因此,正如我们之前提到的,我们可以在am instrument命令行中包含此注解,以仅运行注解的测试:
diego@bruce:\~$ adb shell am instrument -w -e annotation VeryImportantTest \ com.example.aatg.myfirstproject.test/android.test.InstrumentationTestRunner
运行性能测试
我们将在第九章性能测试中回顾性能测试的详细信息,但在这里我们将介绍am instrument命令的可用选项。
要在测试运行中包含性能测试,你应该添加此命令行选项
-e perf true |
包含性能测试。 |
|---|
干运行
有时你可能只需要知道将要运行哪些测试,而不是实际运行它们。
这是你需要添加到命令行中的选项:
-e log true |
显示要运行的测试而不是运行它们。 |
|---|
这对于编写脚本或构建其他工具很有用。
调试测试
您的测试也可能存在错误;您应该假设这一点。在这种情况下,通常的调试技术适用,例如通过 LogCat 添加消息。
如果需要更复杂的调试技术,您应该将调试器附加到测试运行器。为此,有两种主要替代方案。
第一件事很简单——不离开 Eclipse 的便利性,也不必记住难以记忆的命令行选项。在 Android ADT 插件的最新版本中,添加了调试方式 | Android JUnit 测试选项。因此,您可以在测试中设置断点并使用它。
要切换断点,您可以在编辑器中选择所需的行,然后使用菜单选项运行 | 切换行断点。或者,您可以稍微修改测试代码以等待调试器连接。但请放心,这种更改非常简单。将以下片段添加到构造函数或任何其他您想要调试的测试中。您添加它的位置并不重要,因为调试器会在断点处停止。在这种情况下,我们决定将Debug.waitForDebugger()添加到构造函数中,如下所示:
public class MyFirstProjectTests extends TestCase { private static final boolean DEBUG = true;
public MyFirstProjectTests(String name) {
super(name); if ( DEBUG ) {
Debug.waitForDebugger();
}
}
…
当您像往常一样运行测试,使用运行方式 | Android JUnit 测试时,您可能需要更改视角。

一旦完成,您将进入标准调试视角和会话。
此外,如果您无法或不想更改测试代码,您可以在其中设置断点,并将以下选项传递给am instrument。
-e debug true |
附加到调试器。 |
|---|
一旦开始测试,测试运行器将等待您的调试器附加。
执行此命令行以调试测试:
$ adb shell am instrument -w -e debug true com.example.aatg.myfirstproject.test/android.test.InstrumentationTestRunner
您将在到达第一个断点时等待时看到此行:
com.example.aatg.myfirstproject.test.MyFirstProjectTests:
这将在调试附加后正常继续并退出,您的调试会话也将完成。
其他命令行选项
am instrument命令除了之前提到的<name, value>对之外,还接受其他<name, value>对:
| 名称 | 值 |
|---|---|
| package | 测试应用程序中一个或多个包的完全限定包名。多个值由逗号(,)分隔。 |
| class | 要由测试运行器执行的完全限定测试用例类。可选地,这可以包括通过哈希(#)与类名分开的测试方法名称。 |
| coverage | True 运行 EMMA 代码覆盖率,并将输出写入一个文件,该文件也可以指定。我们将在第十章替代测试策略中深入了解支持我们的测试的 EMMA 代码覆盖率。 |
摘要
我们已经回顾了 Android 测试背后的主要技术和工具。
以下是我们在本章中涵盖的内容:
-
创建了我们第一个 Android 测试项目,作为示例 Android 项目的配套。
-
跟随最佳实践,我们始终创建我们的伴随测试项目,即使最初你可能认为它不是必需的。
-
创建了一个简单的测试类来测试项目中的 Activity。我们还没有添加任何有用的测试用例,但添加这些简单的用例是为了验证我们的所有基础设施。
-
我们还从 Eclipse 和命令行运行了这个简单的测试,以了解我们拥有的替代方案。在这个过程中,我们提到了活动管理器及其命令行版本
am。 -
分析了最常用的命令行并解释了它们的选项。
-
创建了一个自定义注解来排序我们的测试,并演示了其用法。
-
运行测试并解释结果让我们了解到我们的应用程序表现如何。
在下一章中,我们将更详细地分析提到的技术、框架和工具,并提供它们用法的示例。
第三章:Android SDK 上的构建块
我们现在知道了如何创建测试项目和运行测试。现在是时候深入挖掘以找到可用于创建测试的构建块了。
因此,在本章的第三部分,我们将涵盖:
-
常见断言
-
视图断言
-
其他断言类型
-
TouchUtils,用于测试用户界面
-
模拟对象
-
仪器化
-
TestCase 类层次结构
-
使用外部库
我们将分析这些组件,并在适用时展示它们的使用示例。本章中的示例故意从包含它们的原始 Android 项目中分离出来,以便您集中注意力和关注所展示的主题,尽管完整的示例可以按稍后解释的方式下载。目前,我们感兴趣的只是树木,而不是森林。
除了提供的示例外,我们还将识别常见的、可重用的模式,这些模式将帮助您为自己的项目创建测试。
演示应用程序
我们创建了一个非常简单的应用程序来演示本章中一些测试的使用。此应用程序的源代码可以从 www.packtpub.com/support 下载。
下一个截图显示了此应用程序的运行情况:
.jpg)
深入探讨断言
断言是应该检查可能被评估的条件的方法,如果条件不满足,则抛出异常,从而终止测试的执行。
JUnit API 包含名为 Assert 的类,它是所有测试用例类的基类。它包含几个对编写测试有用的断言方法。这些继承的方法用于测试各种条件,并且被重载以支持不同的参数类型。它们可以根据检查的条件分组到不同的集合中;例如:
-
assertEquals -
assertFalse -
assertNotNull -
assertNotSame -
assertNull -
assertSame -
assertTrue -
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
}
}
自定义消息
说到自定义消息,值得知道的是,所有的 assert 方法都提供了一个包含自定义 String 消息的重载版本。如果断言失败,测试运行器将打印这个自定义消息而不是默认消息。这个自定义消息在查看测试报告时很容易识别失败,因此强烈建议将其作为最佳实践使用。
这是一个使用此建议进行的简单测试示例:
public void testMax() {
final int a = 1;
final int b = 2;
final int expected = b;
final int actual = Math.max(a, b); assertEquals("Expection " + expected + " but was " + actual,
expected, actual);
}
在示例中,我们可以看到另一种有助于你轻松组织和理解测试的实践。这是为存储预期值和实际值的变量使用显式名称的使用。
静态导入
虽然基本的断言方法是从 Assert 基类继承的,但某些其他断言需要特定的导入。为了提高测试的可读性,有一种模式是从相应的类中静态导入断言方法。使用这种模式而不是有:
public void testAlignment() {
final int margin = 0;
... android.test.ViewAsserts.assertRightAligned( mMessage, mCapitalize, margin);
}
我们可以通过添加静态导入来简化它:
import static android.test.ViewAsserts.assertRightAligned;
public void testAlignment() {
final int margin = 0;
assertRightAligned(mMessage, mCapitalize, margin);
}
Eclipse 通常不会自动处理这些静态导入,所以如果你想当你在输入这些断言的开始时,让内容辅助 (Ctrl+SPACE) 为你添加静态导入,你应该将这些类添加到 Eclipse 的收藏夹列表中。为此,导航到 窗口 | 首选项 | Java | 编辑器 | 内容辅助 | 收藏夹 | 新类型。输入:android.test.ViewAsserts,然后添加另一个类型:android.test.MoreAsserts。

视图断言
之前引入的断言可以处理各种类型的参数,但它们仅用于测试简单的条件或简单的对象。
例如,我们有 assertEquals(short expected, short actual) 来测试 short 值,assertEquals(int expected, int actual) 来测试整数值,assertEquals(Object expected, Object actual) 来测试任何 Object 实例,等等。
通常在测试 Android 用户界面时,你会面临需要更复杂的方法,主要与Views相关。在这方面,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:断言测试视图相对于参考视图垂直居中对齐。
以下示例展示了如何使用ViewAsserts来测试用户界面布局:
public void testUserInterfaceLayout() {
final int margin = 0;
final View origin = mActivity.getWindow().getDecorView(); assertOnScreen(origin, mMessage);
assertOnScreen(origin, mCapitalize);
assertRightAligned(mMessage, mCapitalize, margin);
}
assertOnScreen 方法使用一个原点开始查找请求的Views。在这种情况下,我们使用顶级窗口装饰视图。如果由于某种原因你不需要在层次结构中走那么高,或者这种方法不适合你的测试,你可以使用层次结构中的另一个根View;例如 View.getRootView(),在我们的具体示例中将是 mMessage.getRootView()。
更多的断言
如果之前审查的断言似乎不足以满足测试需求,Android 框架中还包括另一个类来涵盖其他情况。这个类是 MoreAsserts (developer.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 断言中没有涵盖的Collections不是空的。 -
assertNotMatchesRegex:断言指定的Regex与指定的 String 不完全匹配,如果匹配则使用提供的信息失败。 -
checkEqualsAndHashCodeMethods:用于一次性测试equals()和hashCode()结果的工具。测试应用于两个对象的equals()是否匹配指定的结果。
以下测试检查通过点击 UI 按钮调用的首字母大写方法调用期间是否出现错误。
@UiThreadTest
public void testNoErrorInCapitalization() {
final String msg = "this is a sample";
mMessage.setText(msg);
mCapitalize.performClick();
final String actual = mMessage.getText().toString();
final String notExpectedRegexp = "(?i:ERROR)"; assertNotContainsRegex("Capitalization found error:",
notExpectedRegexp, actual);
}
注意,因为这是一个修改用户界面的测试,我们必须用 @UiThreadTest 注解它,否则它将无法从不同的线程更改 UI,并且我们会收到以下异常:
03-02 23:06:05.826: INFO/TestRunner(610): ----- 开始异常 -----
03-02 23:06:05.862: INFO/TestRunner(610): android.view.ViewRoot$CalledFromWrongThreadException: 只能是创建视图层次结构的原始线程才能触摸其视图。
03-02 23:06:05.862: INFO/TestRunner(610): 在 android.view.ViewRoot.java:2932 的 android.view.ViewRoot.checkThread()
[...]
03-02 23:06:05.862: INFO/TestRunner(610): 在 android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:1447)
03-02 23:06:05.892: INFO/TestRunner(610): ----- 结束异常 -----
如果你不太熟悉正则表达式,花些时间访问 developer.android.com/reference/java/util/regex/package-summary.html,这将值得。
在这个特定的情况下,我们正在寻找结果中包含的单词"ERROR",进行不区分大小写的匹配(为此设置了标志'i')。也就是说,如果由于某种原因,大写字母在我们的应用程序中没有起作用,并且它包含错误消息,我们将通过断言检测这种条件。
TouchUtils 类
有时,在测试 UI 时,模拟不同类型的触摸事件是有帮助的。这些触摸事件可以通过许多不同的方式生成,但可能android.test.TouchUtils是最简单的。这个类为从InstrumentationTestCase派生的测试用例提供了生成触摸事件的可重用方法。
特色方法允许模拟与正在测试的 UI 的交互。TouchUtils提供了使用正确的 UI 或主线程注入事件的框架,因此不需要特殊处理,你也不需要使用@UIThreadTest注解测试。
提到的方法支持:
-
点击视图并释放
-
在视图中轻触,即触摸它然后快速释放
-
在视图中长按
-
拖动屏幕
-
拖动视图
以下测试代表了TouchUtils的典型用法:
public void testListScrolling() {
mListView.scrollTo(0, 0);
TouchUtils.dragQuarterScreenUp(this, mActivity);
TouchUtils.dragQuarterScreenUp(this, mActivity);
TouchUtils.dragQuarterScreenUp(this, mActivity);
TouchUtils.dragQuarterScreenUp(this, mActivity);
TouchUtils.tapView(this, mListView);
final int expectedItemPosition = 6;
final int actualItemPosition = mListView.getFirstVisiblePosition();
assertEquals("Wrong position", expectedItemPosition, actualItemPosition);
final String expected = "Anguilla";
final String actual = mListView.getAdapter(). getItem(expectedItemPosition).toString();
assertEquals("Wrong content", actual, expected);
}
这个测试执行以下操作:
-
将列表重新定位到开始位置,以从已知条件开始。
-
滚动列表几次。
-
检查第一个可见位置,以确认列表已正确滚动。
-
检查元素的内容,以验证其是否正确。
即使是最复杂的 UI 也可以用这种方式进行测试,这将帮助你检测可能影响用户体验的各种条件。
模拟对象
我们在第一章 开始测试 中访问了 Android 测试框架提供的模拟对象,并评估了不使用真实对象以隔离测试环境的相关问题。
下一章将讨论测试驱动开发(Test Driven Development),如果我们是测试驱动开发的纯粹主义者,我们可能会争论模拟对象的使用,并更倾向于使用真实对象。马丁·福勒在他的杰出文章《Mock 对象不是存根》(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:模拟的 Context 类。这可以用来注入其他依赖。所有方法都是非功能的,并抛出UnsupportedOperationException。 -
MockCursor:隔离测试代码与真实 Cursor 实现的模拟 Cursor 类。所有方法都是非功能的,并抛出UnsupportedOperationException。 -
MockDialogInterface:DialogInterface类的模拟实现。所有方法都是非功能的,并抛出UnsupportedOperationException。 -
MockPackageManager:PackageManager类的模拟实现。所有方法都是非功能的,并抛出UnsupportedOperationException。 -
MockResources:模拟的 Resources 类。所有方法都是非功能的,并抛出UnsupportedOperationException。
正如我们提到的,所有这些类都有非功能的方法,如果使用这些方法,将会抛出 UnsupportedOperationException。因此,如果你需要使用这些方法之一,或者如果你检测到你的测试因为这个 Exception 而失败,你应该扩展这些基类之一并提供所需的功能。
MockContext 概述
MockContext 类以非功能方式实现所有方法并抛出 UnsupportedOperationException。因此,如果你忘记实现你正在处理的测试用例所需的某个方法,这个异常将会被抛出,你可以立即检测到这种情况。
这个模拟可以用来向待测试的类中注入其他依赖、模拟或监视器。通过扩展这个类可以获得更细粒度的控制。
扩展这个类以提供你期望的行为,重写相应的方法。
正如我们将要介绍的,Android SDK 提供了一些预构建的模拟 Context,在某些情况下非常有用。
IsolatedContext 类
在你的测试中,你可能需要隔离待测试的 Activity 以防止与其他组件交互。这可能是一种完全隔离,但有时这种隔离可以避免与其他组件交互,并且为了你的 Activity 正确行为,仍然需要与系统保持一些连接。
对于这些情况,Android SDK 提供了 android.test.IsolatedContext,这是一个模拟的 Context,它防止与大多数底层系统交互,同时也满足与其他包或组件(如 Services 或 ContentProviders)交互的需求。
文件和数据库操作的替代路径
在某些情况下,我们需要的只是能够提供文件和数据库操作的替代路径。例如,如果我们正在对真实设备上的应用程序进行测试,可能我们不想在测试期间影响现有的文件。
这种情况可以利用另一个类,这个类不属于android.test.mock子包,而是属于android.test包:RenamingDelegatingContext。
这个类允许我们通过在构造函数中指定的前缀来改变对文件和数据库的操作。所有其他操作都委托给构造函数中必须指定的委托上下文。
假设我们要测试的Activity使用了我们想要以某种方式控制的文件,可能是在测试中引入特殊内容或固定装置来驱动测试,而我们不想或不能使用真实文件。在这种情况下,我们创建一个指定前缀的RenamingDelegatingContext;我们将这个前缀添加到替换文件名中,我们的未更改的Activity将使用它们。
例如,如果我们的Activity试图访问名为birthdays.txt的文件,而我们提供了指定前缀“test”的RenamingDelegatingContext,那么当它被测试时,这个相同的Activity将访问文件testbirthdays.txt。
MockContentResolver类
MockContentResolver类以非功能方式实现了所有方法,并在你尝试使用它们时抛出UnsupportedOperationException异常。这个类的原因是将测试与真实内容隔离开来。
假设你的应用程序使用了一个ContentProvider,可能来自多个Activity。你可以使用ProviderTestCase2对这个ContentProvider创建单元测试,我们很快就会看到它,在某些情况下,还可以实现一个如之前所述的RenamingDelegatingContext。
但是当我们尝试对ContentProvider进行功能或集成测试时,使用哪个测试案例并不那么明显。如果你的功能测试主要模拟用户体验,最明显的选择是ActivityInstrumentationTestCase2,因为你可能需要sendKeys()或类似的方法,这些方法在这些测试中是现成的。
你可能遇到的第一问题是,不清楚在哪里注入MockContentResolver到你的测试中,以便能够使用测试数据库实例或数据库固定装置与你的ContentProvider一起使用。也无法注入MockContext。
这个问题将在第七章中解决,测试食谱,其中提供了更多详细信息。
TestCase基类
这是 JUnit 框架中所有其他测试案例的基类。它实现了我们在前例中分析的基本方法。
TestCase还实现了junit.framework.Test接口。
这是TestCase和Test接口的 UML 类图。

测试案例应该直接扩展TestCase或其子类之一。
除了之前解释的方法之外,还有其他方法。
无参构造函数
所有测试用例都需要默认构造函数,因为有时,根据使用的测试运行器,这可能是唯一被调用的构造函数。它也用于序列化。
根据文档,这个方法不打算在没有调用 setName(String name) 的情况下由普通人使用。
一个常见的模式是在这个构造函数中使用默认常量测试用例名称,然后调用 Given name 构造函数。
public class MyTestCase extends TestCase { public MyTestCase() {
this("MyTestCase Default Name");
}
public MyTestCase(String name) {
super(name);
}
}
带名称的构造函数
这个构造函数接受一个参数作为测试用例的名称。它将出现在测试报告中,并在尝试识别失败的测试时很有帮助。
setName() 方法
有些扩展自 TestCase 的类没有提供带名称的构造函数。在这种情况下,唯一的替代方案是调用 setName(String name)。
AndroidTestCase 基类
这个类可以用作通用 Android 测试用例的基类。
这是 AndroidTestCase 及其最相关类的 UML 类图。

当你需要访问 Activity Context(如资源、数据库或文件系统中的文件)时,请使用这个类。Context 作为名为 mContext 的字段存储在这个类中,并在需要时可以在测试中使用。也可以使用 getContext() 方法。
基于这个类的测试可以启动多个 Activity,使用 Context.startActivity()。
Android SDK 中有许多扩展这个基类的测试用例:
-
ApplicationTestCase<T extends Application> -
ProviderTestCase2<T extends ContentProvider> -
ServiceTestCase<T extends Service>
assertActivityRequiresPermission() 方法
该方法的签名如下:
public void assertActivityRequiresPermission (String packageName, String className, String permission)
描述
这个断言方法检查特定 Activity 的启动是否受到特定权限的保护。它接受三个参数:
-
packageName:表示要启动的 Activity 的包名的字符串 -
className:表示要启动的 Activity 类的字符串 -
permission:一个表示查询权限的字符串
Activity 被启动,然后预期会抛出一个 SecurityException,错误信息中提到所需的权限缺失。该 Activity 不由这个测试处理,因此不需要 Instrumentation。
示例
这个测试检查 MyContactsActivity 活动中写入外部存储所需的 android.Manifest.permission.WRITE_EXTERNAL_STORAGE 权限的要求。
public void testActivityPermission() {
final String PKG = "com.example.aatg.myfirstproject";
final String ACTIVITY = PKG + ".MyFirstProjectActivity";
final String PERMISSION = android.Manifest.permission.WRITE_EXTERNAL_STORAGE;
assertActivityRequiresPermission(PKG, ACTIVITY, PERMISSION);
}
小贴士
总是使用来自 android.Manifest.permission 的权限描述常量,而不是 Strings,这样如果实现发生变化,你的代码仍然有效。
assertReadingContentUriRequiresPermission 方法
该方法的签名如下:
public void assertReadingContentUriRequiresPermission ( Uri uri, String permission)
描述
这个断言方法检查从特定 URI 读取是否需要作为参数提供的权限。
它接受两个参数:
-
uri:需要查询权限的 URI -
permission:包含查询权限的字符串
如果生成了包含指定权限的SecurityException,则验证此断言。
示例
此测试尝试读取联系人并验证是否生成了正确的SecurityException:
public void testReadingContacts() {
final Uri URI = ContactsContract.AUTHORITY_URI;
final String PERMISSION = android.Manifest.permission.READ_CONTACTS;
assertReadingContentUriRequiresPermission(URI, PERMISSION);
}
assertWritingContentUriRequiresPermission()方法
此方法的签名如下:
public void assertWritingContentUriRequiresPermission( Uri uri, String permission)
描述
此断言方法检查将数据插入特定 URI 是否需要作为参数提供的权限。
它接受 2 个参数:
-
uri:需要查询权限的 URI -
permission:包含查询权限的字符串
如果生成了包含指定权限的SecurityException,则验证此断言。
示例
此测试尝试写入联系人并验证是否生成了正确的SecurityException:
public void testWritingContacts() {
final Uri URI = ContactsContract.AUTHORITY_URI;
final String PERMISSION = android.Manifest.permission.WRITE_CONTACTS;
assertWritingContentUriRequiresPermission(URI, PERMISSION);
}
Instrumentation
在运行任何应用程序代码之前,系统会实例化 Instrumentation,使其能够监控系统与应用程序之间的所有交互。
与许多其他 Android 应用程序组件一样,Instrumentation 实现是在AndroidManifest.xml文件中的<instrumentation>标签下描述的。例如,如果你打开我们测试的AndroidManifest.xml文件并查看其中内容,你会找到:
<instrumentation
android:targetPackage="com.example.aatg.myfirstproject"
android:name="android.test.InstrumentationTestRunner"
android:label="MyFirstProject Tests"/>
这是 Instrumentation 声明。
targetPackage属性定义了测试包的名称,name是测试运行器的名称,label是当此 Instrumentation 被列出时显示的文本。
请注意,如前所述,此声明属于测试项目,而不是主项目。
ActivityMonitor内部类
如前所述,Instrumentation 类用于监控系统与应用程序或测试中的活动之间的交互。内部类Instrumentation.ActivityMonitor允许监控应用程序中的单个活动。
示例
假设我们在Activity中有一个TextField,它包含一个 URL,并且其自动链接属性已设置:
<TextView android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:text="@string/home"
android:layout_gravity="center" android:gravity="center"
android:autoLink="web" android:id="@+id/link" />
如果我们想验证当点击超链接时,是否正确地跟随并调用浏览器,我们可以创建一个类似的测试:
public void testFollowLink() {
final Instrumentation inst = getInstrumentation();
IntentFilter intentFilter = new IntentFilter( Intent.ACTION_VIEW);
intentFilter.addDataScheme("http");
intentFilter.addCategory(Intent.CATEGORY_BROWSABLE);
ActivityMonitor monitor = inst.addMonitor( intentFilter, null, false);
assertEquals(0, monitor.getHits());
TouchUtils.clickView(this, mLink);
monitor.waitForActivityWithTimeout(5000); assertEquals(1, monitor.getHits());
inst.removeMonitor(monitor);
}
在这里,我们:
-
获取 instrumentation。
-
添加基于
IntentFilter的监控器。 -
等待活动。
-
验证监控器命中次数是否增加。
-
移除监控器。
使用监控器,我们可以测试与系统和其他活动之间甚至最复杂的交互。这是创建集成测试的非常强大的工具。
InstrumentationTestCase类
InstrumentationTestCase类是具有访问 Instrumentation 的各种测试案例的直接或间接基类。以下是直接和间接子类列表中最重要的一些:
-
ActivityTestCase -
ProviderTestCase2<T extends ContentProvider> -
SingleLaunchActivityTestCase<T extends Activity> -
SyncBaseInstrumentation -
ActivityInstrumentationTestCase2<T extends Activity> -
ActivityUnitTestCase<T extends Activity>
这是InstrumentationTestCase及其最相关类的 UML 类图:

InstrumentationTestCase位于android.test包中,图中未显示,它扩展了junit.framework.TestCase,而junit.framework.TestCase又扩展了junit.framework.Assert。
launchActivity和launchActivityWithIntent方法
这些实用方法用于从测试中启动 Activity。如果未使用第二种选项指定Intent,则使用默认的Intent:
public final T launchActivity( String pkg, Class<T> activityCls, Bundle extras)
注意
注意,模板类参数T在activityCls和返回类型中使用,这限制了其只能用于该类型的 Activity。
如果需要指定自定义的Intent,可以使用以下代码,它还添加了intent参数:
public final T launchActivityWithIntent( String pkg, Class<T> activityCls, Intent intent)
sendKeys和sendRepeatedKeys方法
在测试 Activity 的 UI 时,您将需要模拟与基于 qwerty 键盘或 DPAD 按钮的交互,以发送按键来完成字段、选择快捷方式或在不同组件之间导航。
这就是不同的sendKeys和sendRepeatedKeys的作用。
有一个版本的sendKeys接受整数键值。它们可以从KeyEvent类中定义的常量中获得。
例如,我们可以这样使用sendKeys方法:
public void testSendKeyInts() {
try {
runTestOnUiThread(new Runnable() {
public void run() {
mMessage.requestFocus();
}
});
} catch (Throwable e) {
fail("Couldn't set focus");
}
sendKeys(KeyEvent.KEYCODE_H,
KeyEvent.KEYCODE_E,
KeyEvent.KEYCODE_E,
KeyEvent.KEYCODE_E,
KeyEvent.KEYCODE_Y,
KeyEvent.KEYCODE_ALT_LEFT,
KeyEvent.KEYCODE_1,
KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_ENTER);
final String expected = "HEEEY!";
final String actual = mMessage.getText().toString();
assertEquals(expected, actual);
}
在这里,我们正在使用它们的整数表示法向测试中的 Activity 发送H, E和Y字母按键、感叹号,然后是Enter键。
或者,我们可以创建一个字符串,将我们想要发送的按键连接起来,忽略 KEYCODE 前缀,并用空格分隔,这些空格最终会被忽略:
public void testSendKeyString() {
try {
runTestOnUiThread(new Runnable() {
public void run() {
mMessage.requestFocus();
}
});
} catch (Throwable e) {
fail("Couldn't set focus");
}
sendKeys("H 3*E Y ALT_LEFT 1 DPAD_DOWN ENTER");
final String expected = "HEEEY!";
final String actual = mMessage.getText().toString();
assertEquals(expected, actual);
}
在这里,我们与之前的测试完全相同,但使用了一个String。请注意,String中的每个按键都可以由一个重复因子前缀,后跟*和要重复的键来表示。在我们的上一个例子中,我们使用了 3E,这等同于"E E E",即字母E*重复了三次。
如果在我们的测试中需要发送重复按键,还有一个专门针对这些情况的替代方案:
public void testSendRepeatedKeys() {
try {
runTestOnUiThread(new Runnable() {
public void run() {
mMessage.requestFocus();
}
});
} catch (Throwable e) {
fail("Couldn't set focus");
}
sendRepeatedKeys(1, KeyEvent.KEYCODE_H,
3, KeyEvent.KEYCODE_E,
1, KeyEvent.KEYCODE_Y,
1, KeyEvent.KEYCODE_ALT_LEFT,
1, KeyEvent.KEYCODE_1,
1, KeyEvent.KEYCODE_DPAD_DOWN,
1, KeyEvent.KEYCODE_ENTER);
final String expected = "HEEEY!";
final String actual = mMessage.getText().toString();
assertEquals(expected, actual);
}
这是以不同方式实现的相同测试。每个按键前面都跟着重复次数。
runTestOnUiThread辅助方法
runTestOnUiThread方法是一个辅助方法,用于在 UI 线程上运行测试的一部分。
或者,正如我们之前讨论过的,要在一个 UI 线程上运行测试,我们可以使用@UiThreadTest注解它。
但有时,我们只需要在 UI 线程上运行测试的一部分,因为其他部分不适合在该线程上运行,或者正在使用提供该线程基础设施的辅助方法,如TouchUtils方法。
最常见的模式是在发送按键之前改变焦点,这样按键就能正确地发送到目标View:
public void testCapitalizationSendingKeys() {
final String keysSequence = "T E S T SPACE M E"; runTestOnUiThread(new Runnable() {
public void run() {
mMessage.requestFocus();
}
});
mInstrumentation.waitForIdleSync();
sendKeys(keysSequence);
TouchUtils.clickView(this, mCapitalize);
final String expected = "test me".toUpperCase();
final String actual = mMessage.getText().toString();
assertEquals(expected, actual);
}
在等待应用程序空闲之前,我们使用 Instrumentation.waitForIdleSync() 请求 mMessage EditText 的焦点,然后向其发送按键序列。之后,使用 TouchUtils.clickView(),我们点击 Button,最终检查转换后的字段内容。
ActivityTestCase 类
这是一个主要持有其他测试用例通用代码的类,这些测试用例访问 Instrumentation。
如果您正在实现特定于测试用例的行为,并且现有替代方案不符合您的需求,您可以使用此类。
如果不是这样,您可能发现以下选项更适合您的需求:
-
ActivityInstrumentationTestCase2<T extends Activity> -
ActivityUnitTestCase<T extends Activity>
这是 ActivityTestCase 和最相关类的 UML 类图:

抽象类 android.test.ActivityTestCase 扩展 android.test.InstrumentationTestCase 并作为其他不同测试用例的基类,例如 android.test.ActivityInstrumentationTestCase, android.test.ActivityInstrumentationTestCase2 和 android.test.ActivityUnitTestCase。
注意
android.test.ActivityInstrumentationTestCase 自 Android API Level 3 (Android 1.5) 起已被弃用,不应在新项目中使用。
scrubClass 方法
这是该类中的一个受保护方法:
protected void scrubClass (Class<?> testCaseClass)
它在多个测试用例的实现中的 tearDown() 方法中被调用,以清理可能作为非静态内部类实例化的类的变量,从而避免需要持有它们的引用。
这是为了防止大型测试套件出现内存泄漏。
如果发现访问这些变量存在问题,将抛出 IllegalAccessException。
ActivityInstrumentationTestCase2 类
这个类可能是您在编写 Android 测试用例时使用最多的类。它提供单个 Activity 的功能测试。
这个类可以访问 Instrumentation,并通过调用 InstrumentationTestCase.launchActivity() 使用系统基础设施创建受测试的 Activity。
这是显示 ActivityInstrumentationTestCase2 和最相关类的 UML 类图:

类 android.test.ActivityInstrumentationTestCase2 扩展 android.test.ActivityTestCase。此图还显示了 ActivityUnitTestCase,它也扩展了 ActivityTestCase。类模板参数 T 代表 Activity 的类。
创建后,Activity 可以被操作和监控。
如果您需要提供一个自定义 Intent 来启动您的 Activity,在调用 getActivity() 之前,您可以使用 setActivityIntent(Intent intent) 注入一个 Intent。
这种功能测试对于通过用户界面测试交互非常有用,因为可以注入事件来模拟用户行为。
构造函数
这个类只有一个公开的非弃用构造函数。这是:
ActivityInstrumentationTestCase2(Class<T> activityClass)
它应该使用与类模板参数相同的 Activity 类的实例来调用。
setUp 方法
如我们之前在第一章中看到的,开始测试,setUp 方法是初始化测试用例字段和其他需要初始化的固定组件的最佳位置。
这是一个示例,展示了你可能在测试用例中反复找到的一些模式:
protected void setUp() throws Exception {
super.setUp();
// this must be called before getActivity()
// disabling touch mode allows for sending key events
setActivityInitialTouchMode(false);
mActivity = getActivity();
mInstrumentation = getInstrumentation();
mLink = (TextView) mActivity.findViewById( com.example.aatg.myfirstproject.R.id.link);
mMessage = (EditText) mActivity.findViewById( com.example.aatg.myfirstproject.R.id.message);
mCapitalize = (Button) mActivity.findViewById(com.example. aatg.myfirstproject.R.id.capitalize);
}
我们执行了以下操作:
-
调用超类方法。这是一个 JUnit 模式,在这里应该遵循以确保正确操作。
-
禁用触摸模式。这应该在通过调用
getActivity()创建Activity之前完成,以便产生一些效果。它将测试中的Activity的初始触摸模式设置为禁用。触摸模式是 Android UI 的一个基本概念,在developer.android.com/resources/articles/touch-mode.html中有讨论。 -
使用
getActivity()启动 Activity。 -
获取仪器。我们因为
ActivityInstrumentationTestCase2扩展了InstrumentationTestCase而有权访问仪器。 -
查找视图并设置字段。在这些操作中,请注意使用的 R 类来自目标包,而不是来自测试。
tearDown 方法
通常这个方法会清理在 setUp 中初始化的内容。
在这个例子中,我们只调用了超类方法:
protected void tearDown() throws Exception { super.tearDown();
}
testPreconditions 方法
此方法用于检查一些初始条件以确保我们的测试能够正确运行。
尽管它的名字如此,但并不能保证这个测试在其它测试之前运行。然而,将所有预置条件测试收集在这个自定义名称下是一个好的实践。
这是一个 testPrecondition 测试的示例:
public void testPreconditions() {
assertNotNull(mActivity);
assertNotNull(mInstrumentation);
assertNotNull(mLink);
assertNotNull(mMessage);
assertNotNull(mCapitalize);
}
我们只检查非空值,但在这个情况下,断言这一点我们也可以确信视图是通过特定的 ID 找到的,并且它们的类型是正确的,否则它们在 setUp 中被分配。
ProviderTestCase2<T> 类
这是一个设计来测试 ContentProvider 类的测试用例。
这是 ProviderTestCase2 及其最相关的类的 UML 类图:

类 android.test.ProviderTestCase2 也扩展了 AndroidTestCase。类模板参数 T 代表正在测试的 ContentProvider。此测试的实现使用了一个 IsolatedContext 和一个 MockContentResolver,这些是我们在本章中之前描述的模拟对象。
构造函数
这个类只有一个公开的非弃用构造函数。这是:
ProviderTestCase2(Class<T> providerClass, String providerAuthority)
它应该使用与类模板参数相同的 ContentProvider 类的实例来调用。
第二个参数是提供者的权限,通常在 ContentProvider 类中定义为 AUTHORITY 常量。
示例
这是一个典型的ContentProvider测试示例:
public void testQuery() {
Uri uri = Uri.withAppendedPath( MyProvider.CONTENT_URI, "dummy");
final Cursor c = mProvider.query(uri, null, null, null, null);
final int expected = 2;
final int actual = c.getCount();
assertEquals(expected, actual);
}
在这个测试中,我们期望查询返回一个包含 2 行的Cursor。这只是一个例子——使用适用于您特定情况的行数,并断言这个条件。
通常在setUp方法中,我们使用getProvider()获取提供者的引用,在这个例子中是mProvider。
值得注意的是,因为这些测试使用了MockContentResolver和IsolatedContext,所以真实数据库的内容不会受到影响,我们也可以运行这样的测试:
public void testDelete() {
Uri uri = Uri.withAppendedPath( MyProvider.CONTENT_URI, "dummy");
final int actual = mProvider.delete( uri, "_id = ?", new String[] { "1" });
final int expected = 1;
assertEquals(expected, actual);
}
这个测试删除了数据库的一些内容,但数据库被恢复到其初始内容,这样就不会影响其他测试。
The ServiceTestCase
这是一个专门创建来测试服务的测试用例。
这个类,ServiceTestCase<T>,正如这个 UML 类图所示,扩展了AndroidTestCase:

包含了用于练习服务生命周期的方法,如setupService、startService、bindService和shutDownService。
构造函数
这个类只有一个公开的非弃用构造函数。它是:
ServiceTestCase(Class<T> serviceClass)
它应该使用与用作类模板参数的Service类相同的Service类的实例来调用。
TestSuiteBuilder.FailedToCreateTests 类
TestSuiteBuilder.FailedToCreateTests类是一个特殊的TestCase,用于在build()步骤中指示失败。
即,如果在测试套件创建过程中检测到错误,您将收到一个像这样的异常,表明无法构建测试套件:
01-02 06:31:26.656: INFO/TestRunner(4569): java.lang.RuntimeException: Exception during suite construction
01-02 06:31:26.656: INFO/TestRunner(4569): at android.test.suitebuilder.TestSuiteBuilder$FailedToCreateTests.testSuiteConstructionFailed(TestSuiteBuilder.java:239)
01-02 06:31:26.656: INFO/TestRunner(4569): at java.lang.reflect.Method.invokeNative(Native Method)
[...]
01-02 06:31:26.656: INFO/TestRunner(4569): at android.test.InstrumentationTestRunner.onStart(InstrumentationTestRunner.java:520)
01-02 06:31:26.656: INFO/TestRunner(4569): at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:1447)
在测试项目中使用外部库
您的主要 Android 项目可能需要外部库。让我们假设在一个Activity中,我们正在从一个外部库中的类创建对象。为了我们的示例,让我们说这个库叫做libdummy-0.0.1-SNAPSHOT.jar,提到的类是Dummy。这里使用一个不执行任何操作的Dummy类,只是为了不分散您对主要目标的注意力,这个目标是包括您可能需要的任何库,而不仅仅是特定的一个。
因此,我们的Activity将看起来像这样:
package com.example.aatg.myfirstproject; import com.example.libdummy.Dummy;
import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.EditText;
public class MyFirstProjectActivity extends Activity {
private EditText mMessage;
private Button mCapitalize;
private Dummy mDummy;
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
mMessage = (EditText) findViewById(R.id.message);
mCapitalize = (Button) findViewById(R.id.capitalize);
mCapitalize.setOnClickListener(new OnClickListener() {
public void onClick(View v) {
mMessage.setText(mMessage.getText().toString(). toUpperCase());
}
}); mDummy = new Dummy();
}
public static void methodThatShouldThrowException() throws Exception {
throw new Exception("This is an exception");
} public Dummy getDummy() {
return mDummy;
}
}
这个库应该像通常一样添加到项目的 Java 构建路径中,作为一个 JAR 或外部 JAR,具体取决于文件的位置。
现在,让我们创建一个简单的测试。根据我们之前的经验,我们知道如果我们需要测试一个Activity,我们应该使用ActivityInstrumentationTestCase2,这正是我们将要做的。我们的简单测试将是:
public void testDummy() {
assertNotNull(mActivity.getDummy());
}
不幸的是,这个测试无法编译。问题是我们引用了一个缺失的类。我们的测试项目对Dummy类或libdummy库一无所知,因此我们收到了这个错误:
从类型 DummyActivity 引用的 getDummy()方法指向缺失的类型 Dummy。
让我们使用添加外部 JARs...按钮将libdummy库添加到测试项目的属性中:

然而,这样做会导致另一个错误。如果你运行测试,你会收到以下错误:
08-10 00:26:11.820: ERROR/AndroidRuntime(510): FATAL EXCEPTION: main
08-10 00:26:11.820: ERROR/AndroidRuntime(510): java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation
...[为了简洁而省略的行]
08-10 00:26:11.820: ERROR/AndroidRuntime(510): at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:868)
08-10 00:26:11.820: ERROR/AndroidRuntime(510): at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:626)
08-10 00:26:11.820: ERROR/AndroidRuntime(510): at dalvik.system.NativeStart.main(Native Method)
这个问题的原因是将库添加到两个项目中会导致相同的类被插入到两个 APK 中。然而,测试项目会从被测试的项目中加载类。库中的类将从测试项目加载,而被测试项目中的类将引用被测试项目 APK 中的副本。因此产生了引用错误。
解决此问题的方法是导出 libdummy 条目到依赖项目,并从测试项目的 Java 构建路径中删除 JAR 文件。
以下截图显示了如何在主项目的属性中进行此操作:

注意,libdummy-0.0.1-SNAPSHOT.jar 现已检查到 顺序和出口。
摘要
我们调查了创建测试最相关的基本构建块和可重用模式。在这段旅程中,我们:
-
使用了从 JUnit 测试中通常找到的最常见断言到 Android SDK 中找到的最专业断言的多种类型的断言来测试应用程序 UI
-
解释了模拟对象及其在 Android 测试中的应用
-
举例说明了从单元测试到功能测试在 Android SDK 中可用的不同测试的使用
-
使用 UML 类图说明了最常见的类之间的关系,以便清晰地理解它们
-
深入研究了活动可用的仪器和不同监控器
现在我们有了所有构建块,是时候开始创建更多测试,以获得掌握这项技术所需的经验了。
下一章将使用一个示例项目介绍测试驱动开发,以展示其所有优点。
第四章:测试驱动开发
本章介绍了测试驱动开发的学科。我们将从一般复习开始,然后转向与 Android 平台密切相关概念和技术。
这是一个代码密集的章节,所以当你阅读时请准备好输入,这将是最有效地掌握所提供示例的方法。
在本章中,我们将:
-
介绍并解释测试驱动开发(Test Driven Development)
-
分析其优势
-
介绍一个潜在的真实生活例子
-
通过编写测试来理解需求
-
通过应用 TDD(测试驱动开发)来逐步完善项目
-
获取完全符合要求的程序
开始使用 TDD(测试驱动开发)
简而言之,测试驱动开发是在开发过程中编写测试的策略。这些测试用例是在满足它们的代码之前编写的。
添加一个单独的测试,然后编写满足这个测试编译所需的代码,最后运行完整的测试用例集以验证其结果。
这与其他开发过程的方法形成对比,在这些方法中,测试是在所有编码完成后编写的。
在编写满足它们的代码之前编写测试有几个优点。首先,测试无论如何都会被编写,而如果测试留到最后,它们很可能永远不会被编写。其次,开发者对他们的工作质量承担更多的责任。
设计决策是分步骤进行的,最后通过重构来改进满足测试的代码。
这个 UML 活动图描绘了测试驱动开发,以帮助我们理解这个过程:

以下部分解释了活动图中描述的各个活动。
编写测试用例
我们的开发过程从编写一个测试用例开始。这个看似简单的过程将在我们的大脑中启动一些机制。毕竟,如果我们没有对问题域及其细节有清晰的理解,就不可能编写一些代码,无论是否进行测试。通常,这一步会让你直面你不理解的问题方面,如果你想要建模和编写代码,你需要掌握这些。
运行所有测试
一旦编写了测试,接下来的明显步骤就是运行它,连同我们迄今为止编写的其他测试。在这里,具有内置测试环境支持的 IDE 的重要性可能比其他情况下更为明显,这可能会大大减少开发时间。预期的是,首先,我们的测试会失败,因为我们还没有编写任何代码!
为了能够完成我们的测试,我们通常需要编写额外的代码并做出设计决策。所编写的额外代码是最小的,以便让我们的测试能够编译。请考虑这里,如果不能编译就是失败。
当我们编译并运行测试时,如果测试失败,我们就尝试编写最少的代码来使测试通过。这一点在此处可能听起来有些奇怪,但本章中的以下代码示例将帮助您理解这个过程。
可选地,您不必再次运行所有测试,只需先运行新添加的测试以节省时间,因为有时在模拟器上运行测试可能会相当慢。然后运行整个测试套件以验证一切是否仍然正常工作。我们不希望通过破坏现有功能来添加新功能。
重构代码
当测试成功时,我们重构添加的代码以保持其整洁、干净和最小化。
我们再次运行所有测试,以验证我们的重构没有破坏任何东西,如果测试再次满足,并且不再需要重构,我们就完成我们的任务。
在重构后运行测试是这种方法中设置的一个令人难以置信的安全网。如果我们重构算法时犯了错误,提取变量、引入参数、更改签名或无论您的重构由什么组成,这个测试基础设施都会检测到问题。此外,如果某些重构或优化不能适用于所有可能的情况,我们可以验证它适用于应用程序使用的每个案例,并作为测试用例表达。
优势是什么?
个人来说,我迄今为止看到的主要优势是您可以快速集中精力,并且很难在软件中实现那些永远不会使用的实现选项。这种不必要的功能的实现是对您宝贵的发展时间和精力的浪费。而且正如您可能已经知道的,明智地管理这些资源可能是成功完成项目或失败的区别。可能,测试驱动开发不能无差别地应用于任何项目。我认为,就像任何其他技术一样,您应该使用您的判断力和专业知识来识别它可以在哪里应用,在哪里不能。但请记住:没有银弹。
另一个优势是您始终有一个安全网来保护您的更改。每次您更改一段代码时,您都可以绝对确信,只要存在测试来验证条件没有改变,系统的其他部分就不会受到影响。
理解需求
要能够编写关于任何主题的测试,我们首先应该理解要测试的主题。
我们还提到,优势之一是您可以快速集中精力,而不是围绕需求旋转。
将需求转换为测试并交叉引用可能是理解需求、确保所有需求都有实现和验证的最好方法。此外,当需求发生变化(在软件开发项目中这是非常常见的情况)时,我们可以更改验证这些需求的测试,然后更改实现以确保所有内容都被正确理解并映射到代码中。
创建一个示例项目——温度转换器
我们的示例将围绕一个极其简单的 Android 示例项目。它并不试图展示所有花哨的 Android 功能,而是专注于测试,并从测试开始逐步构建应用程序,应用之前学到的概念。
让我们假设我们收到了一个开发 Android 温度转换器应用程序的需求列表。虽然这个例子过于简化,但我们将遵循您通常开发此类应用程序的步骤。然而,在这种情况下,我们将在这个过程中引入测试驱动开发技术。
需求列表
通常情况下,需求列表非常模糊,有很多细节没有完全涵盖。
例如,让我们假设我们收到了项目所有者提供的这个列表:
-
该应用程序将温度从摄氏度转换为华氏度,反之亦然
-
用户界面提供了两个输入温度的字段,一个用于摄氏度,另一个用于华氏度
-
当在一个字段中输入一个温度时,另一个字段会自动更新为转换值
-
如果有错误,应向用户显示,可能使用相同的字段
-
用户界面中应预留一些空间以容纳屏幕键盘,以便在输入多个转换时简化应用程序操作
-
输入字段应保持为空
-
输入的值是两位小数的十进制值
-
数字应右对齐
-
即使应用程序暂停后,也应保留最后输入的值
用户界面概念设计
假设我们收到了用户界面设计团队提供的这个概念用户界面设计:

创建项目
我们的第一步是创建项目。正如我们之前提到的,我们正在创建一个主项目和测试项目。以下屏幕截图显示了TemperatureConverter项目的创建(所有值都是典型的 Android 项目值):

当您准备好继续时,应按下下一步 >按钮以创建相关的测试项目。
测试项目的创建显示在这个屏幕截图中。所有值都将根据您的先前输入为您选择:

创建 TemperatureConverterActivityTests 项目
在我们的主项目中,只有一些由 Android ADT 插件创建的模板,例如:
-
TemperatureConverterActivity -
main.xml布局 -
strings.xml资源 -
其他资源,如图标
此外,我们在我们的测试项目中创建了一些模板。为了将我们的测试与主包分开,相应的测试包是:
-
main.xml布局 -
strings.xml资源 -
其他资源,如图标
注意
请务必谨慎,不要让模板文件迷惑你。在测试项目中这些资源几乎或完全没有用途,为了避免混淆,你应该删除它们。如果你后来发现某些测试需要特定的资源,你只能添加所需的资源。
在 Eclipse 的包资源管理器中,通过选择主测试包名com.example.aatg.tc.test并右键单击它来创建第一个测试。选择新建 | JUnit 测试用例。
你应该有一个这样的对话框:

这里,你需要输入以下内容:
| 字段 | 描述 |
|---|---|
| 新的 JUnit 3 测试 | JUnit 3 是 Android 支持的版本。始终使用此选项。 |
| 源文件夹: | 测试的默认源文件夹。默认值应该足够。 |
| 包: | 测试的默认包。这通常是你的主项目的默认包名,后面跟着子包 test。 |
| 名称: | 此测试类的名称。这里的最佳实践是使用与被测试类相同的类名,后面跟着单词 Tests,因为很可能会在其中托管多个测试。 |
| 超类: | 我们应该根据我们将要测试的内容和方式来选择我们的超类。在第三章中,Android SDK 的构建块,我们回顾了可用的替代方案。当你尝试决定使用哪个超类时,请将其作为参考。在这种情况下,因为我们正在测试单个Activity并使用系统基础设施,所以我们使用ActivityInstrumentationTestCase2。请注意,由于ActivityInstrumentationTestCase2是一个通用类,我们还需要模板参数。这是我们正在测试的Activity,在我们的例子中是TemperatureConverterActivity。现在我们可以忽略指示超类不存在的警告;我们很快就会修复导入。 |
| 方法存根: | 选择你想要创建的方法存根。如果你现在还不确定你需要什么,那么选择所有,因为默认存根将调用它们的超类对应方法。 |
| 你想添加注释吗? | 为存根测试方法生成 Javadoc 注释。通常,除非你已更改代码模板中的默认模板,否则生成的注释将是:
/**
* Test method for {@link method()}.
*/
|
| 测试中的类 | 这是我们要测试的类 - 在这种情况下是TemperatureConverterActivity。在其他情况下,当测试中的类已经实现并且我们可以选择我们想要测试的方法列表时,这最有用。记住,在我们的情况下,我们还没有实现这个类,所以我们将只看到 Android ADT 插件模板中唯一的方法,即onCreate。 |
|---|
这种情况,即测试中的类尚未实现,只有 Android ADT 创建的方法可用,通过按下下一步 >可以更好地理解。在这里,展示了可用于测试的方法列表,在我们的例子中,除了onCreate和从 Activity 继承的方法外,我们还没有实现任何其他方法。

这个对话框有以下组件:
| 字段 | 描述 |
|---|---|
| 可用方法 | 这是所有我们可能想要测试的方法的列表。当方法重载时,测试名称会相应生成以应对这种情况,参数名称会被混淆到测试名称中。 |
| 创建最终方法存根 | 便利设置以将最终修饰符添加到存根方法。最终修饰符防止子类覆盖这些方法。 |
| 为生成的测试方法创建任务 | 在测试用例中创建一个 TODO 注释。 |
无论哪种方式,我们都可以选择onCreate(Bundle)来为我们生成testOnCreateBundle方法,但现在我们留空选择列表以避免增加这个简单演示应用程序的额外复杂性。
我们现在注意到,我们自动生成的类有一些错误需要我们在运行之前修复。否则,这些错误将阻止测试运行。
-
首先,我们应该添加缺少的导入,使用快捷键Shift+Ctrl+O。
-
其次,我们需要解决的问题在第三章中已经描述过,在The no-argument constructor部分下的Building Blocks on the Android SDK。根据这个模式,我们需要实现它:
public TemperatureConverterActivityTests() { this("TemperatureConverterActivityTests"); } public TemperatureConverterActivityTests(String name) { super(TemperatureConverterActivity.class); setName(name); } -
我们添加了无参数构造函数
TemperatureConverterActivityTests()。从这个构造函数中,我们调用带参数名称的构造函数。 -
最后,在这个给定的无参数构造函数中,我们调用带参数的构造函数并设置名称。
为了验证一切设置到位,您可以通过使用运行方式 | Android JUnit 测试来运行测试。目前还没有测试可以运行,但至少我们可以验证支持我们的测试的基础设施已经就绪。
创建固定装置
我们可以通过在setUp方法中填充我们测试所需的元素来开始创建我们的测试固定装置。在这种情况下,不可避免地要使用测试中的Activity,所以让我们为这种情况做好准备,并将其添加到固定装置中:
protected void setUp() throws Exception {
super.setUp();
mActivity = getActivity();
}
让我们同时创建mActivity字段以及 Eclipse 提出的字段。
ActivityInstrumentationTestCase2.getActivity()方法有一个副作用。如果被测试的Activity没有运行,它将被启动。如果我们多次在测试中使用getActivity()作为简单的访问器,并且由于某种原因Activity在测试完成前结束或崩溃,这可能会改变测试的意图。我们将无意中重新启动Activity,这就是为什么在我们的测试中我们不建议使用getActivity(),而是将其包含在测试用例中。
测试前置条件
我们之前提到过,这可以被视为另一种模式。测试所有前置条件并确保我们的测试用例已正确创建是非常有用的。
public final void testPreconditions() {
assertNotNull(mActivity);
}
也就是说,让我们检查我们的测试用例是否由“非空”值组成。
我们可以运行测试以验证一切是否正确且绿色,如图中所示:

创建用户界面
回到我们的测试驱动开发轨道,我们需要从我们简洁的需求列表中知道,分别有两个条目用于摄氏度和华氏温度。所以让我们将它们添加到我们的测试用例中。
它们目前还不存在,我们甚至还没有开始设计用户界面布局,但我们确信应该有两个这样的条目。
这是您应该添加到setUp()方法中的代码:
mCelsius = (EditText)
mActivity.findViewById(com.example.aatg.tc.R.id.celsius);
mFahrenheit = (EditText) mActivity.findViewById(com.example.aatg.tc.R.id.fahrenheit);
有一些重要的事情需要注意:
-
我们使用
EditText定义我们的测试用例字段,这是我们应该导入的 -
我们使用之前创建的
mActivity通过 ID 查找Views -
我们使用主项目的 R 类,而不是测试项目的 R 类
测试用户界面组件的存在
如前一小节所示,一旦我们将它们添加到setUp()方法中,我们就可以在特定的测试中检查它们的存在:
public final void testHasInputFields() {
assertNotNull(mCelsius);
assertNotNull(mFahrenheit);
}
我们目前还不能运行测试,因为我们必须首先修复一些编译问题。我们应该修复 R 类中缺失的 ID。
在创建了引用用户界面中尚未存在的元素和 ID 的测试用例后,测试驱动开发范式要求我们添加必要的代码以满足我们的测试。我们首先应该做的是至少让它编译通过,所以如果我们有一些测试正在测试未实现的功能,它们将会失败。
定义 ID
我们的第一步应该是让用户界面元素的 ID 在R类中定义,这样引用未定义常量com.example.aatg.tc.R.id.celsius和com.example.aatg.tc.R.id.fahrenheit产生的错误就会消失。
您作为一位经验丰富的 Android 开发者,知道如何做。无论如何,我会给您一个复习。在布局编辑器中打开main.xml布局,并添加所需用户界面组件以获得类似于在用户界面概念设计部分中先前引入的设计。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent"> <TextView
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:text="@string/message" />
<TextView
android:id="@+id/celsius_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/celsius" />
<EditText
android:id="@+id/celsius"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:text="EditText" />
<TextView
android:id="@+id/fahrenheit_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/fahrenheit" />
<EditText
android:id="@+id/fahrenheit"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:text="EditText" />
</LinearLayout>
这样做可以使我们的测试编译通过。运行它们,我们得到以下结果:
-
testPreconditions测试成功 -
testHasInputFields测试成功 -
现在一切正常
这清楚地表明我们在应用 TDD 方面进展顺利。
您可能也注意到了,我们在用户界面中添加了一些装饰性和非功能性项目,这些项目我们并未进行测试,主要是为了使我们的示例尽可能简单。在实际场景中,您可能也想对这些元素进行测试。
将需求转换为测试
测试有两个特性。它们验证我们代码的正确性,但有时,尤其是在 TDD 中,它们帮助我们理解设计和消化我们正在实现的内容。为了能够创建测试,我们需要理解我们正在处理的问题,如果我们不了解,我们至少应该有一个对该问题的粗略理解,以便我们能够处理它。
许多时候,用户界面的背后需求并没有明确表达,您应该能够从 UI 设计图解中理解它们。如果我们假设这是情况,那么我们可以通过先编写测试来掌握它。
空字段
从我们的一个需求中,我们得到:输入字段应从空开始。
为了在测试中表达这一点,我们可以编写:
public final void testFieldsShouldStartEmpty() {
assertEquals("", mCelsius.getText().toString());
assertEquals("", mFahrenheit.getText().toString());
}
这里,我们只是将字段的初始内容与空字符串进行比较。
并不出奇,我们发现测试在执行时失败了。我们忘记清除字段的初始内容,它们并不是空的。即使我们没有向这些字段的 android:text 属性添加任何值,ADT 插件布局编辑器也会添加一些默认值。因此,从 android:text="@~+id/EditText01" 和 android:text="@+id/EditText02" 中移除默认值将强制从空温度字段开始。这些值可能是 ADT 插件本身添加的,或者可能是您在输入属性时添加的。
再次运行测试后,我们发现它通过了。我们成功地将一个需求转换为测试,并通过获取测试结果来验证它。
视图属性
同样,我们可以验证组成我们布局的 Views 的其他属性。例如,我们可以验证:
-
字段如预期出现在屏幕上
-
字体大小
-
边距
-
屏幕对齐
让我们开始验证字段是否显示在屏幕上:
public final void testFieldsOnScreen() {
final Window window = mActivity.getWindow();
final View origin = window.getDecorView();
assertOnScreen(origin, mCelsius);
assertOnScreen(origin, mFahrenheit);
}
如前所述,我们在这里使用断言形式:ViewAsserts: assertOnScreen。
备注
静态导入和如何在 Eclipse 的内容辅助中添加它们在第三章中已解释,Android SDK 的构建块。如果您之前没有这样做,现在是时候了。
assertOnScreen 方法需要一个起点来开始寻找其他 Views。在这种情况下,因为我们想从最高级别开始,所以我们使用 getDecorView(),它检索包含标准窗口框架和装饰以及客户端内容的顶级窗口装饰视图。
通过运行这个测试,我们可以确保输入字段按照 UI 设计显示在屏幕上。在某种程度上,我们已知具有这些特定 ID 的一些Views存在。也就是说,我们通过将Views添加到主布局中来使测试用例编译,但我们不确定它们是否真的出现在屏幕上。所以,除了这个测试的唯一存在外,不需要其他任何东西来确保将来条件不会改变。如果我们出于某种原因删除了一个字段,这个测试将告诉我们它缺失并且不符合 UI 设计。
按照我们的需求列表,我们应该测试Views在布局中对齐的方式是否符合我们的预期:
public final void testAlignment() {
assertLeftAligned(mCelsiusLabel, mCelsius);
assertLeftAligned(mFahrenheitLabel, mFahrenheit);
assertLeftAligned(mCelsius, mFahrenheit);
assertRightAligned(mCelsius, mFahrenheit);
}
我们继续使用来自ViewAssert的断言——在这个例子中,assertLeftAligned和assertRightAligned。这些方法验证指定Views的对齐方式。
我们默认使用的LinearLayout以我们期望的方式排列字段。同样,虽然我们不需要向布局中添加任何东西,为了满足测试,这将作为一个保护条件。
一旦我们验证它们已经正确对齐,我们就应该验证它们是否覆盖了由原理图指定的整个屏幕宽度。在这个例子中,验证LayoutParams具有正确的值就足够了:
public final void testCelsiusInputFieldCoverEntireScreen() {
final int expected = LayoutParams.MATCH_PARENT;
final LayoutParams lp = mCelsius.getLayoutParams();
assertEquals("mCelsius layout width is not MATCH_PARENT", expected, lp.width);
}
public final void testFahrenheitInputFieldCoverEntireScreen() {
final int expected = LayoutParams.MATCH_PARENT;
final LayoutParams lp = mFahrenheit.getLayoutParams();
assertEquals("mFahrenheit layout width is not MATCH_PARENT", expected, lp.width);
}
我们使用自定义消息来在测试失败时轻松识别问题。
通过运行这个测试,我们得到以下消息,表明测试失败:
junit.framework.AssertionFailedError: mCelsius 布局宽度不是 MATCH_PARENT,期望<-1>但实际是<-2>
这引导我们到布局定义。我们必须将摄氏度和华氏度字段的layout_width更改为match_parent。
<EditText android:layout_height="wrap_content"
android:id="@+id/celsius" android:layout_width="match_parent"
/>
对于华氏度也是如此——更改完成后,我们重复循环,再次运行测试,可以验证它现在成功了。
我们的方法开始显现。我们创建测试来验证需求中描述的条件。如果没有满足,我们改变问题的原因并再次运行测试,以验证最新的更改解决了问题,也许更重要的是,这个更改并没有破坏现有的代码。
接下来,让我们验证字体大小是否符合我们的要求:
public final void testFontSizes() {
final float expected = 24.0f;
assertEquals(expected, mCelsiusLabel.getTextSize());
assertEquals(expected, mFahrenheitLabel.getTextSize());
}
在这个情况下,获取字段使用的字体大小就足够了。
默认字体大小不是24px,因此我们需要将其添加到我们的布局中。将相应的尺寸添加到资源文件中,然后在布局中需要的地方使用它是一个好习惯。所以,让我们将label_text_size添加到res/values/dimens.xml中,其值为24px。然后在其Text size属性中引用它,对于两个标签celsius_label和fahrenheit_label。
现在测试通过了。
最后,让我们验证边距是否被解释为用户界面设计中所描述的那样:
public final void testMargins() {
LinearLayout.LayoutParams lp;
final int expected = 6;
lp = (LinearLayout.LayoutParams) mCelsius.getLayoutParams();
assertEquals(expected, lp.leftMargin);
assertEquals(expected, lp.rightMargin);
lp = (LinearLayout.LayoutParams) mFahrenheit.getLayoutParams();
assertEquals(expected, lp.leftMargin);
assertEquals(expected, lp.rightMargin);
}
这与之前的情况类似。我们需要将这个添加到我们的布局中。让我们将边距维度添加到资源文件中,然后在布局中需要的地方使用它。将margin维度在res/values/dimens.xml中设置为6px的值。然后在其Margin属性中引用两个字段,即celsius和fahrenheit,以及标签的Left margin。
剩下的一个问题是验证输入值的合理性。我们将很快验证输入,只允许允许的值,但现在让我们只关注合理性。目的是使小于整个字段值的值右对齐并垂直居中:
public final void testJustification() {
final int expected = Gravity.RIGHT|Gravity.CENTER_VERTICAL;
int actual = mCelsius.getGravity();
assertEquals(String.format("Expected 0x%02x but was 0x%02x", expected, actual), expected, actual);
actual = mFahrenheit.getGravity();
assertEquals(String.format("Expected 0x%02x but was 0x%02x", expected, actual), expected, actual);
}
这里我们像往常一样验证重力值。然而,我们使用一个自定义消息来帮助我们识别可能错误的值。由于Gravity类定义了几个常量,其值如果以十六进制表示则更容易识别,因此我们在消息中将值转换为这个基数。
如果这个测试因为字段使用的默认重力而失败,那么唯一剩下的事情就是改变它。转到布局定义并更改这些重力值,以便测试成功。
这正是我们需要添加的:
android:gravity="right|center_vertical"
屏幕布局
我们现在想验证指定为保留足够屏幕空间以显示键盘的要求是否实际上得到了满足。
我们可以编写一个像这样的测试:
public final void testVirtualKeyboardSpaceReserved() {
final int expected = 280;
final int actual = mFahrenheit.getBottom();
assertTrue(actual <= expected);
}
这验证了屏幕上最后一个字段的实际位置,即mFahrenheit,不低于一个建议的值。
我们可以再次运行测试,验证一切是否再次变为绿色。
添加功能
用户界面已经就绪。现在我们开始添加一些基本功能。
这个功能将包括处理实际温度转换的代码。
温度转换
从需求列表中,我们可以获得这个陈述:当在一个字段中输入一个温度时,另一个字段会自动更新为转换值。
按照我们的计划,我们必须将其实现为一个测试来验证正确的功能是否存在。我们的测试看起来可能像这样:
@UiThreadTest
public final void testFahrenheitToCelsiusConversion() { mCelsius.clear();
mFahrenheit.clear();
final double f = 32.5;
mFahrenheit.requestFocus(); mFahrenheit.setNumber(f);
mCelsius.requestFocus(); final double expectedC =
TemperatureConverter.fahrenheitToCelsius(f);
final double actualC = mCelsius.getNumber();
final double delta = Math.abs(expectedC - actualC);
final String msg = "" + f + "F -> " + expectedC + "C but was " + actualC + "C (delta " + delta + ")";
final String msg = "" + f + "F -> " + expectedC + "C but was " + actualC + "C (delta " + delta + ")";
assertTrue(msg, delta < 0.005);
}
首先,正如我们已经知道的,为了与 UI 交互并更改其值,我们应该在 UI 线程上运行测试,因此它被注解为@UiThreadTest。
其次,我们正在使用一个专门的类来替换EditText,提供一些便利方法,如clear()或setNumber()。这将改善我们的应用程序设计。
接下来,我们调用一个名为TemperatureConverter的转换器,这是一个提供在不同温度单位之间转换的不同方法的实用类,并使用不同的类型表示温度值。
最后,由于我们将截断结果以在用户界面中以合适的格式呈现,我们应该比较一个 delta 来断言转换的值。
按照这种方式创建测试将迫使我们遵循计划路径。我们的第一个目标是添加必要的代码以使测试可编译,然后满足测试的需求。
EditNumber 类
在我们的主项目中,而不是在测试项目中,我们应该创建扩展 EditText 的 EditNumber 类,因为我们需要扩展其功能。
我们使用 Eclipse 的帮助使用 文件 | 新建 | 类 或工具栏中的快捷键来创建此类。
此截图显示了使用此快捷键后出现的窗口:

下表描述了上一屏幕中最重要的字段及其含义:
| 字段 | 描述 |
|---|---|
| 源文件夹: | 新创建类的源文件夹。在这种情况下,默认位置是合适的。 |
| 包: | 新类创建的包。在这种情况下,默认包 com.example.aatg.tc 也是合适的。 |
| 名称: | 类的名称。在这种情况下,我们使用 EditNumber。 |
| 修饰符: | 类的修饰符。在这种情况下,我们正在创建一个公共类。 |
| 超类: | 新创建类型的超类。我们正在创建一个自定义的 View 并扩展 EditText 的行为,因此这正是我们选择的超类。记住使用 浏览... 来找到正确的包。 |
| 您想创建哪些方法占位符? | 这些是我们希望 Eclipse 为我们创建的方法占位符。选择 从超类创建构造函数 和 继承的抽象方法 将非常有帮助。由于我们正在创建一个自定义视图,我们应该提供在不同情况下使用的构造函数,例如当自定义视图在 XML 布局中使用时。 |
| 您想添加注释吗? | 当选择此选项时,会自动添加一些注释。您可以配置 Eclipse 以个性化这些注释。 |
一旦创建了类,我们首先需要在测试中更改字段的类型:
public class TemperatureConverterActivityTests extends
ActivityInstrumentationTestCase2<TemperatureConverterActivity> {
private TemperatureConverterActivity mActivity; private EditNumber mCelsius;
private EditNumber mFahrenheit;
private TextView mCelsiusLabel;
private TextView mFahrenheitLabel;
…
然后更改测试中存在的任何类型转换。Eclipse 将帮助您完成此操作。
如果一切顺利,在能够编译测试之前,我们还需要解决两个问题:
-
我们仍然没有在
EditNumber中clear()和setNumber()方法 -
我们没有
TemperatureConverter工具类
要创建我们正在使用的方法,我们使用 Eclipse 的有用操作。让我们选择 在类型 EditNumber 中创建方法 clear()。
对于 setNumber() 和 getNumber() 也是如此。
最后,我们必须创建 TemperatureConverter 类。
小贴士
确保在主项目中创建它,而不是在测试项目中。

完成这些后,在我们的测试中选择 在类型 TemperatureConverter 中创建方法 fahrenheitToCelsius。
这解决了我们最后一个问题,并使我们能够编译和运行测试。
惊讶的是,或者不是,当我们运行测试时,它们将因异常而失败:
09-06 13:22:36.927: INFO/TestRunner(348): java.lang.ClassCastException: android.widget.EditText
09-06 13:22:36.927: INFO/TestRunner(348): at com.example.aatg.tc.test.TemperatureConverterActivityTests.setUp(TemperatureConverterActivityTests.java:41)
09-06 13:22:36.927: INFO/TestRunner(348): at junit.framework.TestCase.runBare(TestCase.java:125)
这是因为我们更新了所有的 Java 文件以包含我们新创建的EditNumber类,但忘记了更改 XML,这只能在运行时检测到。
让我们继续更新我们的 UI 定义:
<com.example.aatg.tc.EditNumber
android:layout_height="wrap_content"
android:id="@+id/celsius"
android:layout_width="match_parent"
android:layout_margin="@dimen/margin"
android:gravity="right|center_vertical"
android:saveEnabled="true" />
也就是说,我们将原始的EditText替换为com.example.aatg.tc.EditNumber,这是一个扩展原始EditText的View。
现在我们再次运行测试,并发现所有测试都通过了。
但等等,我们还没有在新的EditNumber类中实现任何转换或对值的处理,所有测试都顺利通过,没有任何问题。是的,它们通过了,因为我们系统中没有足够的限制,现有的限制只是相互抵消。
在进一步之前,让我们分析一下刚才发生的事情。我们的测试调用了mFahrenheit.setNumber(f)方法来设置在华氏字段中输入的温度,但setNumber()没有实现,它是一个由 Eclipse 生成的空方法,什么也不做。所以字段仍然是空的。
接下来,通过调用TemperatureConverter.fahrenheitToCelsius(f)计算expectedC的值——预期的摄氏温度,但这也是一个由 Eclipse 生成的空方法。在这种情况下,因为 Eclipse 知道返回类型,它返回一个常数 0。所以expectedC变为 0。
然后从 UI 中获取转换的实际值。在这种情况下,通过EditNumber调用getNumber()。但又一次,这个方法是由 Eclipse 自动生成的,为了满足其签名强加的限制,它必须返回一个由 Eclipse 填充为 0 的值。
Δ值再次为 0,这是通过Math.abs(expectedC - actualC)计算得出的。
最后,我们的断言assertTrue(msg, delta < 0.005)为真,因为delta=0满足条件,测试通过。
那么,我们的方法论有缺陷吗?它不能检测像这种情况这样的简单情况?
不,一点也不是。这里的问题是,我们没有足够的限制,并且它们被 Eclipse 用于完成自动生成方法的默认值所满足。一个替代方案可能是抛出所有自动生成方法的异常,例如RuntimeException("not yet implemented"),以检测未实现时的使用。但我们将添加足够的限制到我们的系统中,以便轻松捕获这种条件。
温度转换器单元测试
从我们以前的经验来看,Eclipse 实现的默认转换总是返回 0,所以我们需要更健壮的东西。否则,这只会当参数取值为 32F 时返回有效结果。
TemperatureConverter 是一个与 Android 基础设施无关的实用工具类,因此标准的单元测试就足够测试它了。
我们使用 Eclipse 的 文件 | 新建 | JUnit 测试用例 来创建测试,填写一些适当的值,并选择生成测试的方法,如下一张截图所示。
首先,我们通过扩展 junit.framework.TestCase 并选择 com.example.aatg.tc.TemperatureConverter 作为测试的类来创建单元测试:

然后通过按下 下一步 > 按钮,我们可以获得我们可能想要测试的方法列表:

我们在 TemperatureConverter 中只实现了一个方法,所以它就是列表中唯一出现的方法。实现更多方法的其它类将在这里显示所有选项。
值得注意的是,即使测试方法是 Eclipse 自动生成的,它也不会通过。它会失败,并显示消息 Not yet implemented 来提醒我们某些东西缺失。
让我们先改变这个:
/**
* Test method for {@link com.example.aatg.tc. TemperatureConverter#fahrenheitToCelsius(double)}.
*/
public final void testFahrenheitToCelsius() { for (double c: conversionTableDouble.keySet()) {
final double f = conversionTableDouble.get(c);
final double ca = TemperatureConverter.fahrenheitToCelsius(f);
final double delta = Math.abs(ca - c);
final String msg = "" + f + "F -> " + c + "C but is " + ca + " (delta " + delta + ")";
assertTrue(msg, delta < 0.0001);
}
}
创建一个包含不同温度转换值的转换表,这些值来自其他来源,这将是一个驱动测试的好方法。
private static final HashMap<Double, Double> conversionTableDouble = new HashMap<Double, Double>();
static {
// initialize (c, f) pairs
conversionTableDouble.put(0.0, 32.0);
conversionTableDouble.put(100.0, 212.0);
conversionTableDouble.put(-1.0, 30.20);
conversionTableDouble.put(-100.0, -148.0);
conversionTableDouble.put(32.0, 89.60);
conversionTableDouble.put(-40.0, -40.0);
conversionTableDouble.put(-273.0, -459.40);
}
我们可以运行这个测试来验证它是否失败,给出以下跟踪信息:
junit.framework.AssertionFailedError: -40.0F -> -40.0C but is 0.0 (delta 40.0)
at com.example.aatg.tc.test.TemperatureConverterTests.testFahrenheitToCelsius(TemperatureConverterTests.java:62)
at java.lang.reflect.Method.invokeNative(Native Method)
at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:169)
at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:154)
at android.test.InstrumentationTestRunner.onStart(InstrumentationTestRunner.java:520)
at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:1447)
嗯,这正是我们所预期的,因为我们的转换总是返回 0。实现我们的转换后,我们发现我们需要一个 ABSOLUTE_ZERO_F 常量:
public class TemperatureConverter {
public static final double ABSOLUTE_ZERO_C = -273.15d;
public static final double ABSOLUTE_ZERO_F = -459.67d;
private static final String ERROR_MESSAGE_BELOW_ZERO_FMT =
"Invalid temperature: %.2f%c below absolute zero";
public static double fahrenheitToCelsius(double f) {
if (f < ABSOLUTE_ZERO_F) {
throw new InvalidTemperatureException(
String.format(ERROR_MESSAGE_BELOW_ZERO_FMT, f, 'F'));
}
return ((f - 32) / 1.8d);
}
}
绝对零是熵达到其最小值的理论温度。根据热力学定律,为了能够达到这个绝对零状态,系统应该与宇宙的其余部分隔离。因此,这是一个无法达到的状态。然而,根据国际协议,绝对零被定义为开尔文温标上的 0K,摄氏温标上的 -273.15°C,或者华氏温标上的 -459.67°F。
我们正在创建一个自定义异常 InvalidTemperatureException,以指示向转换方法提供有效温度时失败。这个异常是通过扩展 RuntimeException: 简单创建的。
public class InvalidTemperatureException extends RuntimeException {
public InvalidTemperatureException(String msg) {
super(msg);
}
}
再次运行测试后,我们发现 testFahrenheitToCelsiusConversion 测试失败,而 testFahrenheitToCelsius 测试成功。这告诉我们现在转换已经被转换器类正确处理,但仍然存在一些与 UI 处理这个转换相关的问题。
仔细查看失败跟踪,我们发现当不应该返回 0 时,仍然有东西返回了 0。
这提醒我们,我们仍然缺少一个合适的 EditNumber 实现。在继续实现所提到的方法之前,让我们创建相应的测试来验证我们所实现的是否正确。
EditNumber 测试
从上一章,我们现在可以确定,对于我们的自定义 View 测试,最佳基类是 AndroidTestCase,因为我们需要一个模拟的 Context 来创建自定义 View,但我们不需要系统基础设施。
这是我们必须完成的对话框,以创建测试。在这种情况下,使用 android.test.AndroidTestCase 作为基类,并将 com.example.aatg.tc.EditNumber 作为待测试的类:

在按下 下一步 > 后,我们选择要创建存根的方法:

我们需要更新自动生成的构造函数,以反映我们之前确定的模式,即给定的名称模式:
/**
* Constructor
*/
public EditNumberTests() {
this("EditNumberTests");
}
/**
* @param name
*/
public EditNumberTests(String name) {
setName(name);
}
下一步是创建测试用例。在这种情况下,这是一个简单的 EditNumber,我们将对其进行测试:
/* (non-Javadoc)
* @see junit.framework.TestCase#setUp()
*/
protected void setUp() throws Exception {
super.setUp();
mEditNumber = new EditNumber(mContext);
mEditNumber.setFocusable(true);
}
模拟的上下文是从 AndroidTestCase 类中可用的受保护字段 mContext (developer.android.com/reference/android/test/AndroidTestCase.html#mContext) 获取的。
在测试结束时,我们将 mEditNumber 设置为可聚焦的 View,这意味着它将能够获得焦点,因为它将参与一系列模拟 UI 的测试,这些测试可能需要显式请求其焦点。
接下来,我们测试在 testClear() 方法中正确实现了所需的 clear() 功能:
/**
* Test method for {@link com.example.aatg.tc.EditNumber#clear()}.
*/
public final void testClear() {
final String value = "123.45";
mEditNumber.setText(value);
mEditNumber.clear();
String expectedString = "";
String actualString = mEditNumber.getText().toString();
assertEquals(expectedString, actualString);
}
运行测试以验证它失败:
junit.framework.ComparisonFailure: expected:<> but was:<123.45>
在 com.example.aatg.tc.test.EditNumberTests.testClear(EditNumberTests.java:62)
在 android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:169)
在 android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:154)
在 android.test.InstrumentationTestRunner.onStart(InstrumentationTestRunner.java:529)
在 android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:1447)
我们需要正确实现 EditNumber.clear()。
这是一个简单的情况,所以只需将此实现添加到 EditNumber 中,我们就能满足测试:
public void clear() {
setText("");
}
运行测试并继续。现在让我们完成 testSetNumber() 的实现:
/**
* Test method for {@link com.example.aatg.tc.EditNumber#setNumber(double)}.
*/
public final void testSetNumber() {
mEditNumber.setNumber(123.45);
final String expected = "123.45";
final String actual = mEditNumber.getText().toString();
assertEquals(expected, actual);
}
除非我们实现了 EditNumber.setNumber(),类似于这个实现:
private static final String DEFAULT_FORMAT = "%.2f";
public void setNumber(double f) {super.setText( String.format(DEFAULT_FORMAT, f));
}
我们使用一个常量 DEFAULT_FORMAT 来保存要转换数字的期望格式。这可以稍后转换为可以在字段的 xml 布局定义中指定的属性。
对于 testGetNumber() 和 getNumber() 对,情况相同:
/**
* Test method for {@link com.example.aatg.tc.EditNumber#getNumber()}.
*/
public final void testGetNumber() {
mEditNumber.setNumber(123.45);
final double expected = 123.45;
final double actual = mEditNumber.getNumber();
assertEquals(expected, actual);
}
然后:
public double getNumber() {
Log.d("EditNumber", "getNumber() returning value of '" + getText().toString() + "'");
return Double.valueOf(getText().toString());
}
意外地,这些测试成功了。但现在有一个之前通过而现在开始失败的测试:testFahrenheitToCelsiusConversion()。原因是现在我们已经正确实现了EditNumber.setNumber()和EditNumber.getNumber(),一些值返回的方式不同,而这个测试方法依赖于这些虚假的值。
这是运行测试后获得的结果截图:

如果你仔细分析这个案例,你可以发现问题的所在。
明白了?
我们的测试方法期望在焦点改变时自动实现转换,正如我们在需求列表中所指定的:当一个温度值在一个字段中输入时,另一个值会自动通过转换更新。
记住,我们没有按钮或其他任何东西来转换温度值,所以一旦输入了值,转换应该自动完成。
这又让我们回到了TemperatureConverterActivity以及它处理转换的方式。
TemperatureChangeWatcher类
实现所需行为的一种方法是在一个值改变后不断更新另一个温度值,可以通过TextWatcher来实现。从文档中我们可以理解,TextWatcher是一个附加到Editable对象上的对象类型;当文本改变时,它的方法会被调用(developer.android.com/intl/de/reference/android/text/TextWatcher.html)。
这似乎就是我们需要的东西。
我们把这个类实现为TemperatureConverterActivity的内部类。这是在 Eclipse 中创建新 Java 类的截图:

这是我们在最近创建的类中添加一些内容后的代码:
/**
* Changes fields values when text changes applying the corresponding method.
*
*/
public class TemperatureChangedWatcher implements TextWatcher {
private final EditNumber mSource;
private final EditNumber mDest;
private OP mOp;
/**
* @param mDest
* @param convert
* @throws NoSuchMethodException
* @throws SecurityException
*/
public TemperatureChangedWatcher(TemperatureConverter.OP op) {
if ( op == OP.C2F ) {
this.mSource = mCelsius;
this.mDest = mFahrenheit;
}
else {
this.mSource = mFahrenheit;
this.mDest = mCelsius;
}
this.mOp = op;
}
/* (non-Javadoc)
* @see android.text.TextWatcher#afterTextChanged( android.text.Editable)
*/
public void afterTextChanged(Editable s) {
// TODO Auto-generated method stub
}
/* (non-Javadoc)
* @see android.text.TextWatcher#beforeTextChanged( java.lang.CharSequence, int, int, int)
*/
public void beforeTextChanged( CharSequence s, int start, int count, int after) {
// TODO Auto-generated method stub
}
/* (non-Javadoc)
* @see android.text.TextWatcher#onTextChanged( java.lang.CharSequence, int, int, int)
*/
public void onTextChanged(CharSequence s, int start, int before, int count) {
if (!mDest.hasWindowFocus() || mDest.hasFocus() || s == null )
{
return;
}
final String str = s.toString();
if ( "".equals(str) ) {
mDest.setText("");
return;
}
try {
final double temp = Double.parseDouble(str);
final double result = (mOp == OP.C2F) ? TemperatureConverter.celsiusToFahrenheit(temp) :
TemperatureConverter.fahrenheitToCelsius(temp);
final String resultString = String.format("%.2f", result);
mDest.setNumber(result);
mDest.setSelection(resultString.length());
} catch (NumberFormatException e) {
// WARNING
// this is generated while a number is entered,
// for example just a '-'
// so we don't want to show the error
} catch (Exception e) {
mSource.setError("ERROR: " + e.getLocalizedMessage());
}
}
}
我们实现了扩展TextWatcher并重写未实现的方法。
因为我们将使用相同的TemperatureChangeWatcher实现来处理两个字段,摄氏度和华氏度,所以我们保留了对用作源和目标的字段以及更新它们的值的操作的引用。为了指定这个操作,我们在TemperatureConverter类中引入了一个enum。
/**
* C2F: celsiusToFahrenheit
* F2C: fahrenheitToCelsius
*/
public static enum OP { C2F, F2C };
这个操作在构造函数中指定,并且根据需要选择源和目标EditNumber。这样我们就可以为不同的转换使用相同的监视器。
我们主要感兴趣的TextWatcher接口的方法是onTextChanged,它将在文本改变时被调用。一开始我们避免潜在的循环,检查谁有焦点,如果条件不满足则返回。
如果源数据为空,我们也把目标字段设置为空的String。
最后,我们尝试将调用相应转换方法的结果设置到目标字段。根据需要标记错误,避免在转换使用部分输入的数字时显示过早的错误。
我们需要在TemperatureConverterActivity.onCreate():中的字段上设置监听器。
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
mCelsius = (EditNumber) findViewById(R.id.celsius);
mFahrenheit = (EditNumber) findViewById(R.id.fahrenheit); mCelsius.addTextChangedListener(
new TemperatureChangedWatcher(OP.C2F));
mFahrenheit.addTextChangedListener(
new TemperatureChangedWatcher(OP.F2C));
}
为了能够运行测试,我们应该编译它们。为了编译,我们至少需要定义celsiusToFahrenheit,它尚未定义。
更多温度转换器测试
我们需要实现celsiusToFahrenheit,并且像往常一样,我们从测试开始。
这与另一个转换方法fahrenheitToCelsius相当,我们可以使用我们在创建此测试时设计的框架:
/**
* Test method for {@link com.example.aatg.tc.TemperatureConverter#celsiusToFahrenheit(double)}.
*/
public final void testCelsiusToFahrenheit() {
for (double c: conversionTableDouble.keySet()) {
final double f = conversionTableDouble.get(c);
final double fa = TemperatureConverter.celsiusToFahrenheit(c);
final double delta = Math.abs(fa - f);
final String msg = "" + c + "C -> " + f + "F but is " + fa + " (delta " + delta + ")";
assertTrue(msg, delta < 0.0001);
}
}
我们使用转换表通过不同的转换来练习方法,并验证错误小于预定义的 delta。
然后,TemperatureConverter类中的相应转换实现如下:
public static double celsiusToFahrenheit(double c) {
if (c < ABSOLUTE_ZERO_C) {
throw new InvalidTemperatureException(
String.format(ERROR_MESSAGE_BELOW_ZERO_FMT, c, 'C'));
}
return (c * 1.8d + 32);
}
现在所有的测试都通过了,但我们仍然没有测试所有常见的条件。你应该检查是否正确生成了错误和异常,除了到目前为止我们创建的所有正常情况之外。
这是创建的测试,用于检查在转换中使用低于绝对零的温度时正确生成异常:
public final void testExceptionForLessThanAbsoluteZeroF() {
try {
TemperatureConverter.fahrenheitToCelsius( TemperatureConverter.ABSOLUTE_ZERO_F-1);
fail();
}
catch (InvalidTemperatureException ex) {
// do nothing
}
}
在这个测试中,我们将绝对零温度递减以获得更小的值,然后尝试转换。我们检查是否正确捕获了正确的异常,并最终断言这个条件:
public final void testExceptionForLessThanAbsoluteZeroC() {
try {
TemperatureConverter.celsiusToFahrenheit( TemperatureConverter.ABSOLUTE_ZERO_C-1);
fail();
}
catch (InvalidTemperatureException ex) {
// do nothing
}
}
以类似的方式,我们测试当尝试的转换涉及低于绝对零的温度时抛出的异常。
输入过滤器测试
我们希望过滤由转换工具接收到的输入,以便没有垃圾输入到这个点。
EditNumber类已经过滤了有效的输入并生成异常,否则。我们可以通过在TemperatureConverterActivityTests中生成一些新的测试来验证这个条件。我们选择这个类是因为我们向输入字段发送键,就像一个真实用户会做的那样:
public void testInputFilter() throws Throwable {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
mCelsius.requestFocus();
}
});
final Double n = -1.234d;
sendKeys("MINUS 1 PERIOD 2 PERIOD 3 PERIOD 4");
Object nr = null;
try {
nr = mCelsius.getNumber();
}
catch (NumberFormatException e) {
nr = mCelsius.getText();
}
final String msg = "-1.2.3.4 should be filtered to " + n +
" but is " + nr;
assertEquals(msg, n, nr);
}
这个测试请求使用我们之前审查的模式将焦点放在摄氏度字段上,以在 UI 线程中运行测试的部分,然后发送一些键。发送的键是一个包含多个点的无效序列,这对于一个良好的十进制数字是不接受的。预期当过滤器到位时,这个序列将被过滤,并且只有有效的字符到达字段。我们使用可能生成的NumberFormatException来检测错误,然后断言mCelsius.getNumber()返回的值是我们过滤后预期的值。
要实现这个过滤器,我们需要向EditNumber添加一个InputFilter。因为这将添加到我们创建的所有构造函数中,所以我们创建了一个额外的init()方法,并从它们中调用它。为了达到我们的目标,我们使用一个接受数字、符号和小数点的DigitsKeyListener实例。
/**
* Initialization.
* Set filter.
*
*/
private void init() {
// DigistKeyListener.getInstance(true, true) returns an
// instance that accepts digits, sign and decimal point
final InputFilter[] filters = new InputFilter[] { DigitsKeyListener.getInstance(true, true) };
setFilters(filters);
}
Then from the constructors we should invoke this method:
/**
* @param context
* @param attrs
*/
public EditNumber(Context context, AttributeSet attrs) {
super(context, attrs);
init();>
}
这个init方法是从不同的构造函数中分解和调用的。
再次运行测试,我们可以验证所有测试都已通过,现在一切又都是绿色的。
查看我们的最终应用程序
这是我们满足所有要求的应用程序。
在以下屏幕截图中,我们展示了这些要求之一,即检测尝试将温度转换为摄氏度绝对零度以下的情况(-1000.00°C):

UI 遵循提供的指南;可以在相应的单位字段中输入温度进行转换。
总结一下,这是需求列表:
-
该应用程序可以将温度从摄氏度转换为华氏度,反之亦然
-
用户界面提供了两个输入温度的字段,一个用于摄氏度,另一个用于华氏度
-
当在一个字段中输入一个温度时,另一个字段会自动更新为转换值
-
如果有错误,它们应该显示给用户,可能使用相同的字段
-
用户界面应预留一些空间用于屏幕键盘,以便在输入多个转换时简化应用程序操作。
-
输入字段应保持为空
-
输入的值是带有小数点后两位数字的十进制值
-
数字是右对齐的
但也许更重要的是,我们可以确保应用程序不仅满足要求,而且没有明显的错误或漏洞,因为我们通过分析测试结果并在问题首次出现时修复它们来采取每一步。这将确保一旦发现相同的错误,它将不会再次出现。
摘要
我们介绍了测试驱动开发,介绍了其概念,并在之后逐步在一个潜在的真实问题中应用它们。
我们从一个简洁的需求列表开始,描述了温度转换应用程序。
然后,我们实现了每个测试,随后是实现满足它的代码。以这种方式,我们实现了应用程序的行为以及其展示,通过测试来验证我们设计的 UI 是否遵循规范。
进行测试使我们能够分析运行它们的不同可能性,下一章将专注于测试环境。
第五章:Android 测试环境
我们构建了我们的应用程序和一套合理的测试,我们运行这些测试以验证温度转换应用程序的基本方面和行为。现在,是时候提供不同的条件来运行这些测试,其他测试,甚至手动运行应用程序以了解使用时的用户体验了。
在本章中,我们将涵盖:
-
创建Android 虚拟设备(AVD)以提供不同的条件和配置
-
理解在创建 AVD 时可以指定的不同配置
-
如何运行 AVD
-
如何将 AVD 从其窗口中分离出来以创建无头模拟器
-
解锁屏幕以便运行所有测试
-
模拟现实生活中的网络条件
-
运行
monkey生成事件以发送到应用程序
创建 Android 虚拟设备
为了获得最佳机会来检测与应用程序运行设备相关的问题,你需要尽可能广泛的功能和配置覆盖。
虽然最终和结论性的测试应该始终在具有日益增长的设备数量的真实设备上运行,但你几乎不可能拥有每种设备来测试你的应用程序。云中也有设备农场来测试各种设备,但它的成本有时超过了普通开发者的预算。希望 Android 提供了一种从模拟器 AVD 配置的便利性中,或多或少地模拟出各种功能和配置的方法。
注意
本章中的所有示例都是在 Ubuntu 10.04(Lucid Lynx)64 位操作系统上运行的,使用Android SDK 和 AVD 管理器修订版 10 和Android SDK,安装了平台 2.3(API 9)。
要创建 AVD,你可以使用命令行中的android,甚至可以从 Eclipse 内部使用窗口 | Android SDK 和 AVD 管理器或其快捷图标来完成。
运行命令后,你可以访问Android SDK 和 AVD 管理器,在那里你按下新建...按钮来创建一个新的 AVD,并显示此对话框:

如果你按下创建 AVD,你将使用默认值完成 AVD 的创建。然而,如果你需要支持不同的配置,你可以通过使用新建...按钮来指定不同的硬件属性。
可以设置的属性包括:
| 属性 | 类型 | 描述 |
|---|---|---|
| 摄像头支持 | 布尔值 | 设备是否有摄像头。 |
| 缓存分区大小 | 整数 | 缓存分区的大小。 |
| SD 卡支持 | 布尔值 | 设备是否支持插入和移除虚拟 SD 卡。 |
| 缓存分区支持 | 布尔值 | 是否支持缓存分区。通常这个分区挂载在/cache。 |
| 键盘支持 | 布尔值 | 设备是否具有物理 QWERTY 键盘。 |
| 音频播放支持 | 布尔值 | 设备是否可以播放音频 |
| 音频录制支持 | 布尔值 | 设备是否可以录制音频。 |
| 方向键支持 | 布尔值 | 设备是否有方向键。 |
| 最大垂直摄像头像素 | 整数 | 虚拟摄像头的最大垂直尺寸(以像素为单位)。 |
| 加速计 | 布尔值 | 设备是否有加速度计。 |
| GPS 支持 | 布尔值 | 设备是否有 GPS。 |
| 设备 RAM 大小 | 整数 | 设备上的物理 RAM 量。这以兆字节表示。 |
| 触摸屏支持 | 布尔值 | 设备上是否有触摸屏。 |
| 电池支持 | 布尔值 | 设备是否可以由电池供电。 |
| GSM 调制解调器支持 | 布尔值 | 设备中是否有 GSM 调制解调器。 |
| 轨迹球支持 | 布尔值 | 设备上是否有轨迹球。 |
| 最大水平摄像头像素 | 整数 | 虚拟摄像头的最大水平尺寸(以像素为单位)。 |
在按下开始...以启动 AVD 后,你可以选择其他属性:

设置缩放也非常有用,可以在类似于真实设备大小的窗口中测试你的应用程序。在 AVD 中测试应用程序,窗口大小至少是真实设备大小的两倍,并使用鼠标指针相信一切正常,然后在 5 或 6 英寸的物理设备屏幕上意识到 UI 上的某些项目用手指是无法触摸的,这是一个非常常见的错误。
为了缩放 AVD 屏幕,你还应该将监视器 dpi设置为与您使用的监视器相对应的值。
最后,反复在相同条件下测试你的应用程序也是有帮助的。为了能够反复在相同条件下进行测试,有时删除之前会话中输入的所有信息是有帮助的。如果是这种情况,请检查清除用户数据以每次从头开始。
从命令行运行 AVDs
如果我们能够从命令行运行不同的 AVD,并且可能自动化我们的测试或为其编写脚本,那岂不是很好?
通过将 AVD 从其窗口中解放出来,打开了一个全新的自动化和脚本编写可能性世界。
好吧,让我们探索这些替代方案。
无头模拟器
当我们运行自动化测试且没有人查看窗口,或者测试运行器与应用程序之间的交互非常快以至于我们几乎看不到任何东西时,无头模拟器(其窗口不显示)非常有用。
无论如何,也值得提到的是,有时你只有在看到屏幕上的交互后才能理解为什么某些测试失败,所以请根据一些判断使用这两种替代方案。
我们在运行 AVD 时可能注意到的一件事是,它们的通信端口是在运行时分配的,通过将最后使用的端口增加 2 并从 5554 开始。这是用来命名模拟器和设置其序列号的,例如,使用端口 5554 的模拟器变为emulator-5554。这在我们在开发过程中运行 AVD 时非常有用,因为我们不需要注意端口分配。但如果同时运行多个模拟器,这可能会非常混乱且难以追踪哪个测试运行在哪个模拟器上。
在那些情况下,我们将为通信端口分配已知的端口,以保持特定 AVD 在我们控制之下。
通常,当我们同时运行多个模拟器上的测试时,我们不仅想要断开窗口,还想要避免声音输出。我们也将为此添加选项:
-
启动我们刚刚创建的测试 AVD 的命令行将是:
$ emulator -avd test -no-window -no-audio -no-boot-anim -port 5580 & -
端口必须是一个介于 5554 和 5584 之间的整数:
$ adb devices List of devices attached emulator-5580 device这显示了设备在设备列表中的显示。
-
下一步是安装应用程序和测试:
$ adb -s emulator-5580 install\ TemperatureConverter/bin/TemperatureConverter.apk 347 KB/s (16632 bytes in 0.046s) pkg: /data/local/tmp/TemperatureConverter.apk Success $ adb -s emulator-5580 install\ TemperatureConverterTest/bin/TemperatureConverterTest.apk 222 KB/s (16632 bytes in 0.072s) pkg: /data/local/tmp/TemperatureConverterTest.apk Success -
然后,我们可以使用指定的序列号来运行测试:
$ adb -s emulator-5580 shell am instrument -w\ com.example.aatg.tc.test/android.test.InstrumentationTestRunner com.example.aatg.tc.test.EditNumberTests:...... com.example.aatg.tc.test.TemperatureConverterActivityTests:.......... com.example.aatg.tc.test.TemperatureConverterTests:.... Test results for InstrumentationTestRunner=.................... Time: 25.295 OK (20 tests)
禁用键 guard
我们可以看到测试在没有干预的情况下运行,并且不需要访问模拟器 GUI。
但有时,如果你以更标准的方式运行,比如从 Eclipse 启动的标准模拟器,你可能会收到一些测试未失败的错误。在这种情况下,其中一个原因是模拟器可能在第一屏被锁定,我们需要解锁它才能运行涉及 UI 的测试。
要解锁屏幕,你可以使用:
$ adb -s emulator-5580 emu event send EV_KEY:KEY_MENU:1 EV_KEY:KEY_MENU:0
锁屏也可以通过程序来禁用;然而,这有一个缺点,就是会将测试相关的代码包含在你的应用程序中。一旦应用程序准备发布,这些代码应该被移除或禁用。
要做到这一点,应该在清单文件(AndroidManifest.xml)中添加以下权限,然后在测试中的应用程序中禁用屏幕锁。
要添加权限,将此元素添加到清单中:
<manifest>
...
<uses-permission android:name="android.permission.DISABLE_KEYGUARD"/>
...
</manifest>
然后在测试的Activity中添加以下代码,最好在onResume()中添加:
mKeyGuardManager = (KeyguardManager) getSystemService(KEYGUARD_SERVICE);
mLock = mKeyGuardManager.newKeyguardLock("com.example.aatg.tc");
mLock.disableKeyguard();
即,获取KeyguardManager,然后指定一个标签来获取KeyguardLock,自定义包名以便能够调试谁正在禁用键 guard。
然后使用disableKeyguard()禁用显示键 guard。如果键 guard 当前正在显示,它将被隐藏。只有在调用reenableKeyguard()之前,键 guard 才会被阻止再次显示。
清理
在某些情况下,你还需要清理在运行一些测试后启动的服务和进程,以防止后续测试的结果受到先前测试结束条件的影响。在这些情况下,始终从已知条件开始,释放所有使用的内存,停止服务,重新加载资源,并重新启动进程,这是通过热启动模拟器实现的。
$ adb -s emulator-5580 shell 'stop; sleep 5; start'
此命令行为我们打开模拟器 shell 并运行停止和启动命令。
可以使用 logcat 命令来监控这些命令的演变:
$ adb -s emulator-5580 logcat
您将看到类似的消息:
D/AndroidRuntime( 241):
D/AndroidRuntime( 241): >>>>>>>>>>>>>> AndroidRuntime START <<<<<<<<<<<<<<
D/AndroidRuntime( 241): CheckJNI 是开启的
D/AndroidRuntime( 241): --- 注册本地函数 ---
I/SamplingProfilerIntegration( 241): 分析器已禁用。
I/Zygote ( 241): 预加载类...
D/dalvikvm( 241): GC_EXPLICIT freed 816 objects / 47208 bytes in 7ms
I/ServiceManager( 28): service 'connectivity' died
I/ServiceManager( 28): service 'throttle' died
I/ServiceManager( 28): service 'accessibility' died
…
注意
这个热启动在 Android 2.2 Froyo 模拟器上工作得不太好,但在 Android 设备上运行得非常完美。已经报告了一个错误,您可以在code.google.com/p/android/issues/detail?id=9814上跟踪其进展。
终止模拟器
一旦我们完成了一个无头模拟器实例的工作,我们就开始使用之前提到的命令。我们使用以下命令行来终止它:
$ adb -s emulator-5580 emu kill
这将停止模拟器,释放使用的资源,并在主机计算机上终止模拟器进程。
额外的模拟器配置
有时候,我们需要测试的内容超出了在创建或配置 AVD 时可以设置的选项范围。
其中一种情况可能是需要在不同的区域设置下测试我们的应用程序。比如说,我们想在日本的手机上测试我们的应用程序——一个将语言和地区分别设置为日语和日本的模拟器。
我们有能力在模拟器命令行中传递这些属性。
-prop 命令行选项允许我们设置我们能够设置的任何属性:
$ emulator -avd test -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 test -no-window -no-audio -no-boot-anim -port 5580\ -wipe-data
然后,模拟器将重新启动。
模拟网络条件
在不同的网络条件下进行测试非常重要,但这种情况往往被忽视。这会导致误解,并认为应用程序的行为不同,因为我们使用了主机网络,它具有不同的速度和延迟。
Android 模拟器支持网络限制,例如支持较慢的网络速度和较高的连接延迟。这可以在模拟器命令行中使用选项 -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> |
选择单独的上行和下行速度 | 上行:指定,下行:指定 |
对于延迟:
| 选项 | 描述 | 延迟 [毫秒] |
|---|---|---|
-netdelay gprs |
GPRS | 最小 150,最大 550 |
-netdelay edge |
EDGE/EGPRS | 最小 80,最大 400 |
-netdelay umts |
UMTS/3G | 最小 35,最大 200 |
-netdelay none |
无延迟 | 最小 0,最大 0 |
-netdelay <num> |
选择精确延迟 | 指定的延迟 |
-netdelay <最小>:<最大> |
选择最小和最大延迟 | 指定的最小和最大延迟 |
如果未指定值,模拟器将使用以下默认值:
-
默认网络速度为 '全速'
-
默认网络延迟为 '无'
这是一个模拟器使用这些选项来选择 14.4 kbits/sec 的 GSM 网络速度和 150 到 500 毫秒的 GPRS 延迟的示例。
$ emulator -avd test -port 5580 -netspeed gsm -netdelay gprs
一旦模拟器运行,你可以使用 Android 控制台通过 TELNET 客户端交互式地验证这些网络设置或更改它们:
$ telnet localhost 5580
尝试 ::1...
尝试 127.0.0.1...
连接到本地主机.
转义字符是 '^]'.
Android 控制台:输入 'help' 查看命令列表
OK
连接后,我们可以输入以下命令:
network status
当前网络状态:
下载速度:14400 比特/秒(1.8 KB/s)
上传速度:14400 比特/秒(1.8 KB/s)
最小延迟:150 毫秒
最大延迟:550 毫秒
OK
你可以使用模拟器手动或自动地测试使用网络服务应用程序。
在某些情况下,这不仅涉及限制网络速度,还涉及更改 GPRS 连接的状态,以调查应用程序如何表现和应对这些情况。要更改此状态,我们还可以在运行的模拟器中使用 Android 控制台。
例如,要注销模拟器从网络,我们可以使用:
$ telnet localhost 5580
尝试 ::1...
尝试 127.0.0.1...
连接到本地主机.
转义字符是 '^]'.
Android 控制台:输入 'help' 查看命令列表
OK
在接收到 OK 子提示符后,我们可以通过以下命令将数据网络模式设置为未注册:
gsm data unregistered
OK
退出
在测试应用程序在此条件下后,你可以通过使用以下命令返回到连接状态:
gsm data home
OK
要验证状态,可以使用:
gsm status
gsm voice state: home
gsm data state: home
OK
额外的 qemu 选项
你可能知道,Android 模拟器基于一个名为 Qemu 的开源项目(qemu.org)。
Qemu 是一个通用的模拟器和虚拟化器。Android 使用其模拟器功能在 PC 或 Mac 等不同机器上运行为不同架构制作的操作系统。它使用动态转换,实现了非常好的性能,好到足以在某些情况下限制模拟速度以类似于真实的 Android 设备。
因此,当你运行模拟器时,你可以添加一些 qemu 特定的选项。
例如,我们可能想打开通过 VNC(虚拟网络计算)可访问的 qemu 控制台,这是另一个开源项目,提供远程帧缓冲功能(en.wikipedia.org/wiki/Virtual_Network_Computing)。在这个控制台中,我们可以发出一些 qemu 特定的命令。
要做到这一点,让我们添加以下选项:
$ emulator -avd test -no-window -no-audio -no-boot-anim -port 5580\ -qemu -vnc :2 &
所有跟随-qemu的选项都将原样传递给 qemu。在这种情况下,我们传递-vnc :2,以打开虚拟显示 2,该显示在 VNC 启动时从 5900 端口开始计数,端口号为 5902。
使用一些 VNC 客户端,如 Vinagre—远程桌面查看器,这是在大多数发行版中 GNOME 桌面提供的,我们可以打开到控制台的连接。Vinagre 可以从 GNOME 桌面通过应用程序 | 互联网 | 远程桌面查看器启动。
在 Microsoft Windows 中,可以使用 RealVNC 作为客户端。
然后我们应该在 qemu 中打开到 VNC 服务器的连接:

我们将看到 qemu 控制台:

可以通过在提示符中输入以下命令来获取内部命令列表:
(qemu) help
这些命令的分析超出了本书的范围,但您可以在 Qemu 网站上找到一些相关信息。
注意
从 Android 2.2(Froyo)开始的最新版本的模拟器有一个 bug,阻止在命令行中指定 qemu 选项(甚至帮助选项(-qemu -h)也不起作用),尽管它们在模拟器帮助(emulator -help)中列出,如下所示:
-qemu args... 将参数传递给 qemu
-qemu -h display qemu help
运行 monkey
你可能听说过无限猴子定理。这个定理指出,一只猴子在打字机上随机按键无限时间后,几乎肯定会打出给定的文本,比如威廉·莎士比亚的完整作品。
这个定理的安卓版本指出,一只猴子在设备上随机触摸可能会导致你的应用程序崩溃,嗯...时间远远少于无限时间。
在这一行中,Android 提供了一个 monkey 应用程序(developer.android.com/guide/developing/tools/monkey.html),它会生成随机事件而不是真正的猴子。
对我们的应用程序运行 monkey 以生成随机事件的最简单方法是:
$ adb -e shell monkey -p com.example.aatg.tc -v -v 1000
你将收到以下输出:
注入事件:1000
:丢失:keys=0 pointers=0 trackballs=0 flips=0
## 网络统计:经过时间=100914ms(0ms 移动,0mswifi,100914ms 未连接)
// 猴子完成
这显示了通过猴子注入的事件的详细信息。
猴子只会将事件发送到指定的包(-p),在这个例子中是com.example.aatg.tc,并以非常冗长的方式(-v -v)发送。发送的事件数量将是 1000。
客户端-服务器猴子
运行猴子的另一种方式。它也提供了一个客户端-服务器模型,最终允许创建控制发送哪些事件以及不依赖于随机生成的脚本。
通常猴子使用的端口是 1080,但如果你有更好的偏好,可以使用另一个端口。
$ adb -e shell monkey -p com.example.aatg.tc --port 1080 &
然后我们需要重定向模拟器端口:
$ adb -e forward tcp:1080 tcp:1080
现在我们已经准备好发送事件。要手动发送,我们可以使用 TELNET 客户端:
$ telnet localhost 1080
尝试 ::1...
尝试 127.0.0.1...
连接到 localhost.
退出字符是'^]'.
连接建立后,我们可以输入特定的猴子命令:
tap 150 200
OK
要结束此操作,请退出 TELNET 命令。
如果我们需要重复练习应用程序,创建一个包含我们想要发送的命令的脚本会更为方便。一个猴子脚本可能看起来像这样:
# monkey
tap 100 180
type 123
tap 100 280
press DEL
press DEL
press DEL
press DEL
press DEL
press DEL
press DEL
press DEL
type -460.3
事件及其参数在这里定义。
在启动温度转换器应用程序后,我们可以运行此脚本以练习用户界面。要启动应用程序,你可以使用模拟器窗口并点击其启动器图标,或者使用命令行,这是如果模拟器是无头的情况下唯一的替代方案,如下所示:
$ adb shell am start -n com.example.aatg.tc/.TemperatureConverterActivity
日志中通过这一行来告知:
开始:意图 { cmp=com.example.aatg.tc/.TemperatureConverterActivity }
一旦应用程序启动,你可以使用脚本和netcat实用程序发送事件:
$ nc localhost 1080 < monkey.txt
这将发送脚本文件中包含的事件到模拟器。以下事件如下:
-
触摸并选择摄氏度字段
-
输入 123
-
触摸并选择华氏度字段
-
删除其内容
-
输入-460.3
以这种方式,可以创建由触摸事件和按键组成的简单脚本。
使用 monkeyrunner 进行测试脚本
猴子的可能性相当有限,缺乏流程控制限制了其使用范围,仅适用于非常简单的情况。
为了规避这些限制,创建了一个新的项目,命名为 monkeyrunner (developer.android.com/guide/developing/tools/monkeyrunner_concepts.html)。尽管名称几乎相同,并且导致了不少混淆,但它们在本质上没有任何关联。
Monkeyrunner,它已经包含在 Android SDK 的最新版本中,目前处于初始阶段,如今其使用相当有限,但其未来可能光明。它是一个提供 API 以编写外部控制 Android 设备或模拟器的脚本的工具。
Monkeyrunner 是建立在 Jython (www.jython.org/) 之上的,它是 Python (www.python.org/) 编程语言的一个版本,专为在 Java(tm) 平台上运行而设计。
根据其文档,monkeyrunner 工具为 Android 测试提供了以下独特功能。这只是完整功能列表、示例和参考文档的亮点,这些可以从 monkeyrunner 主页(developer.android.com/guide/developing/tools/monkeyrunner_concepts.html)获取:
-
多设备控制:
monkeyrunnerAPI 可以将一个或多个测试套件应用于多个设备或模拟器。您可以一次性物理连接所有设备或启动所有模拟器(或两者),然后依次程序化连接到每个设备,并运行一个或多个测试。您还可以程序化启动模拟器配置,运行一个或多个测试,然后关闭模拟器。 -
功能测试:
monkeyrunner可以运行 Android 应用程序的自动化从头到尾测试。您提供输入值,使用按键或触摸事件,并以截图的形式查看结果。 -
回归测试:
monkeyrunner可以通过运行应用程序并将其输出截图与一组已知正确的截图进行比较来测试应用程序的稳定性。 -
可扩展的自动化: 由于
monkeyrunner是一个 API 工具包,因此您可以开发一个基于 Python 的模块和程序的系统,用于控制 Android 设备。除了使用monkeyrunnerAPI 本身之外,您还可以使用标准的 Python OS 和 subprocess 模块调用 Android 工具,如 Android 调试桥。 -
您还可以将您自己的类添加到
monkeyrunnerAPI 中。这在线文档的“通过插件扩展 monkeyrunner”部分有更详细的描述。
获取测试截图
目前,monkeyrunner 最明显的用途之一是获取正在测试的应用程序的截图,以便进一步分析或比较。
这些截图可以通过以下步骤获得:
-
导入所需的模块。
-
与设备建立连接。
-
检查错误。
-
启动
TemperatureConverter活动。 -
添加一些延迟。
-
输入 '123'
-
添加一些延迟以允许事件被处理。
-
获取截图并将其保存到文件中。
-
按 BACK 键退出 Activity。
以下是需要执行上述步骤的脚本的代码:
#! /usr/bin/env monkeyrunner
'''
Created on 2011-03-12
@author: diego
'''
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.example.aatg.tc/.TemperatureConverterActivity')
MonkeyRunner.sleep(3.0)
device.type("123")
# Takes a screenshot
MonkeyRunner.sleep(3.0)
result = device.takeSnapshot()
# Writes the screenshot to a file
result.writeToFile('/tmp/device.png','png')
device.press('KEYCODE_BACK', 'DOWN_AND_UP')
一旦此脚本运行,您将在 /tmp/device.png 中找到 TemperatureConverter 的截图。
录制和回放
如果需要更简单的东西,可能没有必要手动创建这些脚本。为了简化流程,可以使用包含在 Android 源代码库中 sdk 项目的脚本monkey_recorder.py(android.git.kernel.org/?p=platform/sdk.git;a=summary),用于记录随后由另一个名为monkey_playback.py的脚本解释的事件描述。
从命令行运行monkey_recorder.py,你将看到这个用户界面:

此界面有一个工具栏,包含用于在记录的脚本中插入不同命令的按钮:
| 按钮 | 描述 |
|---|---|
| 等待 | 等待多少秒。此数字由对话框请求。 |
| 按按钮 | 发送 MENU、HOME 或 SEARCH 按钮。按、向下或向上事件。 |
| 输入文本 | 发送一个字符串。 |
| 滑动 | 在指定方向、距离和步数中发送滑动事件。 |
| 导出动作 | 保存脚本。 |
| 刷新显示 | 刷新显示的截图副本。 |
一旦脚本完成,保存它,比如说保存为script.mr,然后你可以通过使用以下命令行重新运行它:
$ monkey_playback.py script.mr
现在所有的事件都将被回放。
摘要
在本章中,我们涵盖了所有将我们的应用程序及其测试暴露于各种条件和配置的替代方案,包括不同的屏幕尺寸、设备(如摄像头或键盘)的可用性,以及模拟真实网络条件以检测应用程序中的问题。
我们还分析了所有可以远程控制断开其窗口的模拟器的选项。这为我们在第八章(Chapter 8,持续集成)中将要讨论的持续集成奠定了基础,并且依赖于自动运行所有测试套件的能力,以及配置、启动和停止模拟器的功能将是必要的。
最后,介绍了一些脚本编写替代方案,并提供了入门示例。
下一章将介绍行为驱动开发(Behavior Driven Development,简称 BDD)——一种利用通用词汇来表达测试的技术,允许业务人员在软件开发项目中参与。
第六章. 行为驱动开发
行为驱动开发可以理解为测试驱动开发(Test Driven Development)和验收测试(Acceptance Testing)的演变和融合。这两种技术在前几章中都有讨论,所以在继续之前,你可能想回顾一下第一章 测试入门 和 第四章 测试驱动开发。
行为驱动开发引入了一些新概念,例如使用通用词汇来描述测试,以及在软件开发项目中包含商业参与者。有些人仍然认为这只是正确执行的测试驱动开发。
我们之前已经讨论过测试驱动开发,我们专注于将低级需求转换为可以驱动我们开发过程的测试。行为驱动开发迫使我们关注更高层次的需求,并使用特定的词汇,我们可以用这些词汇以可以进一步分析或评估的方式表达这些需求。
我们将探讨这些概念,以便你可以得出自己的结论。
简史
行为驱动开发是由丹·诺斯(Dan North)在 2003 年提出的术语,用来描述一种通过使用通常称为自外向内的软件开发过程来专注于开发人员与其他利益相关者之间协作的技术。其首要目标是满足客户的企业需求。
行为驱动开发源于基于神经语言程序学(Neuro Linguistic Programming,NLP)技术的思想实验。
主要思想是,用来描述一个想法的词汇会严重影响这个想法,以至于我们似乎在用我们说的语言思考。
有实证证明,在记忆测试中,如果他们的母语有特定颜色的特定词汇,那么测试对象更有可能记住该特定颜色。因此,如果我们有一种特定的语言来描述我们的需求,它可能会影响我们对它们的思考方式,从而改善我们编写它们的方式。
因此,行为驱动开发所使用的词汇是经过精心挑选的,以影响你对特性指定的思考方式。它们与因果关系概念密切相关,并遵循这一概念从已知状态开始描述一个特性,应用某些过程,并期望某些结果。
这些词将在下一节中进行描述。
给定,当,然后
给定/当/然后词汇是跨越商业和技术之间的共同词汇,正如在behaviour-driven.org/中所述,它们也可以被称为行为驱动开发的通用语言。该框架基于三个核心原则,我们在此直接引用:
-
商业和技术应该以相同的方式引用相同的系统
-
任何系统都应该有一个对业务可识别、可验证的价值
-
预先分析、设计和规划都有递减的回报
行为驱动开发依赖于使用这个特定的词汇。此外,要求表达的方式是预先确定的,允许工具解释和执行它们。
-
给定,是描述在接收到外部刺激之前的初始状态。
-
当,是描述用户执行的关键动作。
-
然后,是分析动作的结果。为了可观察性,所执行的动作应该有一些结果。
FitNesse
FitNesse 是一个软件开发协作工具。严格来说,FitNesse 是一套工具,如下所述:
-
作为软件测试工具,FitNesse 是一个轻量级、开源的框架,允许团队协作
-
它也是一个 Wiki,你可以轻松创建和编辑页面并分享信息
-
FitNesse 也是一个 Web 服务器,因此它不需要额外的配置或管理权限来设置或配置
从 fitnesse.org/ 下载 FitNesse 发行版。发行版是一个 JAR 文件,首次运行时会自动安装。在这些示例中,我们使用了 FitNesse 版本 20100303,但新版本也应该可以工作。
从命令行运行 FitNesse
默认情况下,当 FitNesse 运行时,它监听端口 80,因此要运行非特权版本,你应该在命令行上更改端口。在这个例子中,我们使用 8900:
$ java -jar fitnesse.jar -p 8900
这是我们运行命令时获得的结果:
FitNesse (v20100303) 已启动..。
端口:8900
根页面:fitnesse.wiki.FileSystemPage 在 ./FitNesseRoot
记录器:无
认证器:fitnesse.authentication.PromiscuousAuthenticator
HTML 页面工厂:fitnesse.html.HtmlPageFactory
页面版本过期设置为 14 天。
一旦运行,你可以将你的浏览器指向本地 FitNesse 服务器的首页,你将看到以下内容:

创建一个 TemperatureConverterTests 子维基
一旦 FitNesse 启动并运行,我们可以先创建一个子维基来组织我们的测试。
你可能已经熟悉了 Wiki 概念。如果不熟悉,Wiki 是一个允许用户编辑和创建页面的网站。这个过程是在浏览器内完成的,并使用一种大大简化过程的标记语言。
注意
你可以在可能是最著名的 Wiki 中了解更多关于 Wiki 的信息:en.wikipedia.org/wiki/Wiki。
虽然这种子维基组织不是强制性的,但强烈推荐,特别是如果你计划在多个项目中使用 FitNesse 进行验收测试的话。
最简化的过程之一是超链接创建,这只需要使用 CamelCase 或 WikiWords;即一个以大写字母开头并且至少有一个更多大写字母的单词。这个 WikiWord 将被转换成一个指向具有该名称页面的超链接。
要创建 TemperatureConverterTests 子维基,我们只需按下 FitNesse 标志下面的编辑按钮来编辑主页,添加以下内容:
| '''My Tests''' |
| TemperatureConverterTests | ''Temperature Converter Tests'' |
这通过使用 "|" 标记作为第一个字符以及分隔列来在页面上添加一个新的表格。
然后创建一个维基页面 TemperatureConverterTests,我们还在其中添加了一个带有关于测试的描述性注释的列。这个注释通过用双单引号('')包围而变成了斜体。
按保存按钮,页面将被修改。
页面显示后,我们可以验证 TemperatureConverterTests 现在后面跟着 [?](问号),因为页面尚未创建,将在我们点击它时创建。
我们可以添加一些注释来清楚地识别这个新创建的子维基的首页。
!contents -R2 -g -p -f -h
This is the !-TemperatureConverterTests SubWiki-!.
在这里,文本TemperatureConverterTests SubWiki使用!-和-!进行转义,以防止它被转换为另一个页面链接。
保存再次。
向子维基添加子页面
现在我们通过使用页面标题旁边的[添加子页面]链接添加一个新的子页面。
创建子页面的选项有很多,我们可以选择:
-
正常,对于一个普通维基页面
-
测试,包含测试的页面
-
套件,包含其他测试的套件页面
-
默认,一个默认页面

这些是需要使用的值:
| 字段 | 值 |
|---|---|
| 页面类型: | 套件 |
| 名称: | TemperatureConverterTestSuite |
| 内容: | !contents |
按下添加后,这个页面将被创建并自动添加为子维基的链接。
让我们跟随这个新创建的链接到达测试套件页面。
一旦你在这里,使用[添加子页面]链接添加另一个子页面。这次,让我们添加一个测试页面,并将其命名为TemperatureConverterCelsiusToFahrenheitFixture,因为这个将包含我们的固定装置。
这些是需要使用的值:
| 字段 | 值 |
|---|---|
| 页面类型: | 测试 |
| 名称: | TemperatureConverterCelsiusToFahrenheitFixture |
| 内容: | !contents |
点击添加以完成操作。
添加验收测试固定装置
到目前为止,我们只是在创建维基页面。这没什么令人兴奋的!但现在我们将直接在页面上添加我们的验收测试固定装置。确保导航到新添加的页面,TemperatureConverterCelsiusToFahrenheitFixture,像往常一样点击编辑,并添加以下内容:
!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?:这是转换结果期望的列名。问号表示这是一个结果值。 -
~=:这表示结果大约是这个值。 -
< _ <:这表示期望值在这个范围内。 -
无效的温度:-274.00C 低于绝对零度,是失败的转换所期望的值。
通过点击保存来保存此内容。
添加支持测试类
如果我们只是按下位于 FitNesse 标志下方的测试按钮(下一张截图将提供详细信息),我们将收到一个错误。在某种程度上,这是预期的,因为我们还没有创建支持测试固定件。这是一个非常简单的类,它调用TemperatureConverter方法。
FitNesse 支持两种不同的测试系统:
-
fit: 这是两种方法中较老的一种,它使用 HTML,在调用固定件之前解析。
-
slim: 这是一种较新的方法,所有的表格处理都在 FitNesse 内部,在 slim 运行器中进行。
更多关于这些测试系统的信息可以在fitnesse.org/FitNesse.UserGuide.TestSystems找到。
在这个例子中,我们使用 slim,这是通过在相同页面中设置变量TEST_SYSTEM来选择的:
!define TEST_SYSTEM {slim}
要创建 slim 测试固定件,我们只需在我们的现有 Android 测试项目TemperatureConverterTest中创建一个新的包,命名为com.example.aatg.tc.test.fitnesse.fixture。我们将在这个包内创建固定件。
接下来,我们必须创建我们在验收测试表中定义的TemperatureConverterCelsiusToFahrenheitFixture类:
package com.example.aatg.tc.test.fitnesse.fixture;
import com.example.aatg.tc.TemperatureConverter;
public class TemperatureConverterCelsiusToFahrenheitFixture {
private double celsius;
public void setCelsius(double celsius) {
this.celsius = celsius;
}
public String fahrenheit() throws Exception {
try {
return String.valueOf( TemperatureConverter.celsiusToFahrenheit(celsius));
}
catch (RuntimeException e) {
return e.getLocalizedMessage();
}
}
}
此固定件应该委托给真实代码,而不是自己执行任何操作。我们决定从fahrenheit返回String,这样我们就可以在同一个方法中返回Exception消息。
在测试页面中,我们还应该定义测试所使用的导入语句:
|import|
|com.example.aatg.tc.test.fitnesse.fixture|
注意
注意,在下一个变量中,您应该将路径更改为适合您系统的路径,并将类路径更改为定位类。
!path /opt/fitnesse/fitnesse.jar:/home/diego/aatg/TemperatureConverter/bin/:/home/diego/aatg/TemperatureConverterTest/bin/
注意
这应该适应您的系统路径。
完成这些步骤后,我们可以点击测试按钮来运行测试,页面将反映结果:

我们可以通过它们的绿色和红色轻松地识别每个成功的测试和失败的测试。在这个例子中,我们没有失败,所以一切都是绿色的。
FitNesse 还有一个有用的功能,即测试历史。所有测试运行和一定数量的结果都会保存一段时间,以便您可以稍后审查结果并比较结果,从而分析您更改的演变。
此功能可以通过点击左侧选项列表底部的测试历史来访问。
在以下图像中,我们可以看到最后 4 次测试运行的结果,其中 3 次失败,1 次成功。此外,通过点击“+”或“-”符号,您可以展开或折叠视图以显示或隐藏有关测试运行的详细信息。

GivWenZen
GivWenZen 是一个框架,它基于 FitNesse 和 Slim 构建,允许用户利用行为驱动开发技术,使用 Given-When-Then 词汇来描述测试。这些测试描述也使用 FitNesse 维基功能创建,将测试作为包含在维基页面表格中的纯文本。
这个想法很简单,并且很直接,遵循我们一直在使用 FitNesse 做的事情,但这次我们不是编写提供值表的验收测试,而是将三个行为驱动开发魔法词 Given-When-Then 用于描述我们的场景。
首先,让我们安装 GivWenZen。从其下载列表页面 code.google.com/p/givwenzen/downloads/list 下载完整发行版,并遵循其网站上的说明。在这些示例中,我们使用了 givwenzen 1.0.1,但新版本也应该可以工作。
GivWenZen 的完整发行版包括所有必要的依赖项,包括 FitNesse,因此如果你已经从之前的示例中运行了 FitNesse,最好停止它,或者你必须为 GivWenZen 使用不同的端口。
启动时,将你的浏览器指向主页,你将找到一个熟悉的 FitNesse 前端页面。你可以花些时间探索包含的示例。
创建测试场景
让我们为我们的温度转换器创建一个简单的场景,以便更好地理解。
在纯测试中,我们的场景将是:
给定 我正在使用温度转换器,当 我将 100 输入到摄氏度字段,然后 我在华氏度字段中获得 212。
并且它通过在维基页面上添加以下内容直接翻译成 GivWenZen 场景:
-|script|
|given |I'm using the !-TemperatureConverter-!|
|when |I enter 100 into Celsius field |
|then |I obtain 212 in Fahrenheit field |
翻译很简单。表标题必须是 script,在这种情况下,它前面有一个连字符 (-) 以隐藏它。然后,每个 Given-When-Then 场景都放置在一列中,而谓词在另一列中。
在运行此脚本之前,当整个页面执行完毕时,我们需要通过运行另一个脚本来初始化 GivWenZen。在这种情况下,它将是:
|script |
|start|giv wen zen for slim|
我们需要在启动 GivWenZen 的脚本之前初始化类路径并添加相应的导入。通常这在一页 setUp 中完成,这些页面在运行每个测试脚本之前执行,但为了简单起见,我们将初始化添加到同一页面上:
!define TEST_SYSTEM {slim}
!path ./target/classes/main
!path ./target/classes/examples
!path ./lib/commons-logging.jar
!path ./lib/fitnesse.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 ./lib/javassist.jar
!path ./lib/google-collect-1.0-rc4.jar
!path ./lib/dom4j-1.6.1.jar
!path ./lib/commons-vfs-1.0.jar
!path ./lib/clover-2.6.1.jar
!path /home/diego/workspace/TemperatureConverter/bin
!path /home/diego/workspace/TemperatureConverterTest/bin
如果你只是在这里点击 测试 按钮运行测试,你将收到以下消息:
__ 异常:org.givwenzen.DomainStepNotFoundException:
你需要一个步骤类,其中有一个注解的方法与以下模式匹配:“我正在使用 TemperatureConverter”。
这种错误的典型原因包括:
-
StepClass缺少@DomainSteps注解 -
StepMethod缺少@DomainStep注解 -
步骤方法注解的正则表达式与当前测试步骤不匹配
这以及其他异常消息对于实现步骤类非常有帮助,然而你应该添加一些行为。
步骤类应该放在bdd.steps包或子包中,或者如果你定义了自己的自定义包,也应该放在那里。
例如:
@DomainSteps
public class StepClass {
@DomainStep("I'm using the TemperatureConverter")
public void domainStep() {
// TODO implement step
} }
在我们特定的案例中,这将实现StepClass:
package bdd.steps.tc;
import org.givwenzen.annotations.DomainStep;
import org.givwenzen.annotations.DomainSteps;
import com.example.aatg.tc.TemperatureConverter;
@DomainSteps
public class TemperatureConverterSteps {
private static final String CELSIUS = "Celsius";
private static final String FAHRENHEIT = "Fahrenheit";
private static final String ANY_TEMPERATURE =
"([-+]?\\d+(?:\\.\\d+)?)";
private static final String UNIT = "(C|F)";
private static final String UNIT_NAME =
"(" + CELSIUS + "|" + FAHRENHEIT + ")";
private static final double DELTA = 0.01d;
private double mValue = Double.NaN;
@DomainStep("I(?: a|')m using the TemperatureConverter")
public void createTemperatureConverter() {
// do nothing
}
@DomainStep("I enter " + ANY_TEMPERATURE + " into " + UNIT_NAME + " field")
public void setField(double value, String unitName) {
mValue = value;
}
@DomainStep("I obtain " + ANY_TEMPERATURE + " in " + UNIT_NAME + " field")
public boolean verifyConversion(double value, String unitName) {
try {
final double t = (FAHRENHEIT.compareTo(unitName) == 0) ? getFahrenheit() : getCelsius();
return (Math.abs(t-value) < DELTA);
}
catch (RuntimeException ex) {
return false;
}
}
@DomainStep("Celsius")
public double getCelsius() {
return TemperatureConverter.fahrenheitToCelsius(mValue);
}
@DomainStep("Fahrenheit")
public double getFahrenheit() {
return TemperatureConverter.celsiusToFahrenheit(mValue);
}
}
在这个例子中,我们使用bdd.steps的子包,因为默认情况下,这是 GivWenZen 搜索步骤实现包的层次结构。否则,需要额外的配置。
实现步骤的类应该由@DomainSteps注解,而步骤的方法应该由@DomainStep注解。后者接收一个正则表达式字符串作为参数。这个正则表达式由 GivWenZen 用来匹配步骤。
例如,在我们的场景中,我们定义了这个步骤:
I enter 100 into Celsius field
我们的注解是:
@DomainStep("I enter " + ANY_TEMPERATURE + " into " + UNIT_NAME + " field")
这将匹配,并且由ANY_TEMPERATURE和UNIT_NAME定义的正则表达式组值将被获取,并作为方法参数的值和unitName:提供给方法。
public void setField(double value, String unitName)
回想一下,在前一章中,我建议回顾正则表达式,因为它们可能很有用。这可能是这些非常有用的地方之一。在ANY_TEMPERATURE中,我们匹配每个可能的温度值,包括可选的符号和小数点。因此,UNIT和UNIT_NAME匹配单位符号或其名称;即摄氏度或华氏度。
这些正则表达式用于构造@DomainStep注解参数。这些正则表达式中的括号"()"分隔的组被转换为方法参数。这就是setField()如何获得其参数的方式。
然后,我们有一个verifyConversion()方法,它返回 true 或 false,取决于实际转换是否在预期的DELTA值范围内。
最后,我们有一些方法,实际上会调用TemperatureConverter类中的转换方法。
再次运行测试后,所有测试都通过了。我们可以通过分析输出消息来确认这一点:
断言:2 个正确,0 个错误,0 个忽略,0 个异常。
注意,我们接收的是 2 个断言的结果,因为一个是调用我们添加到页面上的 GivWenZen 初始化脚本,另一个是我们场景的断言。
我们不仅应该为正常情况创建场景,还应该涵盖异常条件。比如说,用纯文本来说,我们的场景是这样的:
注意
Given 我在使用温度转换器,When 我将-274 输入到摄氏度字段,Then 我获得 'Invalid temperature: -274.00C below absolute zero' 异常。
它可以被翻译成以下 GivWenZen 表格:
-|script|
|given|I am using the !-TemperatureConverter-! |
|when |I enter -274 into Celsius field |
|then |I obtain 'Invalid temperature: -274.00C below absolute zero' exception|
通过添加一个支持步骤方法,我们就能运行它。步骤方法可以像这样实现:
@DomainStep("I obtain '(Invalid temperature: " + ANY_TEMPERATURE + UNIT + " below absolute zero)' exception")
public boolean verifyException(String message, String value, String unit) {
try {
if ( "C".compareTo(unit) == 0 ) {
getFahrenheit();
}
else {
getCelsius();
}
}
catch (RuntimeException ex) {
return ex.getMessage().contains(message);
}
return false;
}
这个方法从正则表达式获取异常消息、温度值和单位。然后,这与实际的异常消息进行比较,以验证它们是否匹配。
此外,我们还可以创建其他场景,在这种情况下,这些场景将由现有的步骤方法支持。这些场景可能包括:
-|script|
|given |I'm using the !-TemperatureConverter-! |
|when |I enter -100 into Celsius field |
|then |I obtain -148 in Fahrenheit field |
-|script|
|given |I'm using the !-TemperatureConverter-! |
|when |I enter -100 into Fahrenheit field |
|then |I obtain -73.33 in Celsius field |
|show |then |Celsius |
-|script|
|given|I'm using the !-TemperatureConverter-! |
|when |I enter -460 into 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 单元测试
有一些情况,你确实需要将应用程序的部分在几乎与底层系统没有关联的情况下单独测试。在这种情况下,我们必须选择一个足够高的基类来消除一些依赖,但又不能高到让我们负责一些基本基础设施。
在这种情况下,候选基类可能是 AndroidTestCase。这个例子是从 Android CTS 测试套件中取出的(source.android.com/compatibility/cts-intro.html):
/*
* Copyright (C) 2009 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
* either express or implied.
* See the License for the specific language governing permissions
* and limitations under the License.
*/
package com.android.cts.appaccessdata;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import android.test.AndroidTestCase;
/**
* Test that another app's private data cannot be accessed.
*
* Assumes that {@link APP_WITH_DATA_PKG} has already created the private data.
*/
public class AccessPrivateDataTest extends AndroidTestCase {
/**
* The Android package name of the application that owns the private data
*/
private static final String APP_WITH_DATA_PKG = "com.android.cts.appwithdata";
到目前为止,我们有:
-
标准的 Android 开源项目版权。
-
包定义。这个测试位于
com.android.cts.appaccessdata。 -
一些导入。
-
AccessPrivateDataTest的定义,它扩展了AndroidTestCase,因为它是一个不需要系统基础设施的单元测试。在这种情况下,我们也可以直接使用TestCase,因为我们没有访问 Context。 -
常量
APP_WITH_DATA_PKG的定义,表示包含我们试图访问的私有数据的应用程序包名:/** * Name of private file to access. This must match the name * of the file created by * {@link APP_WITH_DATA_PKG}. */ private static final String PRIVATE_FILE_NAME = "private_file.txt"; /** * Tests that another app's private file cannot be accessed * @throws IOException */ public void testAccessPrivateData() throws IOException { try { // construct the absolute file path to the app's private file String privateFilePath = String.format( "/data/data/%s/%s", APP_WITH_DATA_PKG, PRIVATE_FILE_NAME); FileInputStream inputStream = new FileInputStream(privateFilePath); inputStream.read(); inputStream.close(); fail("Was able to access another app's private data"); } catch (FileNotFoundException e) { // expected } catch (SecurityException e) { // also valid } } }
在本节的第二部分,我们有:
-
PRIVATE_FILE_NAME的定义,包含我们将尝试访问的文件名 -
测试方法
testAccessPrivateData,它实际上测试了功能
这个测试方法 testAccessPrivateData() 测试对其他包的私有数据的访问,如果这是可能的,则测试失败。为了实现这一点,捕获了预期的异常,如果没有发生,则使用自定义消息调用 fail()。
测试活动和应用程序
本节展示了活动和应用程序测试的一些示例。它们涵盖了你在日常测试中可能会遇到的一些常见情况,你可以根据具体需求对这些示例进行调整。
应用程序和首选项
在 Android 术语中,应用指的是在需要维护全局应用状态时使用的一个基类。这通常用于处理共享首选项。我们期望测试改变这些首选项值的操作不会影响真实应用的行为。想象一下测试删除存储这些值作为共享首选项的应用的用户账户信息。这听起来不是一个好主意。所以,我们真正需要的是能够模拟一个Context,它也能模拟对SharedPreferences的访问。
我们的第一种尝试可能是使用RenamingDelegatingContext,但不幸的是,它并不模拟SharedPreferences,尽管它很接近,因为它模拟了数据库和文件系统访问。所以,首先我们需要创建一个专门的模拟Context,它也能模拟后者。
RenamingMockContext类
让我们创建一个专门的Context。RenamingDelegatingContext类是一个很好的起点,因为我们之前提到,数据库和文件系统访问将被模拟。问题是如何模拟SharedPreferences的访问。
记住,正如其名称所暗示的,RenamingDelegatingContext将一切委托给一个Context。所以,我们问题的根源在于这个Context。因为它也是一个模拟的Context,所以MockContext似乎是一个正确的基类。如您所记得,在第三章中,我们探讨了模拟对象,并指出MockContext只能用于注入其他依赖项,并且所有方法都是非功能的,会抛出UnsupportedOperationException。然而,这也是我们可以利用的一个特性,以检测在这种情况下需要实现的最小方法集。所以,让我们开始创建一个空的MockContext,它将委托给其他Context,我们可以将其命名为RenamingMockContext:
private static class RenamingMockContext extends RenamingDelegatingContext {
private static final String PREFIX = "test.";
public RenamingMockContext(Context context) {
super(new DelegatedMockContext(context), PREFIX);
}
private static class DelegatedMockContext extends MockContext {
public DelegatedMockContext(Context context) {
// TODO Auto-generated constructor stub
}
}
}
我们创建了一个模拟的Context,名为RenamingMockContext,它委托给另一个空的MockContext,即DelegatedMockContext,并使用一个重命名前缀。
TemperatureConverterApplicationTests类
我们已经有了RenamingMockContext,现在我们需要一个使用它的测试。因为我们将会测试一个应用,所以测试的基类将是ApplicationTestCase。这个测试用例提供了一个框架,在其中你可以在一个受控环境中测试应用类。它提供了对应用生命周期的基本支持,以及一些钩子,通过这些钩子你可以注入各种依赖项并控制你的应用被测试的环境。我们可以在创建Application之前使用setContext()方法注入RenamingMockContext。
我们在第四章“测试驱动开发”中开始的TemperatureConverter应用程序,将存储小数位数作为共享偏好设置。因此,我们将创建一个测试来设置小数位数,然后检索它以验证其值:
public class TemperatureConverterApplicationTests extends
ApplicationTestCase<TemperatureConverterApplication> {
private TemperatureConverterApplication mApplication;
public TemperatureConverterApplicationTests() {
this("TemperatureConverterApplicationTests");
}
public TemperatureConverterApplicationTests(String name) {
super(TemperatureConverterApplication.class);
setName(name);
}
@Override
protected void setUp() throws Exception {
super.setUp(); final RenamingMockContext mockContext = new RenamingMockContext(getContext());
setContext(mockContext);
createApplication();
mApplication = getApplication();
}
@Override
protected void tearDown() throws Exception {
super.tearDown();
}
public final void testPreconditions() {
assertNotNull(mApplication);
}
public final void testSetDecimalPlaces() {
final int expected = 3;
mApplication.setDecimalPlaces(expected);
assertEquals(expected, mApplication.getDecimalPlaces());
}
}
我们使用TemperatureConverterApplication模板参数扩展ApplicationTestCase。很快,我们将创建一个扩展Application的类。
然后,我们使用我们在第三章“Android SDK 的构建块”中讨论的给定名称构造函数模式。
在setUp()方法中,我们创建模拟上下文并使用setContext()方法设置此测试的上下文;我们使用createApplication()创建应用程序,并最终持有它的引用,因为它将在我们的测试中频繁使用。
关于我们的测试,使用我们之前审查过的测试前提条件模式,我们检查最近创建的应用程序不为空。
最后是测试实际测试所需行为,设置小数位数,检索它,并验证其值。
我们的首要目标是让这些测试编译通过。稍后我们将关注这些测试的成功。为了编译通过,我们需要创建TemperatureConverterApplication类以及小数位数的 getter 和 setter,最终应该使用SharedPreferences来存储和检索特定的偏好设置:
/**
* Copyright (C) 2010-2011 Diego Torres Milano
*/
package com.example.aatg.tc;
import android.app.Application;
/**
* @author diego
*
*/
public class TemperatureConverterApplication extends
Application {
/**
*
*/
public TemperatureConverterApplication() {
// TODO Auto-generated constructor stub
}
public void setDecimalPlaces(int expected) {
// TODO Auto-generated method stub
}
public Object getDecimalPlaces() {
// TODO Auto-generated method stub
return null;
}
}
运行测试时,我们得到一个失败,原因是我们没有在任何地方存储小数位数。我们可以通过以下方式使用SharedPreferences来实现这一点:
/**
* Copyright (C) 2010-2011 Diego Torres Milano
*/
package com.example.aatg.tc;
import android.app.Application;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.preference.PreferenceManager;
/**
* @author diego
*
*/
public class TemperatureConverterApplication extends Application {
private static final String TAG = "TemperatureConverterApplication";
public static final int DECIMAL_PLACES_DEFAULT = 2;
public static final String DECIMAL_PLACES = "decimalPlaces";
private SharedPreferences mSharedPreferences;
/**
*
*/
public TemperatureConverterApplication() {
// TODO Auto-generated constructor stub
}
@Override
public void onCreate() {
super.onCreate();
mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
}
public void setDecimalPlaces(int d) {
final Editor editor = mSharedPreferences.edit();
editor.putString(DECIMAL_PLACES, Integer.toString(d));
editor.commit();
}
public int getDecimalPlaces() {
return Integer.parseInt( mSharedPreferences.getString(DECIMAL_PLACES, Integer.toString(DECIMAL_PLACES_DEFAULT)));
}
}
如果我们完成这些步骤,编译并运行测试,我们会发现它们在MockContext.getPackageName()中失败,抛出UnsupportedOperationException。
我们将DelegateMockContext改为覆盖getPackageName(),将构造函数中传递的原始上下文委托出去:
private static class RenamingMockContext extends RenamingDelegatingContext {
/**
* The renaming prefix.
*/
private static final String PREFIX = "test.";
public RenamingMockContext(Context context) {
super(new DelegatedMockContext(context), PREFIX);
}
private static class DelegatedMockContext extends MockContext {
private Context mDelegatedContext;
public DelegatedMockContext(Context context) {
mDelegatedContext = context;
} @Override
public String getPackageName() {
return mDelegatedContext.getPackageName();
}
}
再次运行测试,这次我们得到了一个不同但多少有些预期的UnsupportedOperationException。这个异常是在调用getSharedPreferences()时接收到的。因此,下一步是覆盖DelegatedMockContext中的这个方法:
@Override
public SharedPreferences getSharedPreferences( String name, int mode) {
return mDelegatedContext.getSharedPreferences( PREFIX + name, mode);
}
每次请求SharedPreference时,此方法将调用委托上下文,并为名称添加前缀。应用程序使用的原始SharedPreferences保持不变。
我们可以通过向TemperatureConverterApplication类提供之前提到的这些方法,然后在共享偏好设置中存储一些值,运行测试,并最终验证这个值没有被测试运行所影响来验证这种行为。
测试活动
下一个示例展示了如何使用 ActivityUnitTestCase<Activity> 基类在完全隔离的情况下测试活动,而不是使用 ActivityInstrumentationTestCase2<Activity>。这种方法需要更多的注意力和关注,但也提供了对要测试的 Activity 的更大灵活性和控制。这种测试旨在测试一般的 Activity 行为,而不是 Activity 实例与其他系统组件或 UI 相关的测试:
我们从这个 ApiDemos 示例应用程序中获取这个例子(developer.android.com/resources/samples/ApiDemos/index.html),这个应用程序作为 SDK 伴侣提供。由于这个示例相对较长,所以我们将其拆分为几个代码片段以提高其可读性:
/*
* Copyright (C) 2008 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied.
* See the License for the specific language governing permissions
* and limitations under the License.
*/
package com.example.android.apis.app;
import com.example.android.apis.R;
import com.example.android.apis.view.Focus2ActivityTest;
import android.content.Context;
import android.content.Intent;
import android.test.ActivityUnitTestCase;
import android.test.suitebuilder.annotation.MediumTest;
import android.widget.Button;
这个第一个代码片段除了必要的版权声明和导入之外,没有其他内容:
/**
* This demonstrates completely isolated "unit test" of an Activity
* class.
*
* <p>This model for testing creates the entire Activity (
* like {@link Focus2ActivityTest}) but does
* not attach it to the system (for example, it cannot launch another
* Activity).
* It allows you to inject additional behaviors via the
* {@link android.test.ActivityUnitTestCase#setActivityContext(
* Context)} and
* {@link android.test.ActivityUnitTestCase#setApplication(
* android.app.Application)} methods.
* It also allows you to more carefully test your Activity's
* performance
* Writing unit tests in this manner requires more care and
* attention, but allows you to test
* very specific behaviors, and can also be an easier way
* to test error conditions.
*
* <p>Because ActivityUnitTestCase creates the Activity
* under test completely outside of
* the usual system, tests of layout and point-click UI
* interaction are much less useful
* in this configuration. It's more useful here to concentrate
* on tests that involve the
* underlying data model, internal business logic, or exercising
* your Activity's life cycle.
*
* <p>See {@link com.example.android.apis.AllTests} for
* documentation on running
* all tests and individual tests in this application.
*/
public class ForwardingTest extends ActivityUnitTestCase<Forwarding> {
private Intent mStartIntent;
private Button mButton;
public ForwardingTest() {
super(Forwarding.class);
}
第二个代码片段包括测试用例定义,它扩展了 ActivityUnitTestCase<Forwarding>,正如我们之前提到的,这是一个 Activity 类的单元测试。要测试的活动将从系统中断开连接,因此它仅用于测试其内部方面,而不是与其他组件的交互:
正如我们在之前的示例中提到的,这里也定义了无参构造函数:
@Override
protected void setUp() throws Exception {
super.setUp();
// In setUp, you can create any shared test data, // or set up mock components to inject
// into your Activity. But do not call startActivity() // until the actual test methods.
// into your Activity. But do not call startActivity() // until the actual test methods.
mStartIntent = new Intent(Intent.ACTION_MAIN);
}
这个 setUp() 方法遵循调用超类方法并使用启动 Activity 的 Intent 初始化字段的模式。在这种情况下,我们将 Intent 保存为成员 mStartIntent:
/**
* The name 'test preconditions' is a convention to
* signal that if this
* test doesn't pass, the test case was not set up
* properly and it might
* explain any and all failures in other tests.
* This is not guaranteed
* to run before other tests, as junit uses reflection
* to find the tests.
*/
@MediumTest
public void testPreconditions() {
startActivity(mStartIntent, null, null);
mButton = (Button) getActivity().findViewById(R.id.go);
assertNotNull(getActivity());
assertNotNull(mButton);
}
这定义了 testPreconditions() 方法,我们之前也解释过。正如方法注释中提到的,请记住这个名称只是一个约定,没有保证执行顺序:
/**
* This test demonstrates examining the way that activity calls
* startActivity() to launch
* other activities.
*/
@MediumTest
public void testSubLaunch() {
Forwarding activity = startActivity( mStartIntent, null, null);
mButton = (Button) activity.findViewById(R.id.go);
// This test confirms that when you click the button, // the activity attempts to open
// another activity (by calling startActivity) and // close itself (by calling finish()).
mButton.performClick();
assertNotNull(getStartedActivityIntent());
assertTrue(isFinishCalled());
}
这个测试在 Forwarding Activity 的“go”按钮上执行点击操作。该按钮的 onClickListener 调用 startActivity() 并使用一个定义组件为 ForwardTarget 类的 Intent,因此这就是将被启动的 Activity:
执行这个动作后,我们验证用于启动新 Activity 的 Intent 不为 null,并且在我们的 Activity 上调用了 finish():
一旦使用 startActivity(mStartIntent, null, null) 启动了要测试的活动,就会验证组件以确保它们符合预期。为了做到这一点,使用 getActivity() 的断言来验证最近启动的活动不为“null”,然后也验证通过 findViewById() 获取的按钮是否为“null”值:
/**
* This test demonstrates ways to exercise the Activity's
* life cycle.
*/
@MediumTest
public void testLifeCycleCreate() {
Forwarding activity = startActivity( mStartIntent, null, null);
// At this point, onCreate() has been called, but nothing else
// 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(), which is always // automatically called, will take care
// of calling onDestroy().
}
}
这可能是这个测试用例中最有趣的测试方法。这个测试用例演示了如何测试 Activity 生命周期。在启动 Activity 后,自动调用了 onCreate(),然后我们通过手动调用它们来测试其他生命周期方法。为了能够调用这些方法,我们使用这个测试的 Intrumentation。
最后,我们不需要手动调用 onDestroy(),因为它将在 tearDown() 中为我们调用:
接下来,我们有testSubLaunch()测试。这个测试在通过startActivity(mStartIntent, null, null)启动测试中的Activity后检查各种条件。使用findViewById()获取Button,然后通过performClick()来按下它。当这个按钮被触摸时,它的动作是启动一个新的Activity,这正是需要检查的条件,断言getStartedActivityIntent()返回"not null"。后者方法返回如果测试中的Activity调用了startActivity(Intent)或startActivityForResult(Intent, int),则使用的 Intent。最后一步是验证如果启动了其他Activity,则调用了finish(),我们通过验证isFinishCalled()的返回值来完成这一点,如果测试中的Activity中调用了 finish 方法(finish()、finishFromChild(Activity)或finishActivity(int)),则返回 true。
现在是时候测试Activity的生命周期了,这可以通过testLifeCycleCreate()方法来实现。该方法以与之前分析过的测试相同的方式启动Activity。
之后,活动启动,其onCreate()方法被调用,使用Instrumentation调用其他生命周期方法,如getInstrumentation().callActivityOnStart(activity)和getInstrumentation().callActivityOnResume(activity)来完成测试中的Activity启动。
现在Activity已经完全启动,是时候测试我们感兴趣的方面了。一旦实现这一点,我们就可以遵循生命周期中的其他步骤。请注意,这个示例测试在这里没有测试任何特殊的内容。
要完成生命周期,我们将调用getInstrumentation().callActivityOnPause(activity)和getInstrumentation().callActivityOnStop(activity)。正如方法注释中提到的,我们不必担心调用onDestroy(),因为它将由tearDown()自动调用。
如果你想运行测试,一旦你将ApiDemos.apk及其测试安装到设备或模拟器上,你可以运行以下命令行:
$ adb -e shell am instrument -w -e class com.example.android.apis.app.ForwardingTest com.example.android.apis.tests/android.test.InstrumentationTestRunner
输出如下:
com.example.android.apis.app.ForwardingTest:... 仪器测试运行器的测试结果=... 时间:0.614 OK (3 tests)
这个测试代表了一个可以重用的骨架,用于单独测试你的Activities以及测试与生命周期相关的案例。注入模拟对象也可以方便测试Activity的其他方面,例如访问系统资源。
测试文件、数据库和 ContentProviders
一些测试用例需要执行数据库或 ContentProviders 的操作,很快就需要对这些操作进行模拟。例如,如果我们正在测试一个真实设备上的应用程序,我们不想干扰这些设备上应用程序的正常运行,尤其是在我们更改可能被多个应用程序共享的值时。
这种情况可以利用另一个模拟类,它不是 android.test.mock 包的一部分,而是 android.test 包的一部分,即 RenamingDelegatingContext。
这个类让我们可以模拟文件和数据库操作。在构造函数中提供的前缀用于修改这些操作的目标。所有其他操作都委托给在构造函数中指定的委托 Context。
假设我们的被测试 Activity 使用一些我们想要以某种方式控制的文件或数据库,也许是为了引入专门的内容来驱动我们的测试,而我们不想使用或无法使用真实的文件或数据库。在这种情况下,我们创建一个指定前缀的 RenamingDelegatingContext。我们使用这个前缀提供模拟文件,并引入我们需要的任何内容来驱动我们的测试,并且被测试的 Activity 可以使用它们而无需更改。
保持我们的 Activity 不变,即不修改它以从不同的源读取,其优势是这确保了所有测试都是有效的。如果我们只为测试引入更改,我们就无法确保在实际条件下,Activity 的行为相同。
为了演示这种情况,我们将创建一个非常简单的 Activity。
活动类 MockContextExampleActivity 在 TextView 中显示文件内容。我们想要展示的是,与测试时相比,它在 Activity 的正常操作中如何显示不同的内容:
package com.example.aatg.mockcontextexample;
import android.app.Activity;
import android.graphics.Color;
import android.os.Bundle;
import android.widget.TextView;
import java.io.FileInputStream;
public class MockContextExampleActivity extends Activity {
public final static String FILE_NAME = "myfile.txt";
private TextView mTv;
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
mTv = (TextView) findViewById(R.id.TextView01);
final byte[] buffer = new byte[1024];
try {
final FileInputStream fis = openFileInput(FILE_NAME);
final int n = fis.read(buffer);
mTv.setText(new String(buffer, 0, n-1));
} catch (Exception e) {
mTv.setText(e.toString());
mTv.setTextColor(Color.RED);
}
}
public String getText() {
return mTv.getText().toString();
}
}
这是我们简单的 Activity。它读取 myfile.txt 文件的内容,并在 TextView 上显示。它还会显示可能发生的任何错误。
我们需要为这个文件提供一些内容。可能最简单的方法是按照以下方式创建文件:
$ adb shell echo "This is real data" \> \ /data/data/com.example.aatg.mockcontextexample/files/myfile.txt
$ adb shell echo "This is *MOCK* data" \> \ /data/data/com.example.aatg.mockcontextexample/files/test.myfile.txt
我们创建了两个不同的文件,一个名为 myfile.txt,另一个名为 test.myfile.txt,内容不同。后者表示它是一个模拟内容。
以下代码演示了在我们的活动测试中使用此模拟数据:
package com.example.aatg.mockcontextexample.test;
import com.example.aatg.mockcontextexample. MockContextExampleActivity;
import android.content.Intent;
import android.test.ActivityUnitTestCase;
import android.test.RenamingDelegatingContext;
public class MockContextExampleTest extends ActivityUnitTestCase<MockContextExampleActivity> {
private static final String PREFIX = "test.";
private RenamingDelegatingContext mMockContext;
public MockContextExampleTest() {
super(MockContextExampleActivity.class);
}
protected void setUp() throws Exception {
super.setUp();
mMockContext = new RenamingDelegatingContext( getInstrumentation().getTargetContext(), PREFIX);
mMockContext.makeExistingFilesAndDbsAccessible();
}
protected void tearDown() throws Exception {
super.tearDown();
}
public void testSampleTextDisplayed() {
setActivityContext(mMockContext);
startActivity(new Intent(), null, null);
final MockContextExampleActivity activity = getActivity();
assertNotNull(activity);
String text = activity.getText();
assertEquals("This is *MOCK* data", text);
}
}
类 MockContextExampleTest 扩展了 ActivityUnitTestCase,因为我们正在寻找对 MockContextExampleActivity 的隔离测试,并且我们打算注入一个模拟上下文;在这种情况下,注入的上下文是一个作为依赖项的 RenamingDelegatinContext。
我们的测试设置包括模拟上下文 mMockContext,使用通过 .getInstrumentation().getTargetContext() 获取的目标上下文创建的 RenamingDelegatingContext。请注意,运行测试的上下文与被测试 Activity 的上下文不同。
在这里,一个基本步骤紧随其后——因为我们想要使现有的文件和数据库对这次测试可用,我们必须调用 makeExistingFilesAndDbsAccessible()。
然后,我们的测试名为 testSampleTextDisplayed() 的测试用例使用 setActivityContext() 注入模拟上下文。
小贴士
在通过 startActivity() 启动被测试的 Activity 之前,您必须调用 setActivityContext() 来注入模拟上下文 之前。
然后通过使用刚刚创建的Intent调用startActivity()来启动Activity。
被测试的Activity是通过getActivity()获取的,并且会验证其值为“非空”。
我们通过为Activity添加的 getter 方法来获取TextView持有的文本值。
最后,获取到的文本值会与String"This is MOCK data"*进行比对。在这里需要注意的是,用于此测试的值是测试文件内容,而不是实际文件内容。
浏览器提供者测试
这些测试来自 Android 开源项目(AOSP)。源代码可以作为 Browser.git 项目的组成部分获取,项目地址为android.git.kernel.org/?p=platform/packages/apps/Browser.git。它们旨在测试浏览器书签内容提供者BrowserProvider的一些方面,它是 Android 平台标准浏览器的一部分。
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied.
* See the License for the specific language governing permissions
* and limitations under the License.
*/
package com.android.browser;
import android.app.SearchManager;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.test.AndroidTestCase;
import android.test.suitebuilder.annotation.MediumTest;
import java.util.ArrayList;
import java.util.Arrays;
这第一个代码片段除了必要的版权声明和导入之外,没有其他内容:
/**
* Unit tests for {@link BrowserProvider}.
*/
@MediumTest
public class BrowserProviderTests extends AndroidTestCase {
private ArrayList<Uri> mDeleteUris;
@Override
protected void setUp() throws Exception {
mDeleteUris = new ArrayList<Uri>();
super.setUp();
}
@Override
protected void tearDown() throws Exception {
for (Uri uri : mDeleteUris) {
deleteUri(uri);
}
super.tearDown();
}
第二段代码包括扩展AndroidTestCase的测试用例定义。类BrowserProviderTests扩展AndroidTestCase,因为需要Context来访问提供者内容。
在setUp()方法中创建的测试环境创建了一个Uris的ArrayList,用于跟踪在tearDown()方法中要删除的已插入Uris。也许我们可以通过使用模拟内容提供者来避免所有这些麻烦,保持测试与系统之间的隔离。无论如何,tearDown()会遍历这个列表并删除存储的Uris。
在这里不需要重写构造函数,因为AndroidTestCase不是一个参数化类,我们也不需要在其中做任何特殊处理:
public void testHasDefaultBookmarks() {
Cursor c = getBookmarksSuggest("");
try {
assertTrue("No default bookmarks", c.getCount() > 0);
} finally {
c.close();
}
}
public void testPartialFirstTitleWord() {
assertInsertQuery("http://www.example.com/rasdfe", "nfgjra sdfywe", "nfgj");
}
public void testFullFirstTitleWord() {
assertInsertQuery("http://www.example.com/", "nfgjra dfger", "nfgjra");
}
public void testFullFirstTitleWordPartialSecond() {
assertInsertQuery("http://www.example.com/", "nfgjra dfger", "nfgjra df");
}
public void testFullTitle() {
assertInsertQuery("http://www.example.com/", "nfgjra dfger", "nfgjra dfger");
}
下一个测试testHasDefaultBookmarks()是针对默认书签的测试。启动时,光标会遍历通过调用getBookmarksSuggest("")获得的默认书签,该调用返回未过滤的书签;这就是为什么查询参数是""。
然后,testPartialFirstTitleWord()、testFullFirstTitleWord()、testFullFirstTitleWordPartialSecond()和testFullTitle()测试了书签的插入。为了实现这一点,它们使用书签的Url、标题和查询调用assertInsertQuery()方法。assertInsertQuery()方法将书签添加到书签提供者中,插入作为参数提供的Url以及指定的标题。返回的Uri会被验证,确保它不是 null,并且与默认值不完全相同。最后,Uri会被插入到testDown()中要删除的Uri实例列表中:
// Not implemented in BrowserProvider
// public void testFullSecondTitleWord() {
// assertInsertQuery("http://www.example.com/rasdfe", // "nfgjra sdfywe", "sdfywe");
// }
public void testFullTitleJapanese() {
String title = "\u30ae\u30e3\u30e9\u30ea\u30fc\ u30fcGoogle\u691c\u7d22";
assertInsertQuery("http://www.example.com/sdaga", title, title);
}
public void testPartialTitleJapanese() {
String title = "\u30ae\u30e3\u30e9\u30ea\u30fc\ u30fcGoogle\u691c\u7d22";
String query = "\u30ae\u30e3\u30e9\u30ea\u30fc";
assertInsertQuery("http://www.example.com/sdaga", title, query);
}
// Test for http://b/issue?id=2152749
public void testSoundmarkTitleJapanese() {
String title = "\u30ae\u30e3\u30e9\u30ea\u30fc\ u30fcGoogle\u691c\u7d22";
String query = "\u30ad\u30e3\u30e9\u30ea\u30fc";
assertInsertQuery("http://www.example.com/sdaga", title, query);
}
这些测试与之前展示的测试类似,但在这个案例中,它们使用了日语文本标题和查询。建议在不同的条件下测试应用程序的组件,例如在这个案例中使用了不同字符集的其他语言。
我们有几个测试,旨在验证除了英语之外的其他地区和语言的此书签提供者的使用情况。这些特定案例涵盖了书签标题中的日语使用。测试 testFullTitleJapanese()、testPartialTitleJapanese() 和 testSoundmarkTitleJapanese() 是之前引入的测试的日语版本,使用 Unicode 字符:
//
// Utilities
//
private void assertInsertQuery(String url, String title, String query) {
addBookmark(url, title);
assertQueryReturns(url, title, query);
}
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 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 addBookmark(String url, String title) {
Uri uri = insertBookmark(url, title);
assertNotNull(uri);
assertFalse( android.provider.Browser.BOOKMARKS_URI.equals(uri));
mDeleteUris.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( android.provider.Browser.BOOKMARKS_URI, values);
}
private void deleteUri(Uri uri) {
int count = getContext().getContentResolver(). delete(uri, null, null);
assertEquals("Failed to delete " + uri, 1, count);
}
private static 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);
}
}
接下来是几个实用方法。这些是在测试中使用的实用工具。我们之前简要地看过 assertInsertQuery(),现在让我们也看看其他方法。
在 addBookmark() 之后,assertInsertQuery() 方法调用 assertQueryReturns(url, title, query),以验证 getBookmarksSuggest(query) 返回的 Cursor 包含预期的数据。这种期望可以总结为:
-
查询返回的行数大于 0
-
查询返回的行数等于 1
-
返回行中的标题不为空
-
查询返回的标题与方法参数完全相同
-
建议的第二行不为空
-
查询返回的 URL 不为空
-
此 URL 与作为方法参数发布的 URL 完全匹配
这是一个简化的活动图,将帮助我们理解这些方法之间的关系:

这些测试遵循之前描述的基本结构,并在 UML 活动图中表示。首先,调用 assertInsertQuery(),它反过来调用 addBookmark() 和 assertQueryReturns()。然后,调用 getBookmarksSuggest(),最后进行断言以验证我们正在测试的条件。这里最突出的是在这些实用方法中对断言的利用,这有助于我们在测试过程中测试条件。
这种策略为我们测试提供了一种有趣的模式。我们需要创建以完成测试的一些实用方法也可以携带它们自己的多个条件的验证,并提高我们的测试质量。
在我们的类中创建断言方法允许我们引入特定领域的测试语言,该语言可以在测试系统的其他部分时重复使用。
测试异常
我们之前提到过。在 第一章 中,我们提到应该测试异常和错误值,而不仅仅是测试正面案例。
我们之前也介绍过这个测试,但在这里我们更深入地探讨它:
public final void testExceptionForLessThanAbsoluteZeroF() {
try {
TemperatureConverter.fahrenheitToCelsius( TemperatureConverter.ABSOLUTE_ZERO_F-1);
fail();
}
catch (InvalidTemperatureException ex) {
// do nothing
}
}
public final void testExceptionForLessThanAbsoluteZeroC() {
try {
TemperatureConverter.celsiusToFahrenheit( TemperatureConverter.ABSOLUTE_ZERO_C-1);
fail();
}
catch (InvalidTemperatureException ex) {
// do nothing
}
}
每当我们有一个应该抛出异常的方法时,我们应该测试这个条件。最好的方法是调用方法内部的 try-catch 块,捕获预期的 Exception,否则失败。在这个特定情况下,我们测试 InvalidTemperature:
public void testLifeCycleCreate() {
Forwarding activity = startActivity(mStartIntent, null, null);
// At this point, onCreate() has been called,
// but nothing else
// 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(), which is always
// automatically called, will take care
// of calling onDestroy().
}
测试本地和远程服务
这个测试也来自 ApiDemos 示例应用程序 (developer.android.com/resources/samples/ApiDemos/index.html)。
想法是扩展 ServiceTestCase<Service> 类以在受控环境中测试服务:
/*
* Copyright (C) 2008 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied.
* See the License for the specific language governing permissions
* and limitations under the License.
*/
package com.example.android.apis.app;
import android.app.Notification;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.os.Handler;
import android.os.IBinder;
import android.test.MoreAsserts;
import android.test.ServiceTestCase;
import android.test.suitebuilder.annotation.MediumTest;
import android.test.suitebuilder.annotation.SmallTest;
这个第一个代码片段除了必要的版权声明和导入之外,没有其他内容:
/**
* This is a simple framework for a test of a Service.
* See {@link android.test.ServiceTestCase
* ServiceTestCase} for more information on how to write and
* extend service tests.
*
* To run this test, you can type:
* adb shell am instrument -w \
* -e class com.example.android.apis.app.LocalServiceTest \
* com.example.android.apis.tests/android.test.
* InstrumentationTestRunner
*/
public class LocalServiceTest extends ServiceTestCase<LocalService> {
public LocalServiceTest() {
super(LocalService.class);
}
然后,我们像之前一样使用无参数构造函数,使用服务类 LocalService: 调用超类构造函数:
@Override
protected void setUp() throws Exception {
super.setUp();
}
现在我们正在使用在 setUp() 和 tearDown() 中调用超类方法的模式。
在这个测试中我们没有设置任何特定的固定装置,所以我们只是调用超类方法:
/**
* The name 'test preconditions' is a convention to signal that
* if this
* test doesn't pass, the test case was not set up properly and
* it might
* explain any and all failures in other tests. This is not
* guaranteed to run before other tests, as junit uses
* reflection to find the tests.
*/
@SmallTest
public void testPreconditions() {
}
我们现在有一个空的 testPreconditions()。在这里我们不需要测试任何先决条件:
/**
* Test basic startup/shutdown of Service
*/
@SmallTest
public void testStartable() {
Intent startIntent = new Intent();
startIntent.setClass(getContext(), LocalService.class);
startService(startIntent);
}
/**
* Test binding to service
*/
@MediumTest
public void testBindable() {
Intent startIntent = new Intent();
startIntent.setClass(getContext(), LocalService.class);
IBinder service = bindService(startIntent);
}
}
构造函数,与其他类似情况一样,调用父构造函数,传递这个服务类作为参数。
接着是一个 testStartable() 测试。它被 SmallTest 注解标记,以分类这个测试。接下来我们使用我们在这里创建的 Intent 启动服务,将其类设置为正在测试的服务类。我们也为这个 Intent 使用了 instrumented Context。这个类允许进行一些依赖注入,因为每个服务都依赖于它运行的上下文以及与之关联的应用程序。这个框架允许你注入修改过的、模拟的或隔离的替代品来替换这些依赖项,从而执行真正的单元测试。
由于我们只是按原样运行测试,Service 将被注入一个完全功能的 Context 和一个通用的 MockApplication 对象。
然后,我们使用 startService(startIntent) 方法启动服务,就像它是由 Context.startService() 启动的一样,提供它所提供的参数。如果你使用这个方法来启动服务,它将自动由 tearDown() 停止。
另一个测试,testBindable(),被分类为 MediumTest,将测试服务是否可以被绑定。这个测试使用 bindService(startIntent),以与 Context.bindService() 相同的方式启动正在测试的服务,提供它所提供的参数。它返回到服务的通信通道。如果客户端无法绑定到服务,它可能返回 null。最可能的是,这个测试应该通过一个断言如 assertNotNull(service) 来检查服务的 null 返回值,以验证服务是否正确绑定,但它没有这样做。确保在编写类似情况的代码时包含这个测试。
返回的 IBinder 通常用于一个使用 AIDL 描述的复杂接口。为了测试这个接口,你的服务必须实现一个 getService() 方法,正如在 samples.ApiDemos.app.LocalService 中的实现所示:
/**
* Class for clients to access. Because we know this service
* always runs in the same process as its clients,
* we don't need to deal with IPC.
*/
public class LocalBinder extends Binder {
LocalService getService() {
return LocalService.this;
}
}
广泛使用模拟对象
在前面的章节中,我们描述并使用了 Android SDK 中存在的模拟类。虽然这些类可以覆盖大量情况,但这并不是全部,你可能还需要其他模拟对象来完善你的测试用例。
几个库提供了满足我们的模拟需求的基础设施,但我们现在专注于 EasyMock,这可能是 Android 中使用最广泛的。
注意
这不是一个 EasyMock 教程。我们只是分析其在 Android 中的应用,所以如果你不熟悉它,我建议你查看其网站上的文档:easymock.org/。
EasyMock 是一个开源软件项目,在 Apache 2.0 许可下可用,主要提供接口的模拟对象。由于其记录期望的方式和动态生成的模拟对象(因为它们支持重构,并且在重命名方法或更改其签名时测试代码不会中断),它非常适合测试驱动开发。
根据其文档,EasyMock 最相关的优点如下:
-
不需要手动编写模拟对象的类。
-
支持重构安全的模拟对象。当重命名方法或重新排序方法参数时,测试代码在运行时不会中断。
-
支持返回值和异常。
-
支持检查一个或多个模拟对象的方法调用顺序。
为了展示其用法并建立一个可以后来复制的风格,我们正在完成和扩展之前为我们的应用程序产生的测试用例。
在我们之前的TemperatureConverter示例中,我们决定扩展EditText来创建EditNumber,这是一个只接受有符号十进制数字的文本字段。EditNumber使用InputFilter来提供这个功能。在接下来的测试中,我们将测试这个过滤器以验证是否实现了正确的行为。
为了创建测试,我们将使用EditNumber从EditText继承的一个属性,这个属性可以添加一个监听器,实际上是一个TextWatcher,在EditText的文本变化时调用方法。这个TextWatcher是测试的协作者,我们本可以将其实现为一个单独的类,但这很麻烦,可能会引入更多错误,因此采取的方法是使用 EasyMock 来避免编写它。
正是这样,我们引入了一个模拟TextWatcher来检查文本变化时的方法调用。
注意
到本文写作时,Android 支持的 EasyMock 最新版本是 EasyMock 2.5.2。你可能想尝试不同的版本,但很可能会遇到问题。
我们应该做的第一件事是将easymock-2.5.2.jar添加到测试项目的属性中。
以下截图显示了如何将 easymock JAR 文件添加到测试项目的Java 构建路径中:

为了在我们的测试中使用 EasyMock,我们只需要从 org.easymockEasyMock 静态导入其方法,这些方法是 EasyMock 2 的唯一非内部、非弃用的方法:
import static org.easymock.EasyMock.*;
使用特定导入而不是使用通配符更可取,但在 Eclipse 中创建静态导入语句并不容易。然而,如果我们组织导入(使用 源 | 组织导入 或快捷键 Shift+Ctrl+O),Eclipse 将创建特定的语句。
导入库
我们已经将 EasyMock 库添加到项目的 Java 构建路径中。这通常不是问题,但有时重新构建项目会导致我们遇到以下错误,从而避免生成最终的 APK。问题出现在无法创建最终 APK 时,因为存档过程中出现了问题:
[2010-10-28 01:12:29 - TemperatureConverterTest] 生成最终存档时出错:重复条目:LICENSE
这取决于项目包含多少库以及它们是什么。
大多数可用的开源库都有类似于 GNU 提出的类似内容,包括 LICENSE、NOTICE、CHANGES、COPYRIGHT、INSTALL 等文件。当我们尝试在同一个项目中包含多个库以最终构建单个 APK 时,我们会发现这个问题。
解决这个问题的方法是重新打包库内容,重命名这些文件;例如,LICENSE 可以重命名为 LICENSE.<library>。建议将后缀 android 添加到重新打包的库中,以跟踪这些更改。
这是您可能需要重命名这些文件的步骤示例:
$ mkdir mylib-1.0
$ (cd mylib-1.0; jar xf /path/to/mylib-1.0.jar)
$ mv mylib-1.0/META-INF/LICENSE mylib-1.0/META-INF/LICENSE.mylib
$ mv mylib-1.0/META-INF/NOTICE mylib-1.0/META-INF/NOTICE.mylib
$ (cd mylib-1.0; jar cf /path/to/mylib-1.0-android.jar .)
策略是将常见的文件名移动到以库名称后缀的名称中,以提供一些唯一性。
测试 testTextChanged
这个测试将测试 EditNumber 的行为,检查对 TextWatcher 模拟的方法调用并验证结果。
我们使用 AndroidTestCase,因为我们对在与其他组件或 Activities 分离的情况下测试 EditNumber 感兴趣。
这个测试定义了两个 String 数组:sai 和 sar。sai 代表 String 数组输入,sar 代表 String 数组结果。正如你可能已经猜到的,sai 包含输入,而 sar 包含在应用过滤器后输入中相应元素的预期结果。
在现实生活中,你应该为测试中使用的变量选择更具描述性的名称,就像你应该对你的代码做的那样,但在这里我们受限于空间,因此选择了非常短的名字。saInput 和 saResult 会是不错的选择:
/**
* Test method for {@link com.example.aatg.tc.EditNumber}.
* Several input strings are set and compared against the
* expected results after filters are applied.
* This test use {@link EasyMock}
*/
public final void testTextChanged() {
final String[] sai = new String[] {
null, "", "1", "123", "-123", "0", "1.2", "-1.2", "1-2-3", "+1", "1.2.3" };
final String[] sar = new String[] {
"", "", "1", "123", "-123", "0", "1.2", "-1.2", "123", "1", "12.3" };
// mock
final TextWatcher watcher = createMock(TextWatcher.class);
mEditNumber.addTextChangedListener(watcher);
for (int i=1; i < sai.length; i++) {
// record
watcher.beforeTextChanged(stringCmp(sar[i-1]), eq(0),
eq(sar[i-1].length()), eq(sar[i].length()));
watcher.onTextChanged(stringCmp(sar[i]), eq(0),
eq(sar[i-1].length()), eq(sar[i].length()));
watcher.afterTextChanged(stringCmp(
Editable.Factory.getInstance().newEditable(sar[i])));
// replay
replay(watcher);
// exercise
mEditNumber.setText(sai[i]);
// test
final String actual = mEditNumber.getText().toString();
assertEquals(sai[i] + " => " + sar[i] + " => " + actual, sar[i], actual);
// verify
verify(watcher);
// reset
reset(watcher);
}
}
我们开始创建 sai 和 sar。正如我们之前解释的,它们是包含输入和预期结果的两个 String 数组。
然后,我们使用 createMock(TextWatcher.class) 创建一个模拟 TextWatcher 并将其分配给在测试用例中创建的 EditNumber。
我们创建一个循环来遍历 sai 数组的每个元素。
接下来,我们采取通常需要的七个步骤来使用模拟对象:
-
使用
createMock(), createNiceMock(),或createStrictMock()创建模拟。 -
记录预期的行为;所有调用的方法都将被记录。
-
重放,将对象的状态从记录变为播放,当它真正表现得像模拟对象时。
-
练习方法,通常是通过调用被测试类的方
-
使用断言测试已执行方法的输出结果。对于简单情况,这一步是可选的。
-
验证指定的行为是否确实被执行。如果不是这种情况,我们将收到一个异常。
-
重置可以用来重用模拟对象,清除其状态。
在记录步骤中,我们声明所有预期在模拟对象上调用及其参数的方法。我们使用比较器来处理参数。
我们将使用一个特殊的 Comparator, stringCmp(),因为我们感兴趣的是比较 Android 中不同类使用的 String 内容,例如 Editable, CharSequence, String 等。
另一个比较器 eq() 期望一个等于给定值的 int。后者由 EasyMock 为所有原始类型和 Object 提供,但我们需要实现 stringCmp(),因为它支持一些 Android 特定的用法。
EasyMock 有一个预定义的匹配器,可以帮助我们创建比较器:
public static <T> T cmp(T value, Comparator<? super T> comparator, LogicalOperator operator)
cmp 比较器方法期望一个参数,该参数将使用提供的比较器通过运算符进行比较。将要进行的比较是 comparator.compare(actual, value) operator 0,其中运算符可以是 EasyMock 的 LogicalOperator enum 中的逻辑运算符值之一,代表 <,<=,>,>= 或 ==。
如您可能已经意识到,在测试中频繁使用可能会非常复杂,并可能导致错误,因此为了简化此过程,我们将使用一个我们称之为 StringComparator: 的辅助类。
public static final class StringComparator<T> implements Comparator<T> {
/* (non-Javadoc)
* @see java.util.Comparator#compare( java.lang.Object, java.lang.Object)
*
* Return the {@link String} comparison of the arguments.
*/
@Override
public int compare(T object1, T object2) {
return object1.toString().compareTo(object2.toString());
}
}
这个类实现了 Comparator<T> 接口,该接口有一个名为 compare 的抽象方法。我们通过在将传递给参数的对象转换为字符串后返回比较结果来实现此方法。记住,应用于 String 的 compareTo(String string) 比较是将作为参数指定的字符串与使用字符的 Unicode 值进行比较的字符串。它的返回值是:
-
如果字符串包含相同的字符且顺序相同,则为 0(零)
-
如果这个字符串中第一个不相同的字符的 Unicode 值小于指定字符串中相同位置的字符的 Unicode 值,或者如果这个字符串是指定字符串的前缀,则为负整数
-
如果这个字符串中第一个不相同的字符的 Unicode 值大于指定字符串中相同位置的字符的 Unicode 值,或者如果指定字符串是这个字符串的前缀,则是一个正整数
我们可以直接使用这个比较器调用 EasyMock.cmp(),但为了进一步简化事情,我们将创建一个通用的静态方法 stringCmp:。
/**
* Return {@link EasyMock.cmp} using a {@link StringComparator} and
* {@link LogicalOperator.EQUAL}
*
* @param <T> The original class of the arguments
* @param o The argument to the comparison
* @return {@link EasyMock.cmp}
*/
public static <T> T stringCmp(T o) {
return cmp(o, new StringComparator<T>(), LogicalOperator.EQUAL);
}
此方法将使用特定类型的正确比较器和 EQUAL 作为操作符调用 EasyMock.cmp()。
那就是为什么在我们的测试中我们可以简单地使用:
watcher.beforeTextChanged(stringCmp(sar[i-1]), …
介绍 Hamcrest
虽然前一种方法有效,但更通用的方法是将 hamcrest 引入,这是一个匹配器对象库(也称为约束或谓词),允许声明性地定义 匹配 规则,以便在其他框架中使用。Hamcrest 还为 EasyMock 2 提供适配器。
我们将重新审视之前介绍 hamcrest 用于匹配器的示例。
为了能够使用 hamcrest,我们需要将其包含到 Java 构建路径 中。
注意
在此示例中,我们使用 hamcrest-1.2,这是最新版本。我们使用单个组件和之前描述的方法,而不是使用 hamcrest-1.2-all.jar,以避免多个 LICENSE.txt 文件冲突。
从 code.google.com/p/hamcrest. 下载 hamcrest 库
你需要包含以下 JAR 文件:
-
hamcrest-core -
hamcrest-library -
hamcrest-integration
以下截图显示了添加 hamcrest 库后的新项目属性:

Hamcrest 匹配器
Hamcrest 随带一组有用的匹配器。以下是一些最重要的匹配器:
-
Core
-
anything: 总是匹配;如果你不关心被测试的对象是什么,则很有用
-
describedAs: 添加自定义失败描述的装饰器
-
is: 提高可读性的装饰器
-
-
Logical
-
allOf: 如果所有匹配器匹配,则匹配,短路(类似于 Java &&)
-
anyOf: 如果任何匹配器匹配,则匹配,短路(类似于 Java ||)
-
not: 如果包装的匹配器不匹配,则匹配,反之亦然
-
-
对象
-
equalTo: 使用
Object.equals测试对象相等性 -
hasToString: 测试
Object.toString -
instanceOf, isCompatibleType: 测试类型
-
notNullValue, nullValue: 测试空值
-
sameInstance: 测试对象身份
-
-
Beans
- hasProperty: 测试 JavaBeans 属性
-
Collections
-
数组: 测试数组元素与匹配器数组的匹配
-
hasEntry, hasKey, hasValue: 测试包含条目、键或值的映射
-
hasItem, hasItems: 测试包含元素的集合
-
hasItemInArray: 测试包含元素的数组
-
-
数字
-
closeTo: 测试浮点值接近给定值
-
greaterThan, greaterThanOrEqualTo, lessThan, lessThanOrEqualTo: 测试排序
-
-
Text
-
equalToIgnoringCase: 测试字符串忽略大小写的相等性
-
equalToIgnoringWhiteSpace: 测试忽略空白字符差异的字符串相等性
-
containsString, endsWith, startsWith: 测试字符串匹配
-
hasToString 匹配器
我们的下一步是创建匹配器以替换之前使用的 stringCmp() 比较器。EasyMock2Adapter 是 hamcrest 提供的适配器类:
import org.hamcrest.integration.EasyMock2Adapter;
import org.hamcrest.object.HasToString;
/**
* Create an {@link EasyMock2Adapter} using a
* {@link HasToString.hasToString}
*
* @param <T> The original class of the arguments
* @param o The argument to the comparison
* @return o
*/
public static <T> T hasToString(T o) {
EasyMock2Adapter.adapt(
HasToString.hasToString(o.toString()));
return o;
}
在实现了这个匹配器之后,接下来的步骤仍然是必要的。我们需要将testTextChanged()方法修改为包含这个新创建的匹配器,而不是stringCmp():
// record
watcher.beforeTextChanged(hasToString(sar[i-1]), eq(0),
eq(sar[i-1].length()), eq(sar[i].length()));
watcher.onTextChanged(hasToString(sar[i]), eq(0),
eq(sar[i-1].length()), eq(sar[i].length()));
watcher.afterTextChanged(hasToString(
Editable.Factory.getInstance().newEditable(sar[i])));
隔离测试视图
我们在这里分析的测试也属于 ApiDemos 项目。它展示了当行为本身无法隔离时,如何测试符合Layout的Views的一些属性。测试焦点就是这种情况之一。
为了避免创建完整的Activity,这个测试扩展了AndroidTestCase:
/*
* Copyright (C) 2008 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
* either express or implied.
* See the License for the specific language governing permissions
* and limitations under the License.
*/
package com.example.android.apis.view;
import com.example.android.apis.R;
import android.content.Context;
import android.test.AndroidTestCase;
import android.test.suitebuilder.annotation.SmallTest;
import android.view.FocusFinder;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
正如前例中一样,我们首先开始于所需的版权和导入:
/**
* This exercises the same logic as {@link Focus2ActivityTest} but in
* a lighter weight manner; it doesn't need to launch the activity,
* and it can test the focus behavior by calling {@link FocusFinder}
* methods directly.
*
* {@link Focus2ActivityTest} is still useful to verify that, at an
* end to end level, key events actually translate to focus
* transitioning in the way we expect.
* A good complementary way to use both types of tests might be to
* have more exhaustive coverage in the lighter weight test case,
* and a few end to end scenarios in the functional {@link
* android.test.ActivityInstrumentationTestCase}.
* This would provide reasonable assurance that the end to end
* system is working, while avoiding the overhead of
* having every corner case exercised in the slower,
* heavier weight way.
*
* Even as a lighter weight test, this test still needs access to a
* {@link Context} to inflate the file, which is why it extends
* {@link AndroidTestCase}.
*
* If you ever need a context to do your work in tests, you can
* extend {@link AndroidTestCase}, and when run via an {@link
* android.test.InstrumentationTestRunner},
* the context will be injected for you.
*
* See {@link com.example.android.apis.app.ForwardingTest} for
* an example of an Activity unit test.
*
* See {@link com.example.android.apis.AllTests} for
* documentation on running
* all tests and individual tests in this application.
*/
public class Focus2AndroidTest extends AndroidTestCase {
正如我们之前提到的,这个测试扩展了AndroidTestCase,以便在可能的情况下提供一个轻量级的替代方案ActivityInstrumentationTestCase<Activity>。
你可能考虑过只使用TestCase,但不幸的是,这是不可能的,因为我们需要一个Context来通过LayoutInflater展开 XML 布局,而AndroidTestCase将为我们提供这个组件:
private FocusFinder mFocusFinder;
private ViewGroup mRoot;
private Button mLeftButton;
private Button mCenterButton;
private Button mRightButton;
@Override
protected void setUp() throws Exception {
super.setUp();
mFocusFinder = FocusFinder.getInstance();
// inflate the layout
final Context context = getContext();
final LayoutInflater inflater = LayoutInflater.from(context);
mRoot = (ViewGroup) inflater.inflate(R.layout.focus_2, null);
// manually measure it, and lay it out
mRoot.measure(500, 500);
mRoot.layout(0, 0, 500, 500);
mLeftButton = (Button) mRoot.findViewById(R.id.leftButton);
mCenterButton = (Button) mRoot.findViewById(R.id.centerButton);
mRightButton = (Button) mRoot.findViewById( R.id.rightButton);
}
固定装置的设置如下:
-
FocusFinder是一个提供查找下一个可聚焦View所使用的算法的类。它实现了单例模式,这就是为什么我们使用FocusFinder.getInstance()来获取其引用。这个类有几个方法可以帮助我们在给定各种条件下找到可聚焦和可触摸的项目,如我们之前提到的,给定方向上的最近项或从特定矩形中进行搜索。 -
然后,我们获取
LayoutInflater并展开测试中的布局。 -
我们需要考虑的一件事是,由于我们的测试与其他系统部分是隔离的,我们必须手动测量和布局组件。
-
然后,我们使用查找视图模式,并将找到的视图分配给字段:
/** * The name 'test preconditions' is a convention to signal * that if this test doesn't pass, the test case was not * set up properly and it might explain any and all failures * in other tests. This is not guaranteed to run before * other tests, as junit uses reflection to find the tests. */ @SmallTest public void testPreconditions() { assertNotNull(mLeftButton); assertTrue("center button should be right of left button", mLeftButton.getRight() < mCenterButton.getLeft()); assertTrue("right button should be right of center button", mCenterButton.getRight() < mRightButton.getLeft()); }
一旦配置了固定装置,我们就在测试中描述预条件,正如我们之前提到的,该测试被命名为testPreConditions()。然而,由于测试是通过反射发现的,因此无法保证它将以特定的顺序运行,因为所有测试方法都是通过检查其名称是否以“test”开头来查找的。
这些预条件包括对组件在屏幕上的相对位置的验证。在这种情况下,它们相对于父组件的边缘被使用。
在前一章中,我们列举了我们武器库中所有可用的断言,你可能还记得,为了测试Views的位置,我们在ViewAsserts类中有一个完整的断言集。然而,这取决于布局是如何定义的:
@SmallTest
public void testGoingRightFromLeftButtonJumpsOverCenterToRight() {
assertEquals("right should be next focus from left", mRightButton, mFocusFinder.findNextFocus( mRoot, mLeftButton, View.FOCUS_RIGHT));
}
@SmallTest
public void testGoingLeftFromRightButtonGoesToCenter() {
assertEquals("center should be next focus from right", mCenterButton, mFocusFinder.findNextFocus( mRoot, mRightButton, View.FOCUS_LEFT));
}
}
testGoingRightFromLeftButtonJumpsOverCenterToRight()方法,正如其名称所暗示的,测试当焦点从右侧移动到左侧按钮时,右侧按钮获得的焦点。为了实现这个搜索,我们在setUp()方法期间获得的FocusFinder实例被使用。这个类有一个findNextFocus()方法来获取在给定方向上接收焦点的 View。获取的值与我们的期望值进行核对。
以类似的方式,测试testGoingLeftFromRightButtonGoesToCenter()测试了焦点向相反方向移动。
测试解析器
在许多情况下,你的 Android 应用程序依赖于从网络服务获取的外部 XML、JSON 消息或文档。这些文档用于本地应用程序和服务器之间的数据交换。有许多用例,其中 XML 或 JSON 文档从服务器获取或由本地应用程序生成,以发送到服务器。理想情况下,由这些活动调用的方法必须独立测试,以实现真正的单元测试,为此,我们需要在我们的 APK 中包含一些模拟文件以运行测试。
但问题是我们在哪里可以包含这些文件?
让我们来探究一下。
Android 资产
首先,可以在 Android SDK 文档中找到对资产定义的简要回顾:
“资源”和“资产”在表面上没有太大的区别,但一般来说,你更频繁地使用资源来存储外部内容,而不是使用资产。真正的区别在于,放置在资源目录中的任何内容都将通过 Android 编译的 R 类轻松访问,而放置在资产目录中的任何内容将保持其原始文件格式,为了读取它,你必须使用 AssetManager 以字节流的形式读取文件。因此,将文件和数据保存在资源(res/)中使它们易于访问。
显然,资产是我们需要存储将要解析以测试解析器的文件。
因此,我们的 XML 或 JSON 文件应该放置在资产文件夹中,以防止编译时的操作,并在应用程序或测试运行时能够访问它们的原始内容。
但请注意;我们需要将它们放置在我们的测试项目的资产文件夹中,因为它们不是应用程序的一部分,我们不希望它们被打包进去。
解析活动
这是一个极其简单的活动,用于演示这种情况。我们的活动从服务器获取 XML 或 JSON 文档,然后对其进行解析。假设我们有一个parseXml方法:
package com.example.aatg.parserexample;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserFactory;
import android.app.Activity;
import android.os.Bundle;
import java.io.InputStream;
import java.io.InputStreamReader;
public class ParserExampleActivity extends Activity {
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
}
public String parseXml(InputStream xml) {
try {
XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
factory.setNamespaceAware(true);
XmlPullParser parser = factory.newPullParser();
parser.setInput(new InputStreamReader(xml));
int eventType = parser.getEventType();
StringBuilder sb = new StringBuilder();
while (eventType != XmlPullParser.END_DOCUMENT) {
if(eventType == XmlPullParser.TEXT) {
sb.append(parser.getText());
}
eventType = parser.next();
}
return sb.toString();
}
catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return null;
}
}
这是一个包含解析方法的活动示例,用于说明资产的使用。你的实际应用程序可能看起来非常不同,你的解析器可以作为一个外部类实现,可以在稍后阶段独立测试和集成。
解析器测试
此测试实现了ParserExampleActivity类的ActivityInstrumentationTestCase2:
package com.example.aatg.parserexample.test;
import com.example.aatg.parserexample.ParserExampleActivity;
import android.test.ActivityInstrumentationTestCase2;
import java.io.IOException;
import java.io.InputStream;
public class ParserExampleActivityTest extends ActivityInstrumentationTestCase2<ParserExampleActivity> {
public ParserExampleActivityTest() {
super(ParserExampleActivity.class);
}
protected void setUp() throws Exception {
super.setUp();
}
protected void tearDown() throws Exception {
super.tearDown();
}
public final void testParseXml() {
ParserExampleActivity activity = getActivity();
String result = null;
try {
InputStream myxml = getInstrumentation().getContext(). getAssets().open("my_document.xml");
result = activity.parseXml(myxml);
} catch (IOException e) {
fail(e.getLocalizedMessage());
}
assertNotNull(result);
}
}
几乎所有的方法都是默认方法的简单实现,对我们来说唯一有趣的方法是testParseXml()。首先,通过调用getActivity()获取活动。然后通过getInstrumentation().getContext().getAssets()从资产中打开文件my_document.xml以获取InputStream。请注意,这里的Context以及获取到的资产来自测试包,而不是被测试的Activity。
接下来,使用最近获得的InputStream调用活动parseXml()方法。如果发生Exception,则调用fail(),如果一切顺利,我们测试结果不为 null。
我们应该将用于测试的 XML 文件命名为my_document.xml。
内容可能如下:
<?xml version="1.0" encoding="UTF-8"?>
<!-- place this file in assets/my_document.xml -->
<my>This is my document</my>
测试内存泄漏
有时,内存消耗是衡量测试目标良好行为的一个重要因素,无论是 Activity、Service、ContentProvider 还是其他组件。
为了测试此条件,我们可以使用一个实用测试,您可以在运行测试循环后从其他测试中调用它:
public final 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);
}
此断言可以从其他测试中调用。开始时,它使用getMemoryInfo()从ActivityManager获取MemoryInfo,在通过getSystemService()获取实例后。如果系统认为当前处于低内存状态,则将lowMemory字段设置为 true。
在某些情况下,我们希望更深入地了解资源使用情况,我们可以从进程表中获取更详细的信息。
我们可以创建另一个辅助方法来获取进程信息,并在我们的测试中使用它:
public final String captureProcessInfo() {
String cmd = "ps";
String memoryUsage = null;
int ch; // the character read
try {
Process p = Runtime.getRuntime().exec(cmd);
InputStream in = p.getInputStream();
StringBuffer sb = new StringBuffer(512);
while ((ch = in.read()) != -1) {
sb.append((char) ch);
}
memoryUsage = sb.toString();
} catch (IOException e) {
fail(e.getLocalizedMessage());
}
return memoryUsage;
}
为了获取这些信息,执行一个命令(在这种情况下,使用ps,但您可以根据需要对其进行调整)并使用Runtime.exec()。该命令的输出被连接到一个String中,稍后返回。我们可以使用返回值将其打印到测试中的日志,或者我们可以进一步处理内容以获取摘要信息。
这是一个记录输出的示例:
Log.d(TAG, captureProcessInfo());
当运行此测试时,我们获得有关正在运行的进程的信息:
11-12 21:10:29.182: DEBUG/ActivityTest(1811): USER PID PPID VSIZE RSS WCHAN PC NAME
11-12 21:10:29.182: DEBUG/ActivityTest(1811): root 1 0 312 220 c009b74c 0000ca4c S /init
11-12 21:10:29.182: DEBUG/ActivityTest(1811): root 2 0 0 0 c004e72c 00000000 S kthreadd
11-12 21:10:29.182: DEBUG/ActivityTest(1811): root 3 2 0 0 c003fdc8 00000000 S ksoftirqd/0
11-12 21:10:29.182: DEBUG/ActivityTest(1811): root 4 2 0 0 c004b2c4 00000000 S events/0
11-12 21:10:29.182: DEBUG/ActivityTest(1811): root 5 2 0 0 c004b2c4 00000000 S khelper
11-12 21:10:29.182: DEBUG/ActivityTest(1811): root 6 2 0 0 c004b2c4 00000000 S suspend
11-12 21:10:29.182: DEBUG/ActivityTest(1811): root 7 2 0 0 c004b2c4 00000000 S kblockd/0
11-12 21:10:29.182: DEBUG/ActivityTest(1811): root 8 2 0 0 c004b2c4 00000000 S cqueue
11-12 21:10:29.182: DEBUG/ActivityTest(1811): root 9 2 0 0 c018179c 00000000 S kseriod
[...]
输出被截断以节省空间,但您将获得系统上运行的进程的完整列表。
获得的信息的简要说明如下:
| Column | Description |
|---|---|
| USER | 这是文本用户 ID。 |
| PID | 进程的进程 ID 号。 |
| PPID | 父进程 ID。 |
| VSIZE | 进程的虚拟内存大小(KiB)。这是进程保留的虚拟内存。 |
| RSS | 居住集大小,任务使用的非交换物理内存(以页面为单位)。这是进程实际占用的页面数。这不包括尚未按需加载的页面。 |
| WCHAN | 这是进程等待的“通道”。它是系统调用的地址,如果需要文本名称,可以在名称表中查找。 |
| PC | 当前 EIP(指令指针)。 |
| 状态(无标题) | 进程状态。|
-
S 表示处于可中断的睡眠状态
-
R 表示运行状态
-
T 表示一个停止的进程
-
Z 表示僵尸进程
|
| NAME | 命令名称。Android 中的应用进程在其包名之后被重命名。 |
|---|
摘要
在本章中,我们展示了几个涵盖广泛案例的测试的真实世界示例。您可以在创建自己的测试时将它们作为起点。
我们涵盖了各种测试配方,您可以将其扩展到自己的测试中。我们使用了模拟上下文,并展示了如何在不同情况下使用 RenamingDelegatingContext 来更改测试获得的数据。我们还分析了将这些模拟上下文注入测试依赖项的过程。
然后,我们使用 ActivityUnitTestCase 来在完全隔离的状态下测试 Activity。我们使用 AndroidTestCase 来单独测试 View。我们展示了如何使用 EasyMock 2 来模拟对象,并结合 Hamcrest 提供比较器。最后,我们处理了潜在内存泄漏的分析。
下一章将重点介绍使用持续集成来自动化测试过程。
第八章:持续集成
持续集成是软件工程中的一种敏捷技术,旨在通过持续应用集成和测试来提高软件质量并减少集成更改所需的时间,这与开发周期结束时进行集成和测试的传统方法形成对比。这篇文章最初由马丁·福勒在 2000 年撰写(www.martinfowler.com/articles/continuousIntegration.html),描述了在一个大型软件项目中实施持续集成的经验。
近年来,持续集成得到了广泛的应用,商业工具和开源项目的激增清楚地证明了其成功。这一点并不难理解,因为任何在其职业生涯中参与过使用传统方法进行软件开发项目的人,很可能都经历过所谓的“集成地狱”,在那里集成更改所需的时间超过了制作更改所需的时间。这让你想起了什么吗?
相反,持续集成是一种频繁且分步骤地集成更改的实践。这些步骤微不足道,通常不会出现错误,因为集成过程中产生的错误通常不会立即被发现。最常见的方法是在每次提交源代码仓库后触发构建过程。
这种实践还意味着除了源代码由版本控制系统(VCS)维护之外的其他要求:
-
应通过运行单个命令来自动化构建过程。这个特性已经被像
make这样的工具支持了很长时间,最近也被ant和maven所支持。 -
构建过程应该进行自我测试,以确认新构建的软件符合开发者的期望,这一点到目前为止一直是本书的主题。
-
测试的工件和结果应该易于查找和查看。
在前面的章节中,我们已经为我们的 Android 项目编写了一些测试,现在我们希望将持续集成考虑在内。为了实现这一点,我们想要创建一个与传统的 Eclipse 和 Android ADT 环境共存的模式,因此从源代码树中支持这两种选择。
在本章中,我们将讨论:
-
自动化构建过程
-
将版本控制系统引入流程
-
使用 Hudson 进行持续集成
-
自动化测试
在本章之后,你将能够将持续集成应用于自己的项目,无论其规模大小,无论是拥有数十名开发者的中型或大型软件项目,还是你独自编程的项目。
使用 Ant 手动构建 Android 应用程序
如果我们旨在将持续集成纳入我们的开发流程,第一步将是手动构建 Android 应用程序,因为我们可以将此技术与自动化流程相结合。
在这样做的时候,我们旨在保持我们的项目与 Eclipse 和 ADT 插件构建过程兼容,这正是我们打算做的。据我了解,这是一个巨大的优势,并且通过自动构建并最终显示项目中可能存在的错误来加快开发过程。当编辑资源或其他生成中间类的文件时,这也是一个无价的工具,否则一些简单的错误会在构建过程中太晚被发现。
幸运的是,Android 支持这种替代方案,并且不需要太多努力就可以在同一项目中合并这两种方法。在这种情况下,手动使用ant进行构建是支持的。然而,也存在其他选项,尽管不是开箱即用支持的,例如使用maven或甚至make。
注意
Ant 是一个软件命令行工具和 Java 库,通过在包含目标和依赖关系的 XML 文件中描述它来自动化软件构建过程。
更多信息可以在其主页找到,ant.apache.org/。
Android 基于 Ant 的构建系统需要至少 Ant 1.8 或更高版本。
这里值得注意,整个 Android 平台都是由一个极其复杂的 makefile 结构构建的,这种方法甚至用于构建平台中包含的应用程序,如计算器、联系人、浏览器等。
如果你已经使用 Eclipse 构建项目,你可以使用android工具将其转换。android位于 Android SDK 的工具目录中。如果你使用 Microsoft Windows,你应该将以下示例调整为使用有效的 Windows 路径,并用它们的值替换以下示例中不可用的变量。
首先,我们将当前目录更改为项目目录;虽然不是强制性的,但这简化了一些事情。
然后使用android命令,我们将项目转换为使用ant构建,并创建build.xml构建文件:
$ cd <path/to>/TemperatureConverter
$ android update project --path $PWD --name TemperatureConverter
这是获得的输出:
更新 local.properties
添加文件 <path/to>/TemperatureConverter/build.xml
更新文件 <path/to>/TemperatureConverter/proguard.cfg
完成此步骤后,我们就可以从命令行手动构建项目了。此构建文件具有以下目标:
| Target | Description |
|---|---|
| help | 显示简短的帮助信息。 |
| clean | 删除由其他目标创建的输出文件。 |
| compile | 将项目的.java文件编译成.class文件。 |
| debug | 构建应用程序,并使用调试密钥对其进行签名。 |
| release | 构建应用程序。在发布之前,必须对生成的.apk文件进行签名。 |
| install | 将调试包安装/重新安装到运行中的模拟器或设备上。如果应用程序之前已安装,签名必须匹配。 |
| uninstall | 从运行中的模拟器或设备卸载应用程序。 |
其中一些目标在设备或模拟器上操作。如果有多个设备或模拟器连接到构建机器,我们需要在命令行上指定特定的目标。因此,目标使用名为 adb.device.arg 的变量,以便我们指定目标:
$ ant -Dadb.device.arg='-s emulator-5554' install
这是生成的输出:
Buildfile: build.xml
[setup] Android SDK 工具版本 9
[setup] 项目目标:Android 2.3.1
[setup] API 级别:9
[setup] 导入规则文件:platforms/android-8/ant/ant_rules_r2.xml
-compile-tested-if-test:
-dirs:
[echo] 如有必要,创建输出目录...
[mkdir] 创建目录:TemperatureConverter/bin/classes
-resource-src:
[echo] 从资源生成 R.java / Manifest.java...
-aidl:
[echo] 将 aidl 文件编译成 Java 类...
compile:
[javac] 编译 6 个源文件到 TemperatureConverter/bin/classes
-dex:
[echo] 将编译的文件和外部库转换为 TemperatureConverter/bin/classes.dex...
-package-resources:
[echo] 打包资源
[aaptexec] 创建完整的资源包....
-package-debug-sign:
[apkbuilder] 创建 TemperatureConverter-debug-unaligned.apk 并使用调试密钥进行签名...
[apkbuilder] 使用密钥库:.android/debug.keystore
debug:
[echo] 对最终 APK 进行 zip 对齐...
[echo] 调试包:TemperatureConverter/bin/TemperatureConverter-debug.apk
install:
[echo] 将 TemperatureConverter/bin/TemperatureConverter-debug.apk 安装到默认模拟器或设备上...
[exec] 371 KB/s (18635 字节在 0.049 秒内)
[exec] pkg: /data/local/tmp/TemperatureConverter-debug.apk
[exec] 成功
构建成功
总耗时:6 秒
即,运行提到的命令行,以下步骤将被执行:
-
环境设置,包括使用的特定规则
-
如有必要,创建输出目录
-
编译源文件,包括资源、aidl 和 Java 文件
-
将编译的文件转换为
dex -
打包创建和签名
-
将安装到指定的设备或模拟器
一旦我们安装了 APK,并且因为我们现在是从命令行进行所有操作,我们甚至可以启动 TemperatureConverterActivity。使用 am start 命令和一个使用 MAIN 动作以及我们想要启动的活动作为组件的 Intent,我们可以创建以下命令行:
$ adb -s emulator-5554 shell am start -a android.intent.action.MAIN -n com.example.aatg.tc/.TemperatureConverterActivity
活动已启动,您可以在模拟器中验证。然后,我们可以以类似的方式对测试项目进行操作:
$ cd </path/to>/TemperatureConverterTest
$ android update test-project --path $PWD --main <path/to>/TemperatureConverter
如果一切顺利,运行此命令将获得以下类似的输出:
Updated default.properties
更新 local.properties
添加文件 <path/to>/TemperatureConverterTest/build.xml
更新文件 <path/to>/TemperatureConverterTest/proguard.cfg
更新 build.properties
同样,正如我们在主项目中做的那样,我们可以构建和安装测试。要做到这一点,一旦我们将测试项目转换好,我们就可以使用ant来构建它,就像我们为主项目做的那样。要在运行的模拟器上构建和安装它,使用以下命令:
$ ant -Dadb.device.arg='-s emulator-5554' install
值得注意的是,为了能够成功构建项目,我们需要使用的库必须位于项目内部的libs目录中。如果你更喜欢不复制它们,你可以创建到它们原始位置的符号链接。
此外,保持 Eclipse 和 Ant 构建过程同步是一个好习惯,所以如果你将所需的库添加到libs目录中,你也可以使用属性 | Java 构建路径 | 库来替换 Eclipse 项目中库的位置。
现在,我们可以像在前面章节中讨论的那样,从命令行运行测试:
$ adb -e shell am instrument -w com.example.aatg.tc.test/android.test.InstrumentationTestRunner
运行命令后,我们将获得测试结果:
com.example.aatg.tc.test.EditNumberTests:........
com.example.aatg.tc.test.TemperatureConverterActivityTests:..........
com.example.aatg.tc.test.TemperatureConverterApplicationTests:.....
com.example.aatg.tc.test.TemperatureConverterTests:....
InstrumentationTestRunner 的测试结果=...........................
时间:12.125
OK (28 tests)
我们已经通过仅调用一些简单的命令从命令行完成了所有操作,这正是我们为了将其输入到持续集成过程中所寻找的。
Git——快速版本控制系统
Git是一个免费且开源的分布式版本控制系统,旨在以速度和效率处理从小型到非常大的项目。它非常简单易设,我强烈建议即使是个人项目也使用它。没有哪个项目简单到不能从应用这个工具中受益。你可以在git-scm.com/找到信息和下载。
另一方面,版本控制系统或 VCS(也称为源代码管理或SCM)是涉及多个开发者的开发项目中不可避免的一个元素。此外,即使在没有 VCS 的情况下应用持续集成也是可能的,尽管它不是必需的,但这并不是一个合理的做法。
其他,可能还有更多传统的选项存在于版本控制系统(VCS)领域,例如 Subversion 或 CVS,如果你觉得更舒服,可以自由使用。无论如何,Git 在 Android 项目中得到了广泛的应用,因此花些时间至少了解其基础是值得的。
话虽如此,且记住这是一个非常广泛的主题,足以写成一本书(而且确实有一些关于它的好书),我们在这里讨论的是最基本的话题,并提供示例以帮助那些尚未采用这种实践的人入门。
创建本地 git 仓库
这些是创建本地仓库并将我们的项目初始源代码填充到其中的最简单命令。在这种情况下,我们再次使用在前面章节中创建并使用的TemperatureConverter和TemperatureConverterTest项目。我们选择一个名为git-repos的目录作为两个项目的父目录,并将我们在上一节中手动构建时使用的代码复制过来:
$ cd <path/to>/git-repos
$ mkdir TemperatureConverter
$ cd TemperatureConverter
$ git init
$ cp -a <path/to>/TemperatureConverter/. .
$ ant clean
$ rm local.properties
$ git add .
$ git commit -m "Initial commit"
即,我们为仓库创建父目录,创建项目目录,初始化 git 仓库,复制初始内容,清理之前的构建,删除local.properties文件,将所有内容添加到仓库中,并提交。
小贴士
local.properties文件永远不应该被提交到版本控制系统,因为它包含特定于你本地配置的信息。
然后,对于TemperatureConverterTest项目也应做同样的处理:
$ cd <path/to>/git-repos
$ mkdir TemperatureConverterTest
$ cd TemperatureConverterTest
$ git init
$ cp -a <path/to>/TemperatureConverterTest/. .
$ ant clean
$ rm local.properties
$ git add .
$ git commit -m "Initial commit"
到目前为止,我们有两个项目仓库,包含了TemperatureConverter和TemperatureConverterTest项目的初始源代码。我们没有改变它们的结构,因此它们也与Eclipse和Android ADT插件兼容,以便我们在 IDE 中开发时可以构建。
下一步是确保每次我们对源代码进行更改时,两个项目都会自动构建和测试。
使用 Hudson 进行持续集成
Hudson是一个开源、可扩展的持续集成服务器,它具有构建和测试软件项目或监控外部作业执行的能力。Hudson 的安装和配置都很简单,并且做得非常出色,这也是我们以它为基础的原因。
注意
最近(2011 年 1 月),有人提出将名称从 Hudson 更改为 Jenkins,以避免未来可能出现的法律问题,因为 Oracle 已经提交了商标注册。因此,现在存在两个不同的分支项目。尽管这些示例基于 Hudson,但你应该监控各个项目的演变,以找到更适合你需求的项目。
安装和配置 Hudson
我们将简单安装作为 Hudson 的优点之一,安装过程无法再简单。
从hudson-ci.org/下载你选择的操作系统的原生包。有适用于 Debian/Ubuntu、RedHat/Fedora/Centos、openSUSE、OpenSolaris/Nevada 和 FreeBSD 的原生包,或者下载最新的通用hudson.war(它也适用于 Mac 和 Windows)。在以下示例中,我们将使用 2.0 版本。我们将展示后者,因为它不需要管理员权限即可安装、配置和运行。
完成后,将其复制到选定的目录中,比如~/hudson,然后运行以下命令:
$ java -jar hudson-2.0.0.war
这将扩展并启动 Hudson。
默认配置使用 8080 端口作为 HTTP 监听端口,因此将您选择的浏览器指向http://localhost:8080应该会显示 Hudson 主页。
如果需要,您可以通过访问管理 Hudson屏幕来验证和更改 Hudson 的操作参数。我们应该添加到这个配置中用于 Git 集成和构建期间支持 Android 模拟器的插件。这些插件分别命名为Hudson GIT 插件和Android 模拟器插件。
此截图显示了您可以通过 Hudson 管理页面上的超链接获取有关插件的信息:

安装并重新启动 Hudson 后,这些插件将可用于使用。我们的下一步是创建构建项目所需的工作。
创建工作
让我们从使用 Hudson 主页上的新建工作开始创建TemperatureConverter工作。可以创建不同类型的作业;在这种情况下,我们选择构建自由风格软件项目,允许您连接任何 SCM 与任何构建系统。
点击“确定”按钮后,您将看到以下表格中描述的具体工作选项。这是工作属性页面:

新工作屏幕中的所有选项都关联有帮助文本,因此这里我们只解释我们将要输入的选项:
| 选项 | 描述 |
|---|---|
| 项目名称 | 分配给项目的名称。 |
| 描述 | 可选描述。 |
| 丢弃旧构建 | 这有助于您通过管理构建记录(如控制台输出、构建工件等)的保留时间来节省磁盘消耗。 |
| 此构建是参数化的 | 这允许您配置传递给构建过程的参数以创建参数化构建。 |
| 禁用构建(直到项目重新启用,将不会执行新的构建。) | 暂时禁用项目。 |
| 如有必要执行并发构建(beta) | 这允许同时执行多个构建。 |
| 源代码管理 | 也称为 VCS。项目的源代码在哪里?在这种情况下,我们使用 git 和一个 URL 为之前创建的仓库的绝对路径的仓库。例如,/home/diego/aatg/git-repos/TemperatureConverter。 |
| 构建触发器 | 这描述了项目是如何自动构建的。在这种情况下,我们希望每次源代码的更改都触发自动构建,所以我们选择轮询 SCM。另一种选择是使用定期构建。这个功能主要用于将 Hudson 用作cron的替代品,并且对于持续构建软件项目来说并不理想。当人们刚开始持续集成时,他们通常非常习惯于定期构建的想法,如夜间/每周构建,因此他们会使用这个功能。然而,持续集成的目的是在更改发生后立即开始构建,以便为更改提供快速反馈。 |
| 调度 | 此字段遵循cron语法(有一些细微差别)。具体来说,每一行由五个字段组成,字段之间由制表符或空格分隔:分钟 小时 星期几 月份 星期。例如,如果我们想在每小时三十分钟后持续轮询,指定:30 * * * *。请查看文档以获取所有选项的完整解释。 |
| 构建环境 | 允许你为构建环境和可能运行在构建期间的 Android 模拟器指定不同的选项。 |
| 构建 | 这描述了构建步骤。我们选择调用 Ant,因为我们正在重现我们之前手动构建项目的步骤。我们在这里使用的目标是debug,因为我们只想编译项目并生成 APK,而不安装或运行它。此外,使用高级...选项,我们需要指定 Android SDK 目录和 Android 目标版本属性。sdk.dir=/opt/android-sdk target=android-9 |
| 构建后操作 | 这些是在构建完成后我们可以执行的一系列操作。我们感兴趣的是保存 APK,所以我们启用了存档工件,然后定义它们的路径为存档文件;在这个特定情况下是**/*-debug.apk。 |
现在有两种选择:你可以通过立即构建来强制构建,或者通过 Git 对源代码进行一些更改,然后等待我们的轮询策略检测到这些更改。无论哪种方式,我们都会构建我们的项目,并准备好我们的工件,以便用于其他目的,例如依赖项目或质量保证。
到目前为止,我们还没有运行任何测试,我们现在展示的就是这个。Hudson 有处理项目之间依赖关系的能力,因此我们现在创建一个 Hudson 作业,TemperatureConverterTest依赖于TemperatureConverter。
按照之前的方式进行。我们只是在设置此项目与之前设置的不同之处进行精确定位。
| 选项 | 描述 |
|---|---|
| 构建触发器 | 这是我们触发此项目构建的方式。选择在其他项目构建后构建,这样当其他项目完成构建时,就会为该项目安排一个新的构建。我们需要它在TemperatureConverter之后构建。这在构建完成后运行广泛的测试时很方便,就像这个例子一样。 |
| 构建环境 | 我们的意图是在模拟器上安装和运行测试,因此我们的构建环境使用 Android 模拟器插件 提供的设施。如果你希望在构建步骤执行之前自动启动你选择的 Android 模拟器,并在构建完成后停止模拟器,这将非常有用。你可以选择启动一个预定义的、现有的 Android 模拟器实例(AVD)。或者,插件可以自动在构建从机上创建一个新的模拟器,其属性由你在此处指定。无论如何,logcat 输出将自动捕获并归档。然后选择 2.3 作为 Android 操作系统版本,240 DPI 作为 屏幕密度,以及 WVGA 作为 屏幕分辨率。请随意实验并选择更适合你需求的选项。 |
| 常用模拟器选项 | 我们希望 在启动时重置模拟器状态 以清除用户数据并禁用 显示模拟器窗口,这样模拟器窗口就不会显示。 |
| 构建 | 选择 调用 ant 作为构建步骤,并将 install 作为 目标。在这里,就像我们在 TemperatureConverter 中做的那样,我们必须设置一些变量来构建和安装当前作业。使用 高级... 选项设置:sdk.dir=/opt/android-sdk target=android-9 tested.project.dir=../../TemperatureConverter/workspace/ adb.device.arg=-s $ANDROID_AVD_DEVICE。和之前一样,我们指定了 Android SDK 目录和目标版本。此外,在这里我们应该指定目标项目目录,即 SUT,以及我们想要安装 APK 的设备。我们使用 Android 模拟器插件 设置的特殊变量来识别选择为目标的目标设备。 |
在配置和构建此项目后,我们在目标模拟器上安装了 APK。还有一些步骤需要完成,因为我们仍然缺少运行测试和获取要在 Hudson 中显示的结果。
获取 Android 测试结果
为了能够显示测试结果,我们应该在测试运行器中存储原始 XML 结果。默认的 android.test.InstrumentationTestRunner 不支持存储原始 XML,因此这里的解决方案是扩展它以提供缺失的功能。
我在 Google code 上找到了名为 nbandroid-utils (code.google.com/p/nbandroid-utils/) 的项目,该项目提供了我们需要的几乎相同的功能。
com.neenbedankt.android.test.InstrumentationTestRunner 类扩展了 Android 类,以便在运行测试时将测试结果的 XML 写入设备。
我们还希望能够从测试参数中指定文件名,并且能够将文件存储在外部存储中,以防测试结果变得非常大,因此我们稍微修改了该类以支持这些功能。为了使这些更改明显,我们将新类命名为 XMLInstrumentationTestRunner:。
package com.neenbedankt.android.test;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
import org.xmlpull.v1.XmlPullParserFactory;
import org.xmlpull.v1.XmlSerializer;
import android.os.Bundle;
import android.util.Log;
/*
* Copyright (C) 2010 Diego Torres Milano
*
* Base on previous work by
* Copyright (C) 2007 Hugo Visser
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS,WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
* either express or implied.
* See the License for the specific language governing permissions
* and limitations under the License.
*/
/**
* This test runner creates an xml in the files directory of
* the application under test. The output is compatible with
* that of the junitreport ant task, the format that is
* understood by Hudson. Currently this implementation does not
* implement the all aspects of the junitreport format, but
* enough for Hudson to parse the test results.
*/
public class XMLInstrumentationTestRunner extends android.test.InstrumentationTestRunner {
private Writer mWriter;
private XmlSerializer mTestSuiteSerializer;
private long mTestStarted;
这里我们提供了字段以保存输出文件的名称及其默认值。
我们还在定义测试运行器将使用以接收此值的参数名称:
/**
* Output file name.
*/
private String mOutFileName;
/**
* Outfile argument name.
* This argument can be passed to the instrumentation using <code>-e</code>.
*/
private static final String OUT_FILE_ARG = "outfile";
/**
* Default output file name.
*/
private static final String OUT_FILE_DEFAULT = "test-results.xml";
在我们的onCreate()方法中,我们验证是否提供了参数,如果是,则将其存储在之前定义的字段中:
@Override
public void onCreate(Bundle arguments) {
if ( arguments != null ) {
mOutFileName = arguments.getString(OUT_FILE_ARG);
}
if ( mOutFileName == null ) {
mOutFileName = OUT_FILE_DEFAULT;
}
super.onCreate(arguments);
}
在onStart()方法中,我们创建文件并使用它作为 JUnit 输出:
@Override
public void onStart() {
try {
File dir = getTargetContext().getExternalFilesDir(null);
if ( dir == null ) {
dir = getTargetContext().getFilesDir();
}
final File outFile = new File(dir, mOutFileName);
startJUnitOutput(new FileWriter(outFile));
} catch (IOException e) {
throw new RuntimeException(e);
}
super.onStart();
}
以下代码是这个测试运行器的原始代码:
void startJUnitOutput(Writer writer) {
try {
mWriter = writer;
mTestSuiteSerializer = newSerializer(mWriter);
mTestSuiteSerializer.startDocument(null, null);
mTestSuiteSerializer.startTag(null, "testsuites");
mTestSuiteSerializer.startTag(null, "testsuite");
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private XmlSerializer newSerializer(Writer writer) {
try {
XmlPullParserFactory pf = XmlPullParserFactory.newInstance();
XmlSerializer serializer = pf.newSerializer();
serializer.setOutput(writer);
return serializer;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public void sendStatus(int resultCode, Bundle results) {
super.sendStatus(resultCode, results);
switch (resultCode) {
case REPORT_VALUE_RESULT_ERROR:
case REPORT_VALUE_RESULT_FAILURE:
case REPORT_VALUE_RESULT_OK:
try {
recordTestResult(resultCode, results);
} catch (IOException e) {
throw new RuntimeException(e);
}
break;
case REPORT_VALUE_RESULT_START:
recordTestStart(results);
default:
break;
}
}
void recordTestStart(Bundle results) {
mTestStarted = System.currentTimeMillis();
}
void recordTestResult(int resultCode, Bundle results) throws IOException {
float time = (System.currentTimeMillis() - mTestStarted) / 1000.0f;
String className = results.getString(REPORT_KEY_NAME_CLASS);
String testMethod = results.getString(REPORT_KEY_NAME_TEST);
String stack = results.getString(REPORT_KEY_STACK);
int current = results.getInt(REPORT_KEY_NUM_CURRENT);
int total = results.getInt(REPORT_KEY_NUM_TOTAL);
mTestSuiteSerializer.startTag(null, "testcase");
mTestSuiteSerializer.attribute(null, "classname", className);
mTestSuiteSerializer.attribute(null, "name", testMethod);
if (resultCode != REPORT_VALUE_RESULT_OK) {
mTestSuiteSerializer.startTag(null, "failure");
if (stack != null) {
String reason = stack.substring(0, stack.indexOf('\n'));
String message = "";
int index = reason.indexOf(':');
if (index > -1) {
message = reason.substring(index+1);
reason = reason.substring(0, index);
}
mTestSuiteSerializer.attribute(null, "message", message);
mTestSuiteSerializer.attribute(null, "type", reason);
mTestSuiteSerializer.text(stack);
}
mTestSuiteSerializer.endTag(null, "failure");
} else {
mTestSuiteSerializer.attribute(null, "time", String.format("%.3f", time));
}
mTestSuiteSerializer.endTag(null, "testcase");
if (current == total) {
mTestSuiteSerializer.startTag(null, "system-out");
mTestSuiteSerializer.endTag(null, "system-out");
mTestSuiteSerializer.startTag(null, "system-err");
mTestSuiteSerializer.endTag(null, "system-err");
mTestSuiteSerializer.endTag(null, "testsuite");
mTestSuiteSerializer.flush();
}
}
@Override
public void finish(int resultCode, Bundle results) {
endTestSuites();
super.finish(resultCode, results);
}
void endTestSuites() {
try {
if ( mTestSuiteSerializer != null ) {
mTestSuiteSerializer.endTag(null, "testsuites");
mTestSuiteSerializer.endDocument();
mTestSuiteSerializer.flush();
}
if ( mWriter != null) {
mWriter.flush();
mWriter.close();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
实现我们的目标还需要几个步骤。首先是将这个测试运行器通过组合git add/git commit添加到我们的项目中:
您可以简单地使用以下命令:
$ git add src/com/neenbedankt/
$ git commit -a -m "Added XMLInstrumentationTestRunner"
然后我们需要在AndroidManifest.xml中使用测试运行器声明测试。也就是说,使用最近创建的测试运行器com.neenbedankt.android.test.XMLInstrumentationTestRunner作为com.example.aatg.tc包的测试:
<instrumentation
android:targetPackage="com.example.aatg.tc"
android:label="TemperatureConverter tests" android:name="com.neenbedankt.android.test. XMLInstrumentationTestRunner"
/>
同样,将其添加到仓库中,就像我们之前对其他文件所做的那样。
最后,由于我们能够通过使用添加构建步骤来在构建过程中添加一个执行 shell 脚本的步骤,我们将此步骤添加到作业配置页面中的执行 shell步骤。我们使用一些 shell 变量以便能够将此步骤重新用于其他项目:
PKG=com.example.aatg.tc
OUTDIR=/data/data/${PKG}/files/
OUTFILE=test-results.xml
ADB=/opt/android-sdk/platform-tools/adb
$ADB -s $ANDROID_AVD_DEVICE install -r "$WORKSPACE/../../ TemperatureConverter/lastSuccessful/ archive/bin/TemperatureConverter-debug.apk"
$ADB -s $ANDROID_AVD_DEVICE shell am instrument -w -e outfile "$OUTFILE" $PKG.test/com.neenbedankt.android.test.XMLInstrumentationTestRunner
$ADB -s $ANDROID_AVD_DEVICE pull "$OUTDIR/$OUTFILE" "$WORKSPACE/$OUTFILE"
让我们更详细地解释这些步骤:
-
我们将特定的项目包名称分配给 PKG 变量。
-
OUTDIR是测试运行器将文件OUTFILE留下的目录名称。请注意,这是一个在模拟器或设备上的目录,而不是本地目录。 -
将测试包安装到模拟器或设备上。
-
从命令行运行测试,就像我们之前看到的那样,但在这个情况下,添加一个额外的参数
-e outfile,其中包含我们期望接收的文件名。 -
从该文件获取测试结果,从设备拉取到本地工作区。
几乎一切都已经就绪。唯一剩下的是告诉 Hudson 在哪里期望这些测试结果。在这种情况下,我们在作业配置页面中使用构建后操作。
| 选项 | 描述 |
|---|---|
| 发布 JUnit 测试结果报告 | 当此选项配置时,Hudson 可以提供有关测试结果的有用信息,例如历史测试结果趋势、查看测试报告的 Web UI、跟踪失败等。要使用此功能,首先设置构建以运行测试,然后使用com.neenbedankt.android.test.XMLInstrumentationTestRunner作为测试运行器,在仪器测试中使用-e outfile指定输出,并使用此相同的名称告诉 Hudson 在哪里找到结果。Ant glob 语法,如**/build/test-reports/*.xml,也可以使用。确保不要将任何非报告文件包含在此模式中。简单来说,这仅仅是我们在之前的OUTFILE变量中指定的test-results.xml。一旦运行了几个带有测试结果的构建,您应该开始看到一些趋势图表,显示测试的演变。 |
完成之前描述的所有步骤后,只剩下强制构建以查看结果。像往常一样按现在构建,几分钟后您将看到测试结果和统计信息以类似以下屏幕截图所示的方式显示:

从这里我们可以轻松理解我们的项目状态,了解有多少测试失败以及原因。通过检查失败的测试,我们还可以找到广泛的错误消息和堆栈跟踪。
通过评估不同的趋势来了解项目的演变也非常有帮助。每个项目都使用类似天气的图标来展示当前趋势,当项目健康度提高 80%时为晴朗,当健康度低于 20%时为雷暴。此外,对于每个项目,测试成功与失败比率的演变在以下图表中显示:

在这种情况下,我们可以看到自上次构建以来,一个测试开始失败。
要查看通过强制失败来查看项目状态变化的情况,让我们添加一个如下所示的失败测试:
public final void testForceFailure1() {
fail("Forced fail");
}
另一个值得提及的非常有趣的功能是 Hudson 能够保持并显示时间线和构建时间趋势,如下面的屏幕截图所示:

此页面展示了构建历史记录,并提供到每个特定构建的超链接,您可以跟踪以查看详细信息。
现在我们对担忧的事情减少了很多,每次开发团队中的某人在仓库中提交更改时,我们知道这些更改将立即集成,整个项目将构建和测试。如果我们进一步配置 Hudson,我们甚至可以通过电子邮件接收状态。为此,在作业配置页面启用电子邮件通知并输入所需的收件人。
摘要
本章在实践上介绍了持续集成,提供了宝贵的信息,以便您尽快将其应用于项目,无论项目规模大小,无论您是独立开发还是在大公司团队中工作。
所展示的技术专注于 Android 项目的特定之处,维护和支持广泛使用的开发工具,如 Eclipse 和 Android ADT。
我们引入了真实世界的例子,使用了从庞大的开源工具库中可用的真实世界工具。我们使用了 Ant 来自动化构建过程,git 来创建一个简单的版本控制系统存储库以存储我们的源代码并管理变更,最后安装并配置了 Hudson 作为首选的持续集成工具。
在本课程中,我们详细介绍了为自动化创建TemperatureConverter及其测试而创建作业的过程,并强调了项目之间的关系。
最后,我们分析了一种从 Android 测试中获取 XML 结果的方法,并将其实现,以获得一个吸引人的界面来监控测试的运行、结果和现有趋势。
下一章处理测试的不同方面,专注于性能和剖析,这可能是我们在应用程序正确运行并符合我们的规范之后的自然步骤。
第九章。性能测试和配置文件
在前面的章节中,我们研究了为我们的 Android 应用程序开发的测试。这些测试使我们能够评估符合一定数量的规范,并允许我们通过二进制判断(是否合规)来确定软件是否按这些规则正确运行。如果它符合,则软件是正确的;如果不符,我们必须修复它,直到它符合。
在许多其他情况下,主要是在我们验证软件符合所有这些规范之后,我们希望向前推进,了解它们是如何满足的,同时了解系统在不同情况下的性能,以分析其他属性,如可用性、速度、响应时间和可靠性。
根据《Android 开发者指南》(developer.android.com/guide/index.html),以下是我们设计应用程序时的最佳实践:
-
为性能设计
-
为响应性设计
-
为无缝设计
非常重要遵循这些最佳实践,并在设计之初主要从性能和响应性的角度思考。由于我们的应用程序将在有限的计算机功率的移动设备上运行,因此通过识别优化目标,我们可以获得更大的收益,至少部分地构建我们的应用程序,并应用我们即将讨论的性能测试。
如唐纳德·克努特多年前所普及的:
“过早的优化是万恶之源”。
这些基于猜测、直觉甚至迷信的优化,在短期内可能会干扰设计,在长期内可能会干扰可读性和可维护性。相反,微优化基于识别需要优化的瓶颈或热点,应用更改,然后再次基准测试以评估优化的改进。因此,我们在这里关注的是衡量现有性能和优化替代方案。
本章将介绍一系列与基准测试和配置文件相关的概念,如下所示:
-
传统的日志语句方法
-
创建 Android 性能测试
-
使用性能分析工具
-
使用 Caliper 进行微基准测试
旧日志方法
有时这对于实际场景来说过于简单,但我不认为它不能在某些情况下有所帮助,主要是因为它的实现只需要几分钟,而你只需要logcat文本输出来分析案例,这在前面章节中描述的情况中很有用,那时你想自动化流程或应用持续集成。
此方法包括计时一个方法,或其一部分,围绕它进行两次时间测量,并在结束时记录差异:
/* (non-Javadoc)
* @see android.text.TextWatcher#onTextChanged( * java.lang.CharSequence, int, int, int)
*/
public void onTextChanged(CharSequence s, int start, int before, int count) {
if (!mDest.hasWindowFocus() || mDest.hasFocus() || s == null ) {
return;
}
final String str = s.toString();
if ( "".equals(str) ) {
mDest.setText("");
return;
} final long t0;
if ( BENCHMARK_TEMPERATURE_CONVERSION ) {
t0 = System.currentTimeMillis();
}
try {
final double temp = Double.parseDouble(str);
final double result = (mOp == OP.C2F) ? TemperatureConverter.celsiusToFahrenheit(temp) : TemperatureConverter.fahrenheitToCelsius(temp);
final String resultString = String.format("%.2f", result);
mDest.setNumber(result);
mDest.setSelection(resultString.length());
} catch (NumberFormatException e) {
// WARNING
// this is generated while a number is entered,
// for example just a '-'
// so we don't want to show the error
} catch (Exception e) {
mSource.setError("ERROR: " + e.getLocalizedMessage());
} if ( BENCHMARK_TEMPERATURE_CONVERSION ) {
long t = System.currentTimeMillis() - t0;
Log.i(TAG, "TemperatureConversion took " + t + " ms to complete.");
}
}
这非常直接。我们记录时间并记录差异。为此,我们使用 Log.i() 方法,并在运行应用程序时可以在 logcat 中看到输出。您可以通过将 BENCHMARK_TEMPERATURE_CONVERSION 常量设置为 true 或 false 来控制此基准的执行,该常量您应该在别处定义。
当我们在 logcat 中将 BENCHMARK_TEMPERATURE_CONVERSION 常量设置为 true 来启动活动时,每次转换发生时我们都会收到如下消息:
INFO/TemperatureConverterActivity(392): 温度转换完成耗时 55 毫秒。
INFO/TemperatureConverterActivity(392): 温度转换完成耗时 11 毫秒。
INFO/TemperatureConverterActivity(392): 温度转换完成耗时 5 毫秒。
您应该考虑的是,这些启用基准的常量不应在生产构建中启用,因为还使用了其他常见常量,如 DEBUG 或 LOGD。为了避免这种错误,您应该在用于自动化构建(如 Ant 或 Make)的构建过程中集成这些常量值的验证。
很简单,但这不适用于更复杂的情况。
Android SDK 中的性能测试
如果之前添加日志语句的方法不适合您,我们可以采用另一种方法从我们的应用程序中获取性能测试结果。
不幸的是,Android SDK 中的性能测试并不完善(至少在本书编写时,最新的版本是 Android 2.3 Gingerbread)。从 Android SDK 应用程序中获取性能测试结果没有合理的方法,因为 Android 测试所使用的类隐藏在 Android SDK 中,并且仅对系统应用程序可用,即作为主构建或系统镜像的一部分构建的应用程序。这种策略对 SDK 应用程序不可用,因此我们不会深入这个方向,而是将重点放在其他可用的选择上。
启动性能测试
这些测试基于与 Android 测试系统应用程序所使用的方法类似的方法。想法是扩展 android.app.Instrumentation 以提供性能快照,自动创建一个我们可以甚至扩展以满足其他需求的框架。由于这个媒体的限制,我们在这里展示一个简单的案例。
创建 LaunchPerformanceBase 仪器
我们的第一步是扩展 Instrumentation 以提供我们需要的功能。我们正在使用一个名为 com.example.aatg.tc.test.launchperf 的新包来组织我们的测试:
package com.example.aatg.tc.test.launchperf;
import android.app.Instrumentation;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
/**
* Base class for all launch performance Instrumentation classes.
*/
public class LaunchPerformanceBase extends Instrumentation {
public static final String TAG = "LaunchPerformanceBase";
protected Bundle mResults;
protected Intent mIntent;
/**
* Constructor.
*/
public LaunchPerformanceBase() {
mResults = new Bundle();
mIntent = new Intent(Intent.ACTION_MAIN);
mIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
setAutomaticPerformanceSnapshots();
}
/**
* Launches intent {@link #mIntent}, and waits for idle before
* returning.
*/
protected void LaunchApp() {
startActivitySync(mIntent);
waitForIdleSync();
}
@Override
public void finish(int resultCode, Bundle results) {
Log.v(TAG, "Test reults = " + results);
super.finish(resultCode, results);
}
}
我们在这里扩展了 Instrumentation。构造函数初始化了该类中的两个字段:mResults 和 mIntent。最后,我们调用 setAutomaticPerformanceSnapshots() 方法,这是创建此性能测试的关键。
LaunchApp() 方法负责启动所需的 Activity 并等待返回。
finish() 方法记录接收到的结果,然后调用 Instrumentation 的 finish()。
创建 TemperatureConverterActivityLaunchPerformance 类
这个类设置了 Intent 以调用 TemperatureConverterActivity,并提供了 LaunchPerformanceBase 类所提供的基础设施来测试我们的 Activity 的性能:
package com.example.aatg.tc.test.launchperf;
import com.example.aatg.tc.TemperatureConverterActivity;
import android.app.Activity;
import android.os.Bundle;
/**
* Instrumentation class for {@link TemperatureConverterActivity} launch performance testing.
*/
public class TemperatureConverterActivityLaunchPerformance extends LaunchPerformanceBase {
/**
* Constructor.
*/
public TemperatureConverterActivityLaunchPerformance() {
super();
}
@Override
public void onCreate(Bundle arguments) {
super.onCreate(arguments);
mIntent.setClassName("com.example.aatg.tc", "com.example.aatg.tc.TemperatureConverterActivity");
start();
}
/**
* Calls LaunchApp and finish.
*/
@Override
public void onStart() {
super.onStart();
LaunchApp();
finish(Activity.RESULT_OK, mResults);
}
}
在这里,onCreate() 调用 super.onCreate(),遵循 Android 生命周期。然后设置 Intent,指定类名和包名。然后调用 Instrumentation 的一个方法,start(),它创建并启动一个新的线程来运行仪器化测试。这个新线程将调用 onStart(),在那里你可以实现仪器化测试。
然后是 onStart() 的实现,调用 LaunchApp() 和 finish()。
运行测试
为了能够运行这个测试,我们需要在 TemperatureConverterTest 项目的 AndroidManifest.xml 中定义特定的 Instrumentation。
这是我们要添加到清单中的代码片段:
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.example.aatg.tc.test" android:versionCode="1" android:versionName="1.0">
<application android:icon="@drawable/icon" android:label="@string/app_name">
<uses-library android:name="android.test.runner" />
</application>
<uses-sdk android:minSdkVersion="9" />
<instrumentation android:targetPackage="com.example.aatg.tc" android:name="android.test.InstrumentationTestRunner" android:label="Temperature Converter Activity Tests" android:icon="@drawable/icon" /> <instrumentation android:targetPackage="com.example.aatg.tc" android:label="Temperature Converter Activity Launch Performance" android:name=".launchperf.TermeratureConverterActivity LaunchPerformance" />
</manifest>
一切准备就绪后,我们就可以开始运行测试了。
首先,安装包含这些更改的 APK。然后,我们有几种运行测试的方法,正如我们在前面的章节中回顾的那样。在这种情况下,我们使用命令行,因为它是最容易获取所有细节的方法。替换适用于你情况的序列号:
$ adb -s emulator-5554 shell am instrument -w com.example.aatg.tc.test/.launchperf.TermeratureConverterActivityLaunchPerformance
我们在标准输出中收到了这个测试的结果集:
INSTRUMENTATION_RESULT: other_pss=13430
INSTRUMENTATION_RESULT: java_allocated=2565
INSTRUMENTATION_RESULT: global_freed_size=16424
INSTRUMENTATION_RESULT: native_private_dirty=504
INSTRUMENTATION_RESULT: native_free=6
INSTRUMENTATION_RESULT: global_alloc_count=810
INSTRUMENTATION_RESULT: other_private_dirty=12436
INSTRUMENTATION_RESULT: global_freed_count=328
INSTRUMENTATION_RESULT: sent_transactions=-1
INSTRUMENTATION_RESULT: java_free=2814
INSTRUMENTATION_RESULT: received_transactions=-1
INSTRUMENTATION_RESULT: pre_sent_transactions=-1
INSTRUMENTATION_RESULT: other_shared_dirty=5268
INSTRUMENTATION_RESULT: pre_received_transactions=-1 INSTRUMENTATION_RESULT: execution_time=4563
INSTRUMENTATION_RESULT: native_size=11020
INSTRUMENTATION_RESULT: native_shared_dirty=1296 INSTRUMENTATION_RESULT: cpu_time=1761
INSTRUMENTATION_RESULT: java_private_dirty=52
INSTRUMENTATION_RESULT: native_allocated=11013
INSTRUMENTATION_RESULT: gc_invocation_count=0
INSTRUMENTATION_RESULT: java_shared_dirty=1860
INSTRUMENTATION_RESULT: global_alloc_size=44862
INSTRUMENTATION_RESULT: java_pss=1203
INSTRUMENTATION_RESULT: java_size=5379
INSTRUMENTATION_RESULT: native_pss=660
INSTRUMENTATION_CODE: -1
我们突出显示了两个我们感兴趣的值:execution_time 和 cpu_time。它们分别代表总执行时间和使用的 CPU 时间。
在模拟器上运行此测试会增加误测量的可能性,因为主机计算机正在运行其他进程,这些进程也会占用 CPU,而模拟器并不一定代表真实硬件的性能。
由于这个原因,我们正在考虑这两项措施。execution_time 给我们提供了实际时间,而 cpu_time 提供了用于计算我们代码的总 CPU 时间。
不言而喻,在测量随时间变化的事物时,你应该使用测量策略并多次运行测试以获得不同的统计值,例如平均值或标准差。
很遗憾,当前 Android ADT 的实现不允许使用不扩展 android.test.InstrumentationTestRunner 的仪器化测试,尽管 .launchperf.TemperatureConverterActivityLaunchPerformance 扩展了 LaunchPerformaceBase,而 LaunchPerformaceBase 又扩展了 Instrumentation。
这张截图显示了在 Eclipse 运行配置中尝试定义此仪器化测试时出现的错误:

使用 Traceview 和 dmtracedump 平台工具
Android SDK 包含了各种工具,其中有两个是专门用于分析性能问题和可能确定应用优化的目标的。
这些工具与其他替代方案相比具有优势:通常对于更简单的任务不需要修改源代码。然而,对于更复杂的情况,需要添加一些内容,但它们非常简单,就像我们很快就会看到的那样。
如果您不需要关于开始和停止跟踪的精确度,您可以从命令行或 Eclipse 驱动它。例如,要从命令行开始跟踪,您可以使用以下命令。请记住,用您的情况中适用的序列号替换:
$ adb -s emulator-5554 am start -n com.example.aatg.tc/.TemperatureConverterActivity
$ adb -s emulator-5554 shell am profile com.example.aatg.tc start /mnt/sdcard/tc.trace
做些事情,例如在摄氏度字段中输入温度以强制转换。
$ adb -s emulator-5554 shell am profile com.example.aatg.tc stop
$ adb -s emulator-5554 pull /mnt/sdcard/tc.trace /tmp/tc.trace
1132 KB/s (2851698 bytes in 2.459s)
$ traceview /tmp/tc.trace
否则,如果您需要更多关于分析开始时间的精确度,您可以添加此段代码而不是之前的那个:
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
if (!dest.hasWindowFocus() || dest.hasFocus() || s == null ) {
return;
}
final String ss = s.toString();
if ( "".equals(ss) ) {
dest.setText("");
return;
} if ( BENCHMARK_TEMPERATURE_CONVERSION ) {
Debug.startMethodTracing();
}
try {
final double result = (Double) convert.invoke( TemperatureConverter.class, Double.parseDouble(ss));
dest.setNumber(result);
dest.setSelection(dest.getText().toString().length());
} catch (NumberFormatException e) {
// WARNING
// this is generated while a number is entered,
//for example just a '-'
// so we don't want to show the error
} catch (Exception e) {
dest.setError(e.getCause().getLocalizedMessage());
} if ( BENCHMARK_TEMPERATURE_CONVERSION ) {
Debug.stopMethodTracing();
}
}
这将创建一个跟踪文件,通过调用Debug.startMethodTracing()在 Sdcard 中使用默认名称dmtrace.trace,该命令以默认日志名称和缓冲区大小开始方法跟踪。当我们完成时,我们调用Debug.stopMethodTracing()来停止分析。
注意
要能够写入 Sdcard,应用程序需要在清单中添加android.permission.WRITE_EXTERNAL_STORAGE权限。
对于 Android 2.2 之前的虚拟机,即使从 Eclipse 执行此操作也需要权限,因为文件也会生成。从 Android 2.2 开始,流通过 JDWP 连接发送,不再需要权限。
您需要运行应用程序以获取跟踪文件。此文件需要被拉到开发计算机上,以便使用traceview进行进一步分析:
$ adb -s emulator-5554 pull /mnt/sdcard/dmtrace.trace /tmp/dmtrace.trace
375 KB/s (50664 bytes in 0.131s)
$ traceview /tmp/dmtrace.trace
运行此命令后,traceview的窗口出现,显示收集到的所有信息:

注意
记住,启用分析确实会减慢应用程序的执行速度,因此应该根据其相对权重而不是绝对值来解释测量结果。
窗口的顶部显示时间线面板和每个方法的彩色区域。时间向右增加。在彩色行下方还有小线显示所选方法的全部调用范围。
我们分析了应用程序的小部分,所以只有主线程在运行。在其他情况下,如果在分析期间有其他线程在运行,此信息也将显示。
底部部分显示配置面板和每个执行的方法及其父子关系。我们将调用方法称为父方法,被调用方法称为子方法。当点击时,方法展开以显示其父方法和子方法。父方法以紫色背景显示,子方法以黄色背景显示。
选择的用于方法的颜色,以轮询方式完成,显示在方法名称之前。
最后,在底部有一个查找字段,我们可以输入一个过滤器以减少显示的信息量。例如,如果我们只想显示com.example.aatg.tc包中的方法,我们应该输入com/example/aatg/tc。
点击列将根据该列按升序或降序设置列表的顺序。
本表描述了可用的列及其描述:
| 列 | 描述 |
|---|---|
| name | 方法的名称,包括其包名,形式如上所述,使用"/"(斜杠)作为分隔符。同时显示参数和返回类型。 |
| Incl% | 方法使用的包含时间,占总时间的百分比。即包括其所有子项。 |
| Inclusive | 此方法使用的包含时间,以毫秒为单位。即包括此方法和其所有子项。 |
| Excl% | 方法使用的排除时间,占总时间的百分比。即不包括其所有子项。 |
| Exclusive | 排除时间,以毫秒为单位,这是在此方法中花费的总时间。即不包括其所有子项。 |
| Calls+RecurCalls/Total | 此列显示此方法的调用次数和递归调用次数。这是与调用此方法的总调用次数相比的调用次数。 |
| Time/Call | 每次调用的耗时,以毫秒为单位。即包含时间/调用次数。 |
微基准测试
基准测试是指运行计算机程序或操作,以便以产生定量结果的方式比较操作,通常是通过对这些操作运行一系列测试和试验。
基准测试可以分为两大类:
-
宏基准测试
-
微基准测试
宏基准测试作为一种比较不同平台在特定领域(如处理器速度、每单位时间的浮点运算次数、图形和 3D 性能等)的方法存在。它们通常用于硬件组件,但也可以用于测试软件特定区域,例如编译器优化或算法。
与这些传统的宏基准测试相反,微基准测试试图测量非常小的一段代码的性能,通常是单个方法。获得的结果用于在提供相同功能的不同实现之间进行选择,决定优化路径。
这里的风险在于微基准测试可能与你认为测量的内容不同。这主要是在使用 Android 从 2.2 Froyo 版本开始的 JIT 编译器的情况下需要考虑的事情。JIT 编译器可能会以不同于应用程序中相同代码的方式编译和优化你的微基准测试。因此,在做出决定时要谨慎。
这与上一节中介绍的分析策略不同,因为这种方法并不考虑整个应用程序,而是一次考虑一个方法或算法。
Caliper 微基准测试
Caliper 是 Google 的开源框架,用于编写、运行和查看微基准测试的结果。在其网站上有很多示例和教程,网址为 code.google.com/p/caliper/。
这还是一个正在进行中的项目,但在许多情况下仍然很有用。我们在这里探索其基本用法,并在下一章中介绍更多与 Android 相关的用法。
其核心思想是基准测试方法,主要是为了了解它们的效率;我们可能会决定这是我们的优化目标,也许是在分析了通过 traveview 做的剖析提供的结果之后。
Caliper 基准测试通常扩展 com.google.caliper.SimpleBenchmark,该类实现了 Benchmark 接口。基准测试的结构与 JUnit 3 测试类似,并保持了相同的结构,唯一的区别是这里的基准测试以 time 前缀开始,而不是 test。每个基准测试都接受一个 int 参数,通常命名为 reps,表示要基准测试的方法中代码的重复次数,该方法被一个计数重复的循环所包围。
setUp() 方法也存在。
我们需要在我们的计算机上安装 caliper。在撰写本文时,caliper 并非以二进制形式分发,而是以源代码形式提供,您可以下载并自行构建。请遵循其网站上的说明,基本上就是获取源代码并自行构建。
用非常简单的方式来说,您可以使用以下命令行来完成它。您需要安装 Subversion 和 Ant 才能完成此操作:
$ svn checkout http://caliper.googlecode.com/svn/trunk/ caliper-read-only
$ cd caliper-read-only
$ ant
calliper-0.0.jar 和 allocation.jar 将会在 build/caliper-0.0/lib 子目录下找到。
创建 TemperatureConverterBenchmark 项目
让我们从在 Eclipse 中创建一个新的 Java 项目开始。是的,这次不是 Android 项目,而是 Java。
为了保持一致性,请使用包 com.example.aatg.tc.benchmark 作为主包。
将 caliper 库和现有的 TemperatureConverter 项目添加到项目的属性中的 Java Build Path。
然后创建包含我们的基准测试的 TemperatureConverterBenchmark 类:
package com.example.aatg.tc.benchmark;
import java.util.Random;
import com.example.aatg.tc.TemperatureConverter;
import com.google.caliper.Param;
import com.google.caliper.SimpleBenchmark;
/**
* Caliper Benchmark.<br>
* To run the benchmarks in this class:<br>
* {@code $ CLASSPATH=... caliper com.example.aatg.tc. * benchmark.TemperatureConverterBenchmark. * CelsiusToFahrenheitBenchmark} [-Dsize=n]
*
* @author diego
*
*/
public class TemperatureConverterBenchmark {
public static class CelsiusToFahrenheitBenchmark extends SimpleBenchmark {
private static final double T = 10; // some temp
@Param
int size;
private double[] temps;
@Override
protected void setUp() throws Exception {
super.setUp();
temps = new double[size];
Random r = new Random(System.currentTimeMillis());
for (int i=0; i < size; i++) {
temps[i] = T * r.nextGaussian();
}
}
public final void timeCelsiusToFahrenheit(int reps) {
for (int i=0; i < reps; i++) {
for (double t: temps) {
TemperatureConverter.celsiusToFahrenheit(t);
}
}
}
}
public static void main(String[] args) {
System.out.println("This is a caliper benchmark.");
}
}
我们有一个 setUp() 方法,类似于 JUnit 测试,在基准测试运行之前执行。此方法初始化用于转换基准测试的随机温度数组。此数组的大小作为参数传递给 caliper,并在此处用 @Param 注解标记。Caliper 将自动提供此参数的值。
我们使用高斯分布来模拟伪随机温度,因为这可能是现实世界的一个很好的模型。
然后是基准测试本身。正如我们之前提到的,它应该以时间前缀开始,例如在这个例子中是 timeCelsiusToFahrenheit()。在这个方法中,我们循环进行重复操作,并调用转换 TemperatureConverter.celsiusToFahrenheit(),这是我们希望基准测试的方法。
运行 caliper
要运行 caliper,我们使用一个基于分发中提供的脚本的脚本。请确保将其放置在包含在PATH中的目录中,或者使用正确的路径来调用它:
#!/bin/bash
VERSION=0.0
CALIPER_DIR=/opt/caliper-$VERSION
export PATH=$PATH:$JAVA_HOME/bin
exec java -cp ${CALIPER_DIR}/lib/caliper-${VERSION}.jar:$CLASSPATH com.google.caliper.Runner "$@"
根据您的需求进行适配。在运行之前,请记住我们仍然需要设置我们的CLASSPATH,以便 caliper 能够找到TemperatureConverter以及基准测试本身。例如:
$ export CLASSPATH=$CLASSPATH:~/workspace/TemperatureConverter/bin:~/workspace/TemperatureConverterBenchmark/bin
之后我们可以像这样运行 caliper:
$ caliper com.example.aatg.tc.benchmark.TemperatureConverterBenchmark.CelsiusToFahrenheitBenchmark -Dsize=1
这将运行基准测试,如果一切顺利,我们将看到结果:
0% Scenario{vm=java, benchmark=CelsiusToFahrenheit, size=1} 8.95ns; σ=0.11ns @ 10 trials
.caliperrc found, reading properties...
ns 对数运行时间
9 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
vm: java
基准:摄氏度转华氏度
size: 1
或者我们可以为不同的温度重复基准测试,以找出这些值本身是否会影响转换的性能。在这种情况下,我们运行:
$ caliper com.example.aatg.tc.benchmark.TemperatureConverterBenchmark.CelsiusToFahrenheitBenchmark -Dsize=1,10,100
在这里,我们为温度数组添加了不同的尺寸,得到的结果如下:
0% Scenario{vm=java, trial=0, benchmark=CelsiusToFahrenheit, size=1} 3.47 ns; σ=0.19 ns @ 10 trials
33% Scenario{vm=java, trial=0, benchmark=CelsiusToFahrenheit, size=10} 11.67 ns; σ=1.20 ns @ 10 trials
67% Scenario{vm=java, trial=0, benchmark=CelsiusToFahrenheit, size=100} 63.06 ns; σ=3.83 ns @ 10 trials
67% Scenario{vm=java, trial=0, benchmark=CelsiusToFahrenheit, size=100} 63.06 ns; σ=3.83 ns @ 10 trials
size ns 线性运行时间
1 3.47 =
10 11.67 =====
100 63.06 ==============================
vm: java
trial: 0
基准:摄氏度转华氏度
为了帮助可视化这些结果,有一个托管在 Google AppEngine 上的服务(microbenchmarks.appspot.com),它接受您的结果数据,并以更好的方式让您可视化它们。要访问此服务,您应该获得一个提供您的 Google 登录的 API 密钥。一旦获得此密钥,它就被放置在您主目录中的.caliperrc文件中,下次运行基准测试时,结果将被上传。
在粘贴获得的 API 密钥后,.caliperrc将看起来像以下片段:
# Caliper API key for myuser@gmail.com
postUrl: http://microbenchmarks.appspot.com:80/run/
apiKey: 012345678901234567890123456789012
现在再次使用之前的相同命令行运行基准测试:
$ caliper com.example.aatg.tc.benchmark.TemperatureConverterBenchmark.CelsiusToFahrenheitBenchmark -Dsize=1,10,100
除了文本输出外,您还将收到访问结果的说明。您可以在以下位置在线查看当前和以前的基准测试结果:
备注
在之前的 URL 中,将user@gmail.com替换为您用于生成 API 密钥的真实 Google 登录用户名。

摘要
在本章中,我们分析了可用的替代方案,以测试我们应用程序的性能指标,并对我们的代码进行基准测试和性能分析。
虽然在撰写本文时,Android SDK 应提供的某些选项尚未完成,并且由于 SDK 中隐藏了一些代码,无法实现 Android PerformanceTestCases,但我们访问并分析了其他一些有效的替代方案。
在这些替代方案中,我们发现我们可以使用简单的日志语句来扩展更复杂的代码,以增强 Instrumentation 功能。
随后,我们分析了性能分析替代方案,并描述和举例说明了traceview和dmtracedump的使用。
最后,我们发现了一个名为 caliper 的微基准测试工具,它对 Android 有原生支持。然而,我们只介绍了其最基本的使用方法,并将更具体的 Android 和 Dalvik VM 使用方法推迟到下一章。
在下一章中,我们将从源代码构建 Android,以获得 EMMA 增强的构建,并将对我们的代码执行覆盖率报告。我们还将在本章结束时介绍替代策略和工具。
第十章。替代测试策略
到目前为止,我们已经分析了在项目中实现测试的最常见和最易访问的策略。然而,在我们的拼图中还有一些缺失的部分,并且随着当前版本的 Android SDK(截至本文撰写时为 Android 2.3 姜饼),这些功能尚未实现。尽管如此,并非一切皆无。Android 最大的和最强大的优势之一是其开源性质,而我们将要利用的功能正是依赖于它,因为我们将会使用完整的源代码来引入一些我们计划提供的更改所需的变化。
从源代码构建 Android 并非易事。这需要极多的时间,尤其是在你熟悉整个 Android 环境的初期,同时还需要大量的磁盘空间和计算能力。为了说明这一点,对单个目标的简单构建就需要近 10GB 的磁盘空间,并在 4 核机器上花费近一个小时来构建。我并不是想吓唬你,而是警告你,同时请求你有一点耐心。
他们说,巨大的牺牲伴随着巨大的回报,这似乎又是一个遵循这一规则的案例。
在本章中,我们将涵盖:
-
从源代码构建 Android
-
使用 EMMA 进行代码覆盖率
-
将代码覆盖率添加到我们的温度转换器项目中
-
介绍 Robotium
-
在宿主机的 JVM 上进行测试
-
介绍 Robolectric
从源代码构建 Android
也许 Android 的致命弱点就是缺乏文档,以及你需要访问的多个地方才能找到你试图寻找的完整版本,或者更糟糕的是,在许多情况下,官方文档是错误的,或者没有更新以匹配当前版本。一个例子是,在撰写本文时,关于从源代码构建 Android 的要求文档(可在source.android.com/source/download.html找到)仍然声称 Java 6 不受支持,可以使用 Ubuntu 8.10(intrepid)32 位,这是完全错误的。有趣的是,Java 6 和至少 Ubuntu 10.04(lucid)64 位是必需的。从 Android 2.3(姜饼)开始,在 32 位机器上构建不再受支持。但这就足够了,我要把这些牢骚留到我的个人博客上,否则如果文档是完整的,像这样的书就不需要了,我可能正在写一本关于 Windows Phone 7 的书...
开个玩笑,我不认为这会在近期发生。
代码覆盖率
我们从源代码构建 Android 的一个目标是在 EMMA 的帮助下实现代码覆盖率(emma.sourceforge.net/)。
代码覆盖率是软件测试中用来描述测试套件实际测试的源代码量以及达到某些标准的程度的度量。由于代码覆盖率直接检查代码,因此它是一种白盒测试。
在为 Java 提供代码覆盖率分析的几个工具中,我们使用 EMMA,这是一个由 Android 项目支持的、用于测量和报告 Java 代码覆盖率的开源工具包,并且已经存在用于启动您自己的项目的基础设施,因此最小化了实现它的努力。EMMA 出现填补了庞大的开源生态系统中的一个空白,在该空白中不存在具有兼容许可证的覆盖率工具。EMMA 基于 IBM 的 Common Public License v1.0,因此对开源和商业开发都是免费的。
EMMA 通过追求独特的功能组合来区别于其他工具:支持大规模企业级软件开发,同时保持单个开发者的工作快速和迭代。这对于 Android 这样规模的项目至关重要,而 EMMA 在提供代码覆盖率方面表现出色。
EMMA 功能
Android 2.3 包含 EMMA v2.0,版本 5312。其最独特的功能集合,根据其文档的描述,可以在其网站上找到,如下:
-
EMMA 可以在离线(在它们被加载之前)或实时(使用一个分析应用程序类加载器)对类进行代码覆盖率分析。
-
支持的覆盖率类型:类、方法、行、基本块。EMMA 可以检测到单行源代码只部分被覆盖的情况。
-
覆盖率统计数据在方法、类、包和“所有类”级别进行汇总。
-
输出报告类型:纯文本、HTML、XML。所有报告类型都支持钻取,达到用户控制的详细程度。HTML 报告支持源代码链接。
-
输出报告可以突出显示低于用户提供的阈值覆盖率的项。
-
在不同的代码覆盖率分析或测试运行中获得的数据可以合并在一起。
-
EMMA 不需要访问源代码,并且当输入类中可用的调试信息减少时,它能够优雅地降级。
-
EMMA 可以对单个
.class文件或整个.jar文件(如果需要,就地)进行代码覆盖率分析。也支持高效的覆盖率子集过滤。 -
Makefile 和 ANT 构建集成得到同等支持。
-
EMMA 非常快:添加的代码覆盖率分析器的运行时开销很小(5 到 20%),而字节码分析器本身也非常快(主要受限于文件 I/O 速度)。内存开销是每个 Java 类几百字节。
-
EMMA 是 100% 纯 Java,没有外部库依赖,并且可以在任何 Java 2 JVM(甚至 1.2.x)上运行。
Android 对 EMMA 项目进行了一些小的修改,以使其完全适应并支持代码覆盖率:
-
将
core/res/emma_default.properties中的coverage.out.file位置更改为/data/coverage.ec -
从
core/java14/com/vladium/util/IJREVersion.java中移除对sun.misc.*的引用 -
从
core/java13/com/vladium/util/exit/ExitHookManager.java中移除对sun.misc.*和SunJREExitHookManager类的引用 -
将
java.security.cert.Certificate强制转换为core/java12/com/vladium/emma/rt/InstrClassLoader.java中的cast以修复编译器错误 -
将
out/core/res/com/vladium/emma/rt/RTExitHook.closure(来自 Emma Ant 构建)移动到pregenerated/目录中,这样它就不需要在 Android 的基于 make 的构建中生成,但也不会破坏 Emma 的构建
系统要求
Gingerbread 版本的 Android 构建需要一个 64 位构建环境以及一些其他工具:
必需的软件包:
-
Git、JDK、flex 以及其他开发包
-
Java 6
-
32 位交叉构建环境中的片段
-
X11 开发
如果您正在运行推荐的 Ubuntu 10.04 LTS 64 位系统,以下是指令:
$ sudo apt-get install git-core gnupg flex bison gperf libsdl-dev \
libesd0-dev libwxgtk2.6-dev build-essential zip curl libncurses5-dev \
zlib1g-dev $ sudo apt-get install gcc-multilib g++-multilib libc6-dev-i386 \
lib32ncurses5-dev ia32-libs x11proto-core-dev libx11-dev \
lib32readline5-dev lib32z-dev
设置系统默认使用正确的 java 版本:
$ sudo update-java-alternatives -s java-6-sun
在任何情况下,请访问 AOSP 网站 (source.android.com/source/download.html) 以获取更新的说明。
下载 Android 源代码
Android 项目是一组相对独立的项目的集合,它们被置于 Android 的伞下。所有这些项目都使用 Git 作为版本控制系统。您可以通过访问 Android 项目的 Gitweb 界面来了解这一点,网址为 android.git.kernel.org/。
如您所见,列出了数十个项目,您需要所有这些来构建整个平台。为了简化同时处理大量 Git 项目的流程,Google 创建了 repo,这是一个基于 Git 构建的工具,旨在帮助管理多个 Git 仓库,上传到版本控制系统,并自动化 Android 开发工作流程的一部分。
Repo 是一个补充工具,它并不取代 Git,但使得在 Android 的背景下使用 Git 更加容易。repo 命令是一个 Python 可执行文件,被封装在一个 shell 脚本中,并且可以被放置在您的路径中的任何位置。
在 Android 项目的范围内,有关 Git 和 Repo 的详细信息可以从它们的信息页面 source.android.com/source/git-repo.html 获取。
安装 repo
如我们之前提到的,repo 是我们进入 Android 源代码世界的钥匙,因此第一步是安装它。
按照以下命令操作:
$ curl http://android.git.kernel.org/repo > ~/bin/repo
$ chmod a+x ~/bin/repo
这将创建初始的 repo 脚本,它将初始化完整的仓库,并将包括 repo.git 项目,因此 repo 将自动维护。每次您与仓库同步时,如果需要,repo 自身的更改将被传播。这是一个非常聪明的工具使用方式。
创建工作副本
我们可以在计算机的任何位置创建仓库的工作副本。只需记住,至少需要 10GB 的可用空间,如果您为不同的目标构建,有时可能需要更多空间。
假设我们决定在 ~/android/android-2.3 中创建工作副本,然后使用以下命令:
$ mkdir ~/android/android-2.3
$ cd ~/android/android-2.3
$ repo init -u git://android.git.kernel.org/platform/manifest.git
这三个简单的步骤已经创建了我们准备同步的工作副本。请记住,这是一个非常大的下载,根据您的网络连接速度和服务器负载,可能需要一些时间。因此,在主要版本推送到服务器后等待几天是非常明智的。
当您准备好同步时,只需在您的工作副本中调用此命令:
$ repo sync
当您运行 repo sync 时,会发生以下情况:
-
如果项目从未同步过,则
repo sync等同于git clone。远程仓库中的所有分支都将复制到本地项目目录。 -
如果项目已经同步过一次,则
repo sync等同于:-
git remote update -
git rebase origin/branch -
其中分支是本地项目目录中当前签出的分支。如果本地分支没有跟踪远程仓库中的分支,则项目不会发生同步。
-
-
如果
git rebase操作导致合并冲突,您需要使用正常的 Git 命令(例如,git rebase --continue)来解决冲突。
完成后,完整的 Android 源代码已下载到您的工作副本。我们没有指定任何特定的分支,所以我们只下载了最新的 Android 开源项目 (AOSP) 主分支。
构建步骤
我们已经准备好开始支持代码覆盖率分析的建设。
要实现这一点,我们需要遵循设置环境和选择您的组合的步骤:
~/android/android-2.3$ source build/envsetup.sh
包括 device/htc/passion/vendorsetup.sh
包括 device/samsung/crespo/vendorsetup.sh
~/android/android-2.3$ lunch
您正在 Linux 上构建
午餐菜单...选择一个组合:
1. full-eng
2. full_x86-eng
3. 模拟器
4. full_passion-userdebug
5. full_crespo-userdebug
您想选择哪个? [full-eng]
在此情况下选择 full-eng。
============================================
PLATFORM_VERSION_CODENAME=AOSP
PLATFORM_VERSION=AOSP
TARGET_PRODUCT=full
TARGET_BUILD_VARIANT=eng
TARGET_SIMULATOR=false
TARGET_BUILD_TYPE=release
TARGET_BUILD_APPS=
TARGET_ARCH=arm
TARGET_ARCH_VARIANT=armv5te
HOST_ARCH=x86
HOST_OS=linux
HOST_BUILD_TYPE=release
BUILD_ID=OPENMASTER
============================================
在这种情况下还需要进行一个步骤。因为我们想启用 EMMA 代码覆盖率,所以需要在环境中设置此选项:
~/android/android-2.3$ export EMMA_INSTRUMENT=true
准备好,出发:
~/android/android-2.3$ make -j4
注意
make 的 -j 或 -jobs 选项允许您指定同时运行的作业(命令)数量。这在多处理器或多核机器中加快长时间构建过程非常有用。如果没有给 -j 选项提供参数,则 make 不会限制可以同时运行的作业数量。
经过一段时间和大量消息后,你的构建将可用。如果一切顺利,你将在结束时看到类似以下的消息:
目标系统文件系统镜像:out/target/product/generic/obj/PACKAGING/systemimage_intermediates/system.img
安装系统文件系统镜像:out/target/product/generic/system.img
已安装文件列表:out/target/product/generic/installed-files.txt
这是因为最后一步是创建系统镜像和已安装文件列表。
如果构建失败,请尝试以下建议中的某些方法来修复它,或者更多地在 AOSP 网站上了解信息(source.android.com/source/building.html)。如果有一些问题,事情并不那么顺利,这里有一份你可以遵循的提示列表来恢复情况。
提示
从损坏的构建中恢复的提示
清理,使用make clean,然后再次构建。
减少作业数量(使用make -j或make jobs)通常也很有帮助。
有时候,仅仅在构建失败后再次调用 make,就可以使构建成功。是的,我知道这听起来很荒谬,但当你尝试了所有其他方法时,它是有帮助的。
我们现在有一个经过测试的构建,这将使我们能够为我们项目的测试获得代码覆盖率分析。所以这是我们的下一步。
温度转换器代码覆盖率
我们从源代码构建 Android,主要是为了能够为我们项目的代码覆盖率分析报告。主要有两个原因:
-
我们需要一个 EMMA 测试的构建,这正是我们在前面的章节中做的。
-
要能够对应用程序进行测试,这个应用程序应该作为主构建树的一部分进行构建,这正是我们现在要做的;
我们的应用程序和测试在主 Android 树中的可能位置是development/samples,因此我们将使用它。如果你决定使用不同的位置,这里提供的文件和命令可能需要进行一些小的调整。
我们已经在我们的文件系统中某个地方有了我们的TemperatureConverter项目和它的测试TemperatureConverterTests,如果你之前遵循了示例,它们可能已经被检查到你所选择的版本控制系统,所以这里的选项是在这个位置再次检出项目或者创建一个符号链接。让我们选择后者,为了简化这个示例:
~/android/android-2.3/development/samples$ ln -s ~/workspace/TemperatureConverter .
~/android/android-2.3/development/samples$ ln -s ~/workspace/TemperatureConverterTest .
接下来,我们需要添加 makefiles。我们最初从 Eclipse 构建我们的项目,后来添加了ant支持。现在我们正在添加对第三个构建系统make的支持。
构建的 Android 是基于make的,我们应该遵循其约定和风格,以便能够将我们的应用程序及其测试作为主构建的一部分进行构建。
在TemperatureConverter项目内部创建以下Android.mk:
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE_TAGS := samples
# Only compile source java files in this apk.
LOCAL_SRC_FILES := $(call all-java-files-under, src)
LOCAL_PACKAGE_NAME := TemperatureConverter
LOCAL_SDK_VERSION := current
include $(BUILD_PACKAGE)
如果执行,此 makefile 将作为主构建的一部分包含。
要单独构建它,我们可以使用在设置环境时定义的辅助函数。这个函数是mm,定义为:
mm ()
{
if [ -f build/core/envsetup.mk -a -f Makefile ]; then
make $@;
else
T=$(gettop);
local M=$(findmakefile);
local M=`echo $M|sed 's:'$T'/::'`;
if [ ! "$T" ]; then
echo "Couldn't locate the top of the tree. Try setting TOP.";
else
if [ ! "$M" ]; then
echo "Couldn't locate a makefile from the current directory.";
else
ONE_SHOT_MAKEFILE=$M make -C $T all_modules $@;
fi;
fi;
fi
}
用于定位和包含所需组件的样板代码由这个函数提供。
要构建应用程序,只需在当前工作目录是我们想要编译的项目时调用它即可。
~/android/android-2.3/development/samples/TemperatureConverter$ EMMA_INSTRUMENT=true mm
由于我们在环境中通过设置EMMA_INSTRUMENT=true启用了 EMMA,因此我们应该看到以下信息:
EMMA:处理仪器化路径 ...
EMMA:处理仪器化路径耗时 149 毫秒
EMMA:[仪器化 14 个类,复制 4 个资源]
EMMA:元数据已合并到[/home/diego/android/android-2.3/out/target/common/obj/APPS/TemperatureConverter_intermediates/coverage.em] {耗时 16 毫秒}
这表明我们的构建正在被仪器化。
我们应该以类似的方式继续构建和仪器化我们的测试。
在TemperatureConverterTest项目中创建相应的 makefile:Android.mk,这次包含以下信息,与主项目略有不同:
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
# We only want this apk build for tests.
LOCAL_MODULE_TAGS := tests
LOCAL_JAVA_LIBRARIES := android.test.runner
LOCAL_STATIC_JAVA_LIBRARIES := easymock hamcrest-core \
hamcrest-integration hamcrest-library
# Include all test java files.
LOCAL_SRC_FILES := $(call all-java-files-under, src)
LOCAL_PACKAGE_NAME := TemperatureConverterTest
LOCAL_INSTRUMENTATION_FOR := TemperatureConverter
LOCAL_SDK_VERSION := current
include $(BUILD_PACKAGE)
LOCAL_PREBUILT_STATIC_JAVA_LIBRARIES := \
easymock:libs/easymock-2.5.2.jar \
hamcrest-core:libs/hamcrest-core-1.2-android.jar \
hamcrest-integration:libs/hamcrest-integration-1.2-android.jar \
hamcrest-library:libs/hamcrest-library-1.2-android.jar
include $(BUILD_MULTI_PREBUILT)
这稍微复杂一些,因为测试正在使用我们需要在构建过程中定义的外部库。
再次强调,我们应该使用mm函数来构建它:
~/android/android-2.3/development/samples/TemperatureConverterTest \ $ EMMA_INSTRUMENT=true mm
我们已成功构建了TemperatureConverter应用程序及其测试,现在它已成为 Android 主构建的一部分。此时,我们准备通过执行几个更多步骤来获取代码覆盖率分析报告。
生成代码覆盖率分析报告
到达这一点后,我们已经在输出目录out/target/common/obj/APPS/中构建并仪器化了TemperatureConverter及其测试。
我们需要一个属于我们的仪器构建的模拟器实例。这个模拟器也在out目录中。
在这种情况下,我们将默认系统分区大小扩展到 256MB,并包括一个之前应该创建的 sdcard 镜像。这些元素是必需的,因为在仪器化测试运行期间将收集一些数据,我们需要一些空间来保存它。
~/android/android-2.3$ ./out/host/linux-x86/bin/emulator -sdcard ~/tmp/sdcard.img -partition-size 256
我们现在的目的是将模拟器上运行的镜像与我们的更改同步。
这些步骤通过复制修改后的文件来避免在有更改或更新可用时创建新的镜像。
要能够做到这一点,我们首先需要启用向系统镜像写入:
~/android/android-2.3$ adb remount
当此命令成功完成后,应该给出以下输出:
挂载成功
紧接着是同步更改:
~/android/android-2.3/development/samples/TemperatureConverterTest$ adb sync
显示正在复制到模拟器映像的文件列表。一旦所有内容都更新完毕,我们现在可以使用am instrument命令运行测试,就像我们之前做的那样。正如我们在第二章中提到的,在回顾此命令的可用选项时,-e可以用来设置各种子选项。在这种情况下,我们使用它来启用代码覆盖率收集:
~/android/android-2.3$ adb shell am instrument -e coverage 'true' \ -w com.example.aatg.tc.test/android.test.InstrumentationTestRunner
以下信息验证了我们的测试正在收集覆盖率数据:
EMMA:收集运行时覆盖率数据 ..。
最后一条信息确实告诉我们数据是从哪里收集的:
生成的代码覆盖率数据写入/data/data/com.example.aatg.tc/files/coverage.ec
我们可以在开发计算机上创建一个目录来保存本项目的覆盖率报告。在这个目录中,我们还应该复制离线覆盖率元数据,然后生成报告:
~/android/android-2.3$ mkdir -p out/emma/tc
~/android/android-2.3$ cd out/emma/tc
然后我们从设备复制覆盖率报告:
~/android/android-2.3/out/emma/tc$ adb pull /data/data/com.example.aatg.tc/files/coverage.ec coverage.ec
数据传输时,我们会收到以下统计信息:
200 KB/s(0.110 秒内 22840 字节)
以及离线覆盖率元数据:
~/android/android-2.3/out/emma/tc$ cp ~/android/android-2.3/out/target/common/obj/APPS/TemperatureConverter_intermediates/coverage.em .# not the dot (.) at the end
在我们的工作目录中存在所有这些组件后,指定命令行选项将更容易。如果您愿意,可以使用不同的组织结构,并将文件放在其他地方,甚至在此处创建符号链接。
阅读完所有内容后,我们可以调用emma来生成报告。默认报告显示整体覆盖率摘要,然后按包分拆。在这个例子中,我们使用 HTML 输出,并链接到源代码。
注意
如果TemperatureConverter主项目的源文件夹不是~/workspace/TemperatureConverter/src,请记住调整以下命令,否则命令将失败:~/android/android-2.3/out/emma/tc$ java -cp ~/android/android-2.3/external/emma/lib/emma.jar emma report -r html -in coverage.ec -sp ~/workspace/TemperatureConverter/src -in coverage.em。
我们还将能够看到指示报告创建的消息:
EMMA:处理输入文件 ..。
EMMA:2 个文件在 20 毫秒内读取并合并
EMMA:将[html]报告写入[/home/diego/android/android-2.3/out/emma/tc/coverage/index.html] ..。
EMMA:将[html]报告写入[/home/diego/android/android-2.3/out/emma/tc/coverage/index.html] ..。
这在覆盖率目录内创建了报告文件,因此我们可以通过调用以下命令来打开索引:
~/android/android-2.3/out/emma/tc$ firefox coverage/index.html
然后,显示覆盖率分析报告:

该报告有三个主要部分:
-
总体覆盖率摘要: 这里展示了所有类的摘要。
-
总体统计摘要: 这里展示了覆盖率统计信息,例如有多少个包、类或行。
-
按包分拆的覆盖率: 对于较大的应用程序,这将显示特定包的覆盖率。在这个例子中,它与总数相同,因为只有一个包。
报告中展示的信息以允许自上而下钻取数据的方式呈现覆盖率指标,从所有类开始,一直钻取到单个方法和源代码行(在 HTML 报告中)。
EMMA 中代码覆盖率的基本单位是基本块;所有其他类型的覆盖率都是以某种方式从基本块覆盖率派生出来的。行覆盖率主要用于链接到源代码。
此表描述了 EMMA 覆盖率报告中重要信息的关键部分:
| 标签 | 描述 |
|---|---|
| 名称 | 类或包的名称 |
| 类,% | 总类覆盖的百分比和详细数字。 |
| 方法,% | 总方法覆盖的百分比和详细数字。这是一个基本的 Java 方法,由给定数量的基本块组成。 |
| 块,% | 总块覆盖的百分比和详细数字。基本块被定义为没有跳转或跳转目标的字节码指令序列。一个方法中的基本块数量是该方法复杂度的一个良好度量。 |
| 行,% | 总行覆盖的百分比和详细数字。这基本上用于链接到源代码。 |
当显示的值低于阈值覆盖率指标值时,这些指标在报告中以红色显示。默认情况下,这些值是:
-
对于方法:70%
-
对于块:80%
-
对于行:80%
-
对于类:100%
所有这些值都可以更改,可以通过命令行或配置文件指定参数。请参阅文档以获取详细信息(emma.sourceforge.net/docs.html)。
我们可以从包中钻取到具体的方法,被覆盖的行以绿色显示,未覆盖的行以红色显示,部分覆盖的行以黄色显示。
这是TemperatureConverter类的报告示例:

在此报告中,我们可以看到TemperatureConverter类没有 100%覆盖,但其中的所有基本块都被覆盖了。
你知道为什么吗?
想想看...
是的,因为隐式的默认构造函数尚未经过测试。但是等等;这是一个不应该被实例化的工具类。我们可以看到,这种分析不仅帮助我们测试代码和发现潜在的错误,还可以改进设计。
为了防止TemperatureConverter被实例化,我们需要创建一个私有的默认构造函数:
public TemperatureConverter {
…
private TemperatureConverter() {
}
...
}
一旦我们添加了这个私有构造函数并再次运行测试和覆盖率,现在我们可以看到,尽管类还没有 100%覆盖,因此不是绿色的,但我们可以确保这个构造函数不会被其他任何类调用。
覆盖恢复实例状态
另有一个情况我们将进行分析。在 TemperatureConverterActivity 的报告中,我们可以看到一些代码块仍然没有被覆盖,并且它们是红色的。其中之一是我们之前添加的恢复保存实例的部分支持,尽管这个代码块尚未启用,它只是记录了一条消息,我们应该用测试来覆盖它。
在 TemperatureConverterActivity.java 中提到的代码是:
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
if ( savedInstanceState != null ) { Log.d(TAG, "Should restore state from " + savedInstanceState);
}
…
要测试此代码块,我们必须控制 onCreate() 方法的调用,并注入一个模拟的 Bundle 来模拟实际的 Android 生命周期。
我们可能会考虑使用之前创建的测试类来添加所需的测试,但如果你记得我们之前的章节,我们提到当我们需要对测试的 Activity 创建有更高程度的控制时,我们应该使用 ActivityUnitTestCase<T> 而不是 ActivityInstrumentationTestCase2<T>,后者也是从 InstrumentationTestCase 派生的(参见 第三章 中 ActivityInstrumentationTestCase2<T> 的 UML 类图,Android SDK 的构建块)
基于 ActivityUnitTestCase<T> 的测试用例允许我们在通过 startActivity(Intent intent, Bundle savedInstanceState, Object lastNonConfigurationInstance) 启动 Activity 的同时,向 onCreate() 注入所需的值。
以下代码片段显示了我们要添加到现有 TemperatureConverterActivityUnitTests 类中的测试用例:
package com.example.aatg.tc.test;
import com.example.aatg.tc.TemperatureConverterActivity;
import com.example.aatg.tc.TemperatureConverterApplication;
import android.app.Instrumentation;
import android.content.Intent;
import android.os.Bundle;
import android.test.ActivityUnitTestCase;
public class TemperatureConverterActivityUnitTests extends
ActivityUnitTestCase<TemperatureConverterActivity> {
public TemperatureConverterActivityUnitTests(String name) {
super(TemperatureConverterActivity.class);
setName(name);
}
protected void setUp() throws Exception {
super.setUp(); mStartIntent = new Intent(Intent.ACTION_MAIN);
mInstrumentation = getInstrumentation();
setApplication(new TemperatureConverterApplication());
}
protected void tearDown() throws Exception {
super.tearDown();
}
// other tests not displayed here … public final void testOnCreateBundle() {
Bundle savedInstanceState = new Bundle();
savedInstanceState.putString("dummy", "dummy");
setApplication(new TemperatureConverterApplication());
Intent intent = new Intent(mInstrumentation.getTargetContext(), TemperatureConverterActivity.class);
startActivity(intent, savedInstanceState, null);
TemperatureConverterActivity activity = getActivity();
assertNotNull(activity);
}
}
我们正在创建一个只包含模拟值的 Bundle,因为在 Activity 中没有期望到任何特殊的东西。此外,我们注入了一个真实的 TemperatureConverterApplication 对象而不是 Application 模拟对象,因为它在 Activity 的 onCreate() 方法中被使用和转换,否则会失败。
在恢复保存状态时没有进行任何特殊操作,因此没有向此类添加额外的测试。对于您的特定应用程序,您可能希望检查某些值是否已正确恢复。
如果我们再次运行测试覆盖率报告,我们会看到现在提到的代码块已被覆盖。
覆盖异常
继续检查覆盖率报告将引导我们发现另一个当前测试未涉及的代码块。所讨论的代码块是以下 try-catch 块中的最后一个 catch 在 TemeratureConverterActivity: 中:
try {
final double temp = Double.parseDouble(str);
final double result = (mOp == OP.C2F) ?
TemperatureConverter.celsiusToFahrenheit(temp) :
TemperatureConverter.fahrenheitToCelsius(temp);
final String resultString = String.format("%.2f", result);
mDest.setNumber(result);
mDest.setSelection(resultString.length());
} catch (NumberFormatException e) {
// WARNING
// this is generated while a number is entered,
// for example just a '-'
// so we don't want to show the error } catch (InvalidTemperatureException e) {
mSource.setError("ERROR: " + e.getLocalizedMessage());
}
我们应该提供一个测试,或者更好的是一个测试对,每个温度单位一个,以验证无效的温度会显示错误。这是 TemperatureConverterActivityTests 中的 Celsius 情况的测试,你可以轻松地将其转换为提供其他情况:
public void testInvalidTemperatureInCelsius() throws Throwable {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
mCelsius.clear();
mCelsius.requestFocus();
}
});
// temp less than ABSOLUTE_ZERO_C
assertNull(mCelsius.getError());
sendKeys("MINUS 3 8 0");
assertNotNull(mCelsius.getError());
}
我们清除并请求测试字段的关注。正如我们之前所做的那样,我们应该通过在 UI 线程上使用 Runnable 来实现这一点,否则我们将收到一个异常。
然后,我们检查没有之前的错误,设置无效的温度,并检索错误消息以验证它不为 null。再次运行端到端过程,我们可以证明现在块已被覆盖,从而实现了预期的全面覆盖。
这是你应该遵循的迭代过程,尽可能多地更改代码以使其变为绿色。理想情况下,这应该是 100%,但有时这是不可实现的,主要是因为一些在测试期间无法到达的块。
绕过访问限制
我们添加的一个块,为了满足我们的需求,TemperatureConverter的私有构造函数现在无法被我们的测试访问,并标记为红色。在这种情况下,我们可以让它保持原样,或者我们可以使用更复杂的解决方案,通过反射绕过访问限制并创建一个测试。尽管这并不是真的建议,因为严格来说,你应该限制测试公共接口,但我们将其包括在内,以说明这种技术。
这是我们要添加到TemperatureConverterTests类中的测试:
public final void testPrivateConstructor() throws
SecurityException, NoSuchMethodException,
IllegalArgumentException, InstantiationException,
IllegalAccessException, InvocationTargetException {
Constructor<TemperatureConverter> ctor =
TemperatureConverter.class.getDeclaredConstructor();
ctor.setAccessible(true);
TemperatureConverter tc = ctor.newInstance((Object[])null);
assertNotNull(tc);
}
此示例使用反射来绕过访问限制并创建一个新的TemperatureConstructor实例,然后验证它是否已成功创建。
如果你对这个技术或 Java 反射不太熟悉,你可以阅读 Oracle 的 Java 教程中的优秀教程(download.oracle.com/javase/tutorial/reflect/)。
覆盖选项菜单
再次查看覆盖率报告,我们还可以识别出一个我们的测试没有覆盖的方法。它是TemperatureConverterActivity.onCreateOptionsMenu(),它在我们特定情况下创建了包含“偏好设置”选项的菜单。它的操作非常简单直接。它创建了一个MenuItem,当点击时,通过相应的 intent 调用TemperatureConverterPreferences活动。这正是我们要测试的。根据我们的经验,如果我们想知道是否从我们的测试活动启动了活动,那么我们需要的是一个ActivityMonitor,因此我们基于这个组件建立测试。
这是我们要添加到TemperatureConverterActivityTests类中的新测试:
public final void testOnCreateOptionsMenu() {
final Instrumentation instrumentation = getInstrumentation();
final ActivityMonitor preferencesMon = instrumentation.addMonitor( "com.example.aatg.tc.TemperatureConverterPreferences", null, false);
assertTrue(instrumentation.invokeMenuActionSync( mActivity, TemperatureConverterActivity. MENU_ID_PREFERENCES, 0));
final Activity preferences = preferencesMon.waitForActivityWithTimeout(3000);
assertNotNull(preferences);
preferences.finish();
}
首先,我们像其他情况一样获取 Instrumentation。然后,我们使用addMonitor()添加一个监控器,这是一个便利包装器,它也会为我们创建ActivityMonitor并返回它,定义要监控的活动类名称,结果为 null,因为我们对此不感兴趣,并且不阻塞活动的启动。如果启动了一个匹配的 Activity,这个监控器将被触发。
接下来,我们调用 ID 为0的菜单选项,正如它在onCreateOptionsMenu()中定义的那样,并传递没有标志(再次为 0)。我们断言调用是成功的,因为在这种情况下invokeMenuActionSync()返回 true。
我们等待Activity启动,验证它实际上已经启动,因为如果waitForActivityWithTimeout()在Activity启动之前超时,则返回 null,最后finishing()``Activity。
这是一个ActivityMonitor使用的良好示例。然而,我们过去调用特定菜单项的方式以及如果我们打算继续测试新的Activity进行实际功能测试时可能面临的限制,使我们相信应该有另一种方式,实际上确实存在!
我们将在下一节中探讨这些方法。
未记录的 Ant 覆盖率目标
如果使用 make 构建不太吸引你,还有另一种选择。Android 工具的最新版本包括一个未记录的选项,它增加了我们之前提到的文档化目标:帮助、清理、编译、调试、发布、安装和卸载。
此目标是coverage,可以在以下示例中像在TemperatureConverterTest项目中一样使用。
注意
要能够成功完成所有子任务,应运行一个合适的模拟器或设备。
$ ant coverage
这将生成以下输出(为了包含在这里,输出的一部分已被裁剪):
Buildfile: <path/to>/TemperatureConverterTest/build.xml
[setup] Android SDK Tools Revision 11
[setup] Project Target: Android 2.3.1
...
-set-coverage-classpath:
-install-instrumented:
...
-package-with-emma:
...
-install-with-emma:
...
coverage:
[echo] Running tests ...
[exec]
[exec] com.example.aatg.tc.test.EditNumberTests:.......
[exec] com.example.aatg.tc.test. TemperatureConverterActivityTests:...............
[exec] com.example.aatg.tc.test. TemperatureConverterActivityUnitTest:...
[exec] com.example.aatg.tc.test. TemperatureConverterApplicationTests:....
[exec] com.example.aatg.tc.test.TemperatureConverterTests:.......
[exec] com.example.aatg.tc.test.robotium. TemperatureConverterActivityTests:..
[exec] Test results for InstrumentationTestRunner=..........................
[exec] Time: 61.931
[exec]
[exec] OK (38 tests)
[exec]
[exec]
[exec] Generated code coverage data to /data/data/com.example.aatg.tc/files/coverage.ec
[echo] Downloading coverage file into project directory...
[exec] 14 KB/s (751 bytes in 0.050s)
[echo] Extracting coverage report...
... [echo] Saving the report file in <path/to>/ TemperatureConverterTest/coverage/coverage.html
BUILD SUCCESSFUL
Total time: 1 minute 31 seconds
这自动化了我们之前描述的几个步骤。然而,它尚未被记录,因此将来可能会被删除或更改。另一方面,当项目复杂或有很多依赖项时,如果 makefile 成功,则此构建目标可能会失败,因此请谨慎使用。
介绍 Robotium
广泛兴起的机器人群体中的一个组成部分是 Robotium (code.google.com/p/robotium/),这是一个测试框架,旨在简化需要最少了解被测试应用程序的测试的编写。Robotium 主要面向编写强大且健壮的自动黑盒测试用例,用于 Android 应用程序。它可以覆盖功能、系统和验收测试场景,甚至可以自动跨越同一应用程序的多个 Android 活动。
Robotium 还可以用于测试我们没有源代码的应用程序,甚至可以用于测试预安装的应用程序。
Robotium 完全支持Activities、Dialogs、Toasts、Menus和Context Menus。
让我们使用 Robotium 为TemperatureConverter创建一些新的测试。为了使我们的测试井然有序,我们在TemperatureConverterTest项目中创建了一个名为com.example.aatg.tc.tests.robotium的新包。在这个包中,我们创建测试用例的类,因为我们最初将测试TemperatureConverterActivity。即使我们还有一个在另一个包中具有相同名称的类,它也扩展了ActivityInstrumentationTestCase2,将其命名为TemperatureConverterActivityTests也是合理的。毕竟,这个类也将包含对这个相同Activity的测试。
下载 Robotium
我们需要下载 robotium-solo JAR 文件及其 Javadoc,以便将它们添加到我们的项目中。请访问 Robotium 下载网站 (code.google.com/p/robotium/downloads/list) 并选择可用的最新版本,在撰写本文时是 robotium-solo-2.1.jar。
配置项目
在我们的 TemperatureConverterTest 项目的属性中,我们需要将此 JAR 添加到 Java Build Path | Libraries。一旦添加,您可以展开此节点,并使用 Javadoc in archive 选项将 Javadoc 位置指向伴随的 JAR 文件。
创建测试用例
从前一章我们知道,如果我们为应该连接到系统基础设施的 Activity 创建测试用例,我们应该基于 ActivityInstrumentationTestCase2,这正是我们要做的。
测试 FahrenheitToCelsiusConversion() 测试
大体上,测试用例的结构与其他基于 Instrumentation 的测试相同。主要区别在于我们需要在测试的 setUp() 中实例化 Robotium 的 Solo,并在 tearDown() 中最终化它:
package com.example.aatg.tc.test.robotium;
import android.test.ActivityInstrumentationTestCase2;
import com.example.aatg.tc.TemperatureConverterActivity; import com.jayway.android.robotium.solo.Solo;
/**
* @author diego
*
*/
public class TemperatureConverterActivityTests extends
ActivityInstrumentationTestCase2<TemperatureConverterActivity> { private Solo mSolo;
private TemperatureConverterActivity mActivity;
/**
* @param name
*/
public TemperatureConverterActivityTests(String name) {
super(TemperatureConverterActivity.class);
setName(name);
}
/* (non-Javadoc)
* @see android.test.ActivityInstrumentationTestCase2#setUp()
*/
protected void setUp() throws Exception {
super.setUp();
mActivity = getActivity(); mSolo = new Solo(getInstrumentation(), mActivity);
}
testFahrenheitToCelsiusConversion() testabout/* (non-Javadoc)
* @see android.test.ActivityInstrumentationTestCase2#tearDown()
*/
protected void tearDown() throws Exception {
try { mSolo.finalize();
}
catch (Throwable ex) {
ex.printStackTrace();
}
mActivity.finish();
super.tearDown();
}
}
要实例化 Solo,我们必须传递对 Instrumentation 和待测试的 Activity 的引用。
另一方面,为了最终化 Solo,我们应该精确地调用 finalize() 方法,然后完成 Activity,并调用 super.tearDown()。
Solo 提供了各种方法来驱动 UI 测试和一些断言。让我们从重新实现 testFahrenheitToCelsiusConversion() 开始,这是我们之前使用传统方法实现的,但这次使用 Solo 功能:
public final void testFahrenheitToCelsiusConversion() {
mSolo.clearEditText(CELSIUS);
mSolo.clearEditText(FAHRENHEIT);
final double f = 32.5d;
mSolo.clickOnEditText(FAHRENHEIT);
mSolo.enterText(FAHRENHEIT, Double.toString(f));
mSolo.clickOnEditText(CELSIUS);
final double expectedC = TemperatureConverter.fahrenheitToCelsius(f);
final double actualC = Double.parseDouble(mSolo.getEditText(CELSIUS). getText().toString());
final double delta = Math.abs(expectedC - actualC);
final String msg = "" + f + "F -> " + expectedC + "C but was " + actualC + "C (delta " + delta + ")";
assertTrue(msg, delta < 0.005);
}
这非常相似,然而你可能注意到的第一个区别是,在这种情况下,我们并没有像在 setUp() 方法中使用 findViewById() 定位 View 那样获取 UI 元素的引用。然而,我们正在使用 Solo 的一个最大的优点,即使用某些标准为我们定位 View。在这种情况下,标准按照它们在屏幕上出现的顺序使用,并且由于它们被计数,因此分配了一个索引。方法 mSolo.clearEditText(int index) 期望一个整数索引,该索引从屏幕上的 0 开始。因此,我们应该将这些常量添加到测试用例中,因为在我们 UI 中,摄氏度字段在顶部,华氏度在下面:
private static final int CELSIUS = 0;
private static final int FAHRENHEIT = 1;
其他方法遵循相同的约定,并在必要时提供这些常量。这个测试与 com.example.aatg.tc.test.TemperatureConverterActivityTest 中的测试非常相似,但你可能已经注意到有一个细微的差别。在这里,我们处于一个更高的层次,不必担心内部或实现细节;例如,在我们之前的测试中,我们调用 mCelsius.requestFocus() 来触发转换机制,但在这里我们只是模拟用户的行为并发出 mSolo.clickOnEditText(CELSIUS)。
由于这个原因,我们也不想使用EditNumber.getNumber()进行类型转换和使用。我们只是获取屏幕上的文本数据,将其转换为Double,然后将其与预期值进行比较。
我们合理地简化了测试,但使用Solo的最大优势还在后面。
再次审视 testOnCreateOptionsMenu()
你可能从我们之前的testOnCreateOptionsMenu()实现中宣布这个功能以来一直在等待这个时刻。这次我们处于一个更高的层次,我们不需要处理实现细节。当我们点击菜单项时,如果启动了一个新的活动,这不是我们的问题;我们只从 UI 的角度处理这种情况。
这是一张显示小数位数偏好设置的截图:

我们的目的是也将小数位数偏好值更改为 5,并验证更改是否确实发生了。
以下代码片段说明了测试的细节:
public final void testOnCreateOptionsMenu() {
final int decimalPlaces = 5;
final String numberRE = "^[0-9]+$";
mSolo.sendKey(Solo.MENU);
mSolo.clickOnText("Preferences");
mSolo.clickOnText("Decimal places");
assertTrue(mSolo.searchText(numberRE));
mSolo.clearEditText(DECIMAL_PLACES);
assertFalse(mSolo.searchText(numberRE));
mSolo.enterText(DECIMAL_PLACES, Integer.toString(decimalPlaces));
mSolo.clickOnButton("OK");
mSolo.goBack();
mSolo.sendKey(Solo.MENU);
mSolo.clickOnText("Preferences");
mSolo.clickOnText("Decimal places");
assertTrue(mSolo.searchText(numberRE));
assertEquals(decimalPlaces, Integer.parseInt( mSolo.getEditText(DECIMAL_PLACES). getText().toString()));
}
你已经能感受到这种差异了吗?这里没有关于如何实现这些功能的血腥细节。我们只测试其功能。
我们首先按下菜单键,点击偏好设置。
哇,我们只需指定菜单项标题就足够了!
新的活动已经开始,但我们不必担心它。我们继续并点击小数位数。
我们验证了一个包含数字的字段出现了,这个数字是此偏好的先前值。你还记得我关于正则表达式说过的话吗:它们总是以某种方式派上用场;这里用来匹配任何十进制整数(任何数字后跟零个或多个数字)。然后我们清除字段并验证它确实被清除了。
我们输入代表我们想要用作偏好的数字的字符串,在这个例子中是 5。点击确定按钮,偏好设置就被保存了。
剩下的工作就是验证它确实发生了。使用相同的程序来获取菜单和字段,最后我们验证实际数字已经存在。
你可能会想知道DECIMAL_PLACES是从哪里来的。我们之前为屏幕上的字段定义了CELSIUS和FAHRENHEIT索引常量,这是同样的情况,因为这将是我们类中应该定义的第三个EditText:
private static final int DECIMAL_PLACES = 2;
根据你的偏好,可以从 Eclipse 或命令行运行测试。
我希望你和我的感受一样,喜欢这种简单性,并且你的大脑现在充满了实现你自己的测试的想法。
在主机 JVM 上测试
我们把这个主题留到了本章的末尾,因为它似乎是这个 Android 平台的圣杯。
你知道 Android 是基于名为 Dalvik 的虚拟机,这个名字来源于冰岛的一个村庄,它针对具有有限能力如内存和处理器速度受限的移动资源进行了优化。这当然与我们的开发主机计算机非常不同的环境,后者可能拥有大量的内存和处理器速度来享受。
通常,我们在模拟器或设备上运行我们的应用程序和测试。这些目标具有较慢的真实或模拟 CPU,因此运行我们的测试是一个耗时活动,尤其是在我们的项目开始增长时,并且应用测试驱动开发技术迫使我们运行数百个测试来验证我们引入的每个更改。
注意
值得注意的是,这种技术只能在开发过程中作为权宜之计来加快速度,它永远不应该取代在真实平台上的最终测试,因为 Dalvik 和 JavaSE 运行时之间的不兼容性可能会影响测试的准确性。
之后,我们应该找到一个方法,允许我们在模拟器或设备上拦截标准的 编译-dexing-运行 序列,并能够直接在我们的主机计算机上运行。
创建 TemperatureConverterJVMTest 项目
让我们把这里提出的想法付诸实践。这次我们在 Eclipse 中创建一个 Java 项目,而不是我们之前创建的 Android 项目。
这是完成此操作的步骤:
-
首先,我们创建项目并选择 JavaSE-1.6 作为执行环境:
![创建 TemperatureConverterJVMTest 项目]()
-
按下 Next > 我们可以为此项目选择 Java 设置,由于我们的意图是为
TemperatureConverter项目创建测试,因此我们应该将其添加为 构建路径上的必需项目:![创建 TemperatureConverterJVMTest 项目]()
-
然后,我们在项目中创建一个新的包来保存我们的测试,命名为
com.example.aatg.tc.test。在这个包中,我们创建一个新的 JUnit 测试用例,命名为TemperatureConverterTests,使用 JUnit 版本 4,而不是标准 Android 测试用例中使用的支持的 JUnit 版本 3。选择 TemperatureConverter 作为 测试的类:![创建 TemperatureConverterJVMTest 项目]()
-
按下 Next > 这次我们可以选择要测试的方法,方法占位符将自动生成:

现在我们已经完成了测试用例模板和方法占位符。我们现在需要将这些之前章节中为 TemperatureConverter 创建的测试代码输入到这些占位符中:
package com.example.aatg.tc.test;
import static org.junit.Assert.*;
import java.util.HashMap;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import com.example.aatg.tc.TemperatureConverter;
public class TemperatureConverterTests {
private static final HashMap<Double, Double> conversionTableDouble =
new HashMap<Double, Double>();
static {
// initialize (c, f) pairs
conversionTableDouble.put(0.0, 32.0);
conversionTableDouble.put(100.0, 212.0);
conversionTableDouble.put(-1.0, 30.20);
conversionTableDouble.put(-100.0, -148.0);
conversionTableDouble.put(32.0, 89.60);
conversionTableDouble.put(-40.0, -40.0);
conversionTableDouble.put(-273.0, -459.40);
}
之前的代码片段显示了导入和 TemperatureConverterTests 的定义。这几乎与之前完全相同,只是增加了一个 JUnit 4 注解:
@Before
public void setUp() throws Exception {
}
@After
public void tearDown() throws Exception {
}
/**
* Test method for {@link com.example.aatg.tc. TemperatureConverter#fahrenheitToCelsius(double)}.
*/ @Test
public void testFahrenheitToCelsius() {
for (double c: conversionTableDouble.keySet()) {
final double f = conversionTableDouble.get(c);
final double ca = TemperatureConverter.fahrenheitToCelsius(f);
final double delta = Math.abs(ca - c);
final String msg = "" + f + "F -> " + c + "C but is " + ca + " (delta " + delta + ")";
assertTrue(msg, delta < 0.0001);
}
}
/**
* Test method for {@link com.example.aatg.tc. TemperatureConverter#celsiusToFahrenheit(double)}.
*/ @Test
public void testCelsiusToFahrenheit() {
for (double c: conversionTableDouble.keySet()) {
final double f = conversionTableDouble.get(c);
final double fa = TemperatureConverter.celsiusToFahrenheit(c);
final double delta = Math.abs(fa - f);
final String msg = "" + c + "C -> " + f + "F but is " + fa + " (delta " + delta + ")";
assertTrue(msg, delta < 0.0001);
}
}
再次,这个代码片段与我们的测试用例的先前版本没有变化,只是增加了一个 JUnit 4 注解:
@Test
public final void testExceptionForLessThanAbsoluteZeroF() {
try {
final double c = TemperatureConverter.fahrenheitToCelsius( TemperatureConverter.ABSOLUTE_ZERO_F-1);
fail("Less than absolute zero F not detected");
}
catch (InvalidTemperatureException ex) {
// do nothing
}
} @Test
public final void testExceptionForLessThanAbsoluteZeroC() {
try {
final double f = TemperatureConverter.celsiusToFahrenheit( TemperatureConverter.ABSOLUTE_ZERO_C-1);
fail("Less than absolute zero C not detected");
}
catch (RuntimeException ex) {
// do nothing
}
}
}
代码完全相同,只有一些细微的差异。其中一个差异是我们现在使用 @Test 注解测试,因为 JUnit 4 通过这个注解找到测试方法,而不是通过它们的名称。所以在这个例子中,我们使用与之前相同的测试方法名称,但严格来说,我们也可以使用不同的名称,例如 shouldRaiseExceptionForLessThanAbsoluteZeroC 而不是 testExceptionForLessThanAbsoluteZeroC。
比较性能提升
一旦测试完成,我们就可以通过选择适当的测试启动器在 Eclipse 中运行它们,Eclipse JUnit 启动器:

区别很明显。没有模拟器启动,没有设备通信,因此速度提升很重要。分析证据,我们可以找出这些差异。
在我的开发计算机上运行所有测试需要 0.005 秒,有些测试所需时间如此之短,以至于甚至不计入,显示为 0.000 秒:

将此与在模拟器上运行相同测试所需的时间进行比较,这种巨大的差异就显而易见了:

这些相同的测试运行了 0.443 秒,几乎是前者的 100 倍,如果你考虑到每天运行成百上千次测试,这将是巨大的差异。
还值得注意的是,除了速度提升之外,还存在其他优势,那就是有多个模拟框架和代码覆盖率工具可用。
将 Android 加入画面
我们故意将 Android 留在我们的画面之外。让我们分析如果我们包含一个简单的 Android 测试会发生什么。记住,为了使这些测试编译,android.jar 也应该添加到项目的库中。
将此测试添加到名为 TemperatureConverterActivityUnitTests: 的新 JUnit 测试用例中
package com.example.aatg.tc.test;
import static org.junit.Assert.assertNotNull;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import android.app.Application;
import android.content.Intent;
import com.example.aatg.tc.TemperatureConverterActivity;
import com.example.aatg.tc.TemperatureConverterApplication;
public class TemperatureConverterActivityUnitTests { @Before
public void setUp() throws Exception {
} @After
public void tearDown() throws Exception {
} @Test
public final void testApplication() {
Application application = new TemperatureConverterApplication();
assertNotNull(application);
}
}
下面是我们得到的结果:
java.lang.RuntimeException: Stub!
在 android.content.Context.
在 android.content.ContextWrapper.
在 android.app.Application.
在 com.example.aatg.tc.TemperatureConverterApplication.
…
原因是 android.jar 只提供了 API,没有实现。所有方法在使用时都会抛出 java.lang.RuntimeException: Stub!。
如果我们想绕过这种限制来测试 Android 操作系统之外的某些类,我们应该创建一个模拟每个类的 android.jar。然而,我们也会发现 Android 类的子类(如 TemperatureConverterApplication)存在问题。这将是一项艰巨的任务,需要大量的工作,因此我们应该寻找另一种解决方案。
介绍 Robolectric
Robolectric (pivotal.github.com/robolectric/) 是一个单元测试框架,它拦截 Android 类的加载并重写方法体。Robolectric 重新定义了 Android 方法,使它们返回默认值,如 null, 0 或 false,如果提供了,它将方法调用转发到阴影对象,以提供 Android 的行为。
提供了大量阴影对象,但这远非全面覆盖,然而它正在不断改进。这也应该引导你将其视为一个不断发展的开源项目,你应该准备好为其做出贡献以使其变得更好,但也应谨慎依赖它,因为你可能会发现你为测试所需的功能尚未实现。这绝不是要贬低其有希望的未来。
安装 Robolectric
Robolectric 可以通过从 Maven 中央仓库下载 robolectric-<version>-jar-with-dependencies.jar 来安装 (repo1.maven.org/maven2/com/pivotallabs/robolectric/)。在撰写本文时,可用的最新 JAR 文件是 robolectric-0.9.8-jar-with-dependencies.jar,这是我们将在示例中使用的内容。
方便的是,您还可以下载相应的 Javadoc 并将其附加到项目属性中的库,以便您可以从 Eclipse 访问文档。
创建一个新的 Java 项目
为了使我们的测试保持组织,我们正在创建一个新的 Java 项目,就像我们在前面的部分中所做的那样。这次我们添加了以下库:
-
robolectric-<version>-jar-with-dependencies.jar。 -
您 Android SDK 中的
android.jar。 -
maps.jar也来自您的 Android SDK。请注意,当您安装 SDK 时,这是一个可选包。 -
JUnit 4.
编写一些测试
我们将通过重现我们之前编写的一些测试来熟悉 Robolectric。
一个很好的例子可以重写 EditNumber 测试。让我们创建一个新的 EditNumberTests 类,这次是在新创建的项目中,并将测试从 TemperatureConverterTest 项目的 EditNumberTests 中复制过来:
package com.example.aatg.tc.test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import com.example.aatg.tc.EditNumber;
import com.xtremelabs.robolectric.RobolectricTestRunner; @RunWith(RobolectricTestRunner.class)
public class EditNumberTests {
private static final double DELTA = 0.00001d;
private EditNumber mEditNumber;
在前面的代码片段中,我们定义了包。在这种情况下,使用 com.example.aatg.tc.test 如常。我们还使用 @RunWith 注解声明了测试运行器。稍后,我们定义了 mEditNumber 字段来保存对 EditNumber: 的引用:
@Before
public void setUp() throws Exception { mEditNumber = new EditNumber(null);
mEditNumber.setFocusable(true);
}
@After
public void tearDown() throws Exception {
}
@Test
public final void testPreconditions() {
assertNotNull(mEditNumber);
}
/**
* Test method for {@link com.example.aatg.tc.EditNumber# EditNumber(android.content.Context, AttributeSet attrs, int defStyle)}.
*/
@Test
public final void testEditNumberContextAttributeSetInt() {
final EditNumber e = new EditNumber(null, null, -1);
assertNotNull(e);
}
这个代码片段包括通常的 setup() 和 tearDown() 方法,然后是 testPreconditions() 测试。在 setUp() 方法中,我们创建了一个具有 null 上下文的 EditNumber,然后将其设置为可聚焦:
/**
* Test method for {@link com.example.aatg.tc.EditNumber#clear()}.
*/
@Test
public final void testClear() {
final String value = "123.45";
mEditNumber.setText(value);
mEditNumber.clear();
String expectedString = "";
String actualString = mEditNumber.getText().toString();
assertEquals(expectedString, actualString);
}
/**
* Test method for {@link com.example.aatg.tc.EditNumber# setNumber(double)}.
*/
@Test
public final void testSetNumber() {
mEditNumber.setNumber(123.45);
final String expected = "123.45";
final String actual = mEditNumber.getText().toString();
assertEquals(expected, actual);
}
/**
* Test method for {@link com.example.aatg.tc.EditNumber# getNumber()}.
*/
@Test
public final void testGetNumber() {
mEditNumber.setNumber(123.45);
final double expected = 123.45;
final double actual = mEditNumber.getNumber(); assertEquals(expected, actual, DELTA);
}
}
在这个最后的代码片段中,我们有基本的测试,与之前示例中的 EditNumber 测试相同。
我们强调最重要的变化。第一个变化是使用注解@RunWith指定 JUnit 将委托处理测试的测试运行器。在这种情况下,我们需要使用RobolectricTestRunner.class作为运行器。然后我们使用一个 null 的Context创建一个EditText,因为这个类不能被实例化。最后,在testGetNumber中指定了一个DELTA值,因为 JUnit 4 中浮点数需要它。此外,我们还添加了@Test注解来标记方法为测试。
原始EditNumberTests中存在的其他测试方法由于各种原因无法实现或简单地失败。例如,正如我们之前提到的,Robolectric 类返回默认值,如null, 0, false等,这种情况也适用于Editable.Factory.getInstance(),它返回 null 并导致测试失败;因为没有其他方法创建Editable对象,所以我们陷入了僵局。
同样,EditNumber设置的InputFilter是非功能的。创建一个期望某些行为的测试是徒劳的。
这些不足的替代方案是创建Shadow类,但这需要修改 Robolectric 源代码并创建Robolectric.shadowOf()方法。如果您有兴趣将这种方法应用于测试,可以在文档中找到此过程的描述。
在能够运行测试之前,您需要为TemperatureConverter项目的AndroidManifest.xml和 Robolectric 使用的资源创建符号链接。
$ cd ~/workspace/TemperatureConverterJVMTests
$ ln -s ../TemperatureConverter/AndroidManifest.xml
$ ln -s ../TemperatureConverter/res . # note the dot at the end
在确定了这些问题之后,我们可以从 Eclipse 内部运行测试,它们将在主机的 JVM 上运行,无需启动或与模拟器或设备通信。
摘要
这一章比之前的章节要复杂一些,唯一的目的是面对现实情况以及最先进的 Android 测试。
我们开始分析从源代码构建 Android 的要求和步骤。这项措施是为了能够通过 EMMA 激活代码覆盖率,我们确实这样做了,并且后来运行了我们的测试,获得了详细的代码覆盖率分析报告。
然后,我们使用这份报告来改进我们的测试,并创建了一些覆盖我们之前未意识到尚未测试的区域。这使我们有了更好的测试,并在某些情况下改进了被测试项目的架构。
我们介绍了 Robotium,这是一个非常有用的工具,可以帮助我们轻松创建 Android 应用的测试用例,并且我们用它改进了一些测试。
然后,我们分析了 Android 测试中最热门的话题之一,即开发主机 JVM 上的测试优化,这可以显著减少运行测试所需的时间,这在我们将测试驱动开发应用于我们的流程时是非常期望的。在这个范围内,我们分析了 JUnit 4 和 Robolectric,并创建了一些测试作为演示,以便您开始使用这些技术。
我们已经到达了通过可用方法和工具探索 Android 测试的旅程的终点。现在,你应该已经为开始将所学应用到自己的项目中做好了充分的准备。一旦开始使用,效果就会立即显现。
最后,我希望你阅读这本书的乐趣和我写作这本书的乐趣一样多。
祝测试愉快!





浙公网安备 33010602011771号