安卓应用测试学习手册(三)
原文:
zh.annas-archive.org/md5/2D763C9A9F15D0F0D25AB1997E2D1779译者:飞龙
第七章:行为驱动开发
行为驱动开发(BDD)可以理解为测试驱动开发(TDD)和验收测试的发展和融合。这两种技术在前面的章节中都有讨论,所以在继续之前,您可能想回顾一下第一章,开始测试,以及第六章,实践测试驱动开发。
行为驱动开发(BDD)引入了一些新概念,例如使用通用词汇描述测试,以及在软件开发项目中包含业务参与者,如产品所有者或业务分析师。
我们之前已经接触过测试驱动开发,我们专注于将低级需求转换为可以推动我们开发过程的测试。行为驱动开发迫使我们关注更高级别的需求,通过使用特定的词汇,我们可以以可以进一步分析或评估的方式表达这些需求。有些人认为 BDD 只是正确执行的 TDD 的哲学。
我们将通过实例来探索这些概念,以便您可以得出自己的结论。
给定、当、然后
Given/When/Then 这些词汇是跨越业务和技术之间鸿沟的通用语言,正如在 behaviour-driven.org 所描述的,它们也可以被称为行为驱动开发无处不在的语言。该框架基于以下三个核心原则,我们在这里逐字重现:
-
业务和技术应该以相同的方式指代同一个系统。
-
任何系统都应该对业务有一个明确且可验证的价值。
-
前期的分析、设计和规划,都会有一个递减的回报。
行为驱动开发依赖于使用这个特定的词汇。此外,预先确定的需求表达格式允许工具解释和执行它们:
-
给定:这是描述在外部刺激接收之前的初始状态。
-
当:这是为了描述用户执行的关键动作。
-
然后:这是为了分析行动的结果。为了可观察,所执行的动作应该有一种结果。
FitNesse
FitNesse 是一个软件开发协作工具,可用于管理 BDD 场景。严格来说,FitNesse 是一组工具,如下所述:
-
作为软件测试工具,FitNesse 是一个轻量级、开源的框架,允许团队协作。
-
它还是一个 Wiki,您可以轻松创建、编辑页面并共享信息。
-
一个网络服务器,因此它不需要额外的配置或管理权限来设置或配置。
从www.fitnesse.org下载 FitNesse 发行版。该发行版是一个 JAR 文件,在首次运行时自动安装。在这些示例中,我们使用了 FitNesse 独立版 20140901,但较新版本也应该可以工作。
从命令行运行 FitNesse
默认情况下,FitNesse 运行时监听 80 端口,因此要在非特权模式下运行,你应该在命令行上更改端口。在此示例中,我们使用8900:
$ java -jar fitnesse.jar -p 8900
这是我们运行命令时获得的输出:
Bootstrapping FitNesse, the fully integrated standalone wiki and acceptance testing framework.
root page: fitnesse.wiki.fs.FileSystemPage at ./FitNesseRoot#latest
logger: none
authenticator: fitnesse.authentication.PromiscuousAuthenticator
page factory: fitnesse.html.template.PageFactory
page theme: fitnesse_straight
Starting FitNesse on port: 8900
一旦运行,你可以将浏览器直接指向本地 FitNesse 服务器主页(http://localhost:8900/FrontPage),你将看到以下内容:
创建 TemperatureConverterTests 子维基
一旦 FitNesse 启动并运行,我们可以通过创建一个子维基来组织我们的测试。你可能已经熟悉维基概念。如果不是,维基是一个允许用户编辑和创建网页的网站。这种编辑过程是在浏览器内完成的,并使用一种大大简化过程的标记语言。
注意
你可以在可能是最著名的维基en.wikipedia.org/wiki/Wiki上了解更多关于维基的信息。
尽管这种子维基组织不是强制性的,但强烈建议这样做,特别是如果你打算在多个项目中使用 FitNesse 进行验收测试。
最简化的过程之一是超链接创建,只需使用驼峰命名或维基词即可完成;即以大写字母开头并至少包含一个大写字母的单词。这个维基词将被转换成同名页面的超链接。
要创建TemperatureConverterTests子维基,我们只需点击 FitNesse 标志右侧的编辑按钮,编辑主页,添加以下内容:
| '''My Tests''' |
| TemperatureConverterTests | ''Temperature Converter Tests'' |
这通过使用"|"标记作为第一个字符和分隔列,向页面添加了一个新表格。
我们还添加了一列带有描述性注释的测试。这个注释通过使用两个单引号('')包围变成斜体。这段文本将创建一个名为TemperatureConverterTests的维基链接。
按下保存,页面将被修改。
一旦页面显示,我们可以验证TemperatureConverterTests后面是否跟着一个**[?]**(问号),因为页面尚未创建,在我们点击它时将会创建。现在点击它,这将直接把我们带入新页面的编辑模式。我们可以添加一些注释以清楚地标识这个新创建的子维基主页:
!contents -R2 -g -p -f -h
This is the !-TemperatureConverterTests SubWiki-!.
这里,文本TemperatureConverterTests SubWiki通过使用!-和-!进行转义,以防止它被转换成另一个页面链接。
再次按下保存。
向子维基添加子页面
现在,我们通过使用页面标题旁边的**[添加]**链接来添加一个新子页面。
创建子页面有不同的选项,我们可以选择:
-
静态:这是一个普通的维基页面
-
套件:这是一个包含其他测试构成套件的页面
-
测试:这是一个包含测试的页面
我们将选择添加一个套件页面,并将其命名为TemperatureConverterTestSuite,如下截图所示:
点击保存后,这个页面就会被创建,并且自动作为链接添加到TemperatureConverterTests子维基中。
让我们跟随这个新创建的链接到达测试套件页面。
到这里后,使用**[添加]** | **[测试页面]**再添加一个子页面。这添加了一个测试页面,我们将它命名为TemperatureConverterCelsiusToFahrenheitFixture,因为这将会包含我们的夹具。这里的命名只是按照约定来组织我们的维基。
点击保存来完成操作。
添加验收测试夹具
直到现在,我们一直在创建维基页面。这并没有什么令人兴奋的!现在,我们将直接把验收测试夹具添加到页面中。确保你已经导航到新添加的页面TemperatureConverterCelsiusToFahrenheitFixture,点击编辑,并将<test page>替换为以下内容:
!contents
!|TemperatureConverterCelsiusToFahrenheitFixture |
|celsius|fahrenheit? |
|0.0 |~= 32 |
|100.0 |212.0 |
|-1.0 |30.2 |
|-100.0 |-148.0 |
|32.0 |89.6 |
|-40.0 |-40.0 |
|-273.0 |~= -459.4 |
|-273 |~= -459.4 |
|-273 |~= -459 |
|-273 |~= -459.40000000000003 |
|-273 |-459.40000000000003 |
|-273 |-459.41 < _ < -459.40 |
|-274.0 |Invalid temperature: -274.00C below absolute zero|
这个表格为我们的测试特性定义了几个项目:
-
TemperatureConverterCelsiusToFahrenheitFixture:这是表格标题和测试夹具名称。 -
celsius:这是我们提供给测试作为输入值的列名。 -
fahrenheit?:这是我们期望作为转换结果的值的列名。问号表示这是一个结果值。 -
~=:这表示结果大约是这个值。 -
< _ <:这表示预期值在这个范围内。 -
Invalid temperature:低于绝对零度-274.00 摄氏度是失败的转换预期的值。
通过点击保存来保存这些内容。
添加支持测试类
如果我们按下测试按钮,这个按钮位于 FitNesse 标志下方(详细情况见下截图),我们将收到一个错误。在某种程度上这是预期的,因为我们还没有创建支持测试夹具。测试夹具将是一个非常简单的类,调用TemperatureConverter类的方法。
FitNesse 支持以下两种不同的测试系统:
-
fit:这是两种方法中较旧的一种,使用 HTML,在调用夹具之前解析
-
slim:这是较新的方法;所有的表格处理都在 FitNesse 内的 slim 运行器中完成
关于这些测试系统的更多信息可以在以下链接找到:fitnesse.org/FitNesse.UserGuide.WritingAcceptanceTests.TestSystems。
在这个例子中,我们通过在同一个页面内设置变量TEST_SYSTEM来使用 slim:
!define TEST_SYSTEM {slim}
现在,我们将要创建 slim 测试夹具。请记住,测试夹具是一个简单的类,它允许我们从 FitNesse 测试套件运行已经写好的温度转换代码。我们在现有项目TemperatureConverter的core模块内创建一个名为com.blundell.tut.fitnesse.fixture的新包。我们将在该包内创建夹具。
接下来,我们必须创建一个TemperatureConverterCelsiusToFahrenheitFixture类,这是我们在验收测试表中定义的:
public class TemperatureConverterCelsiusToFahrenheitFixture {
private double celsius;
public void setCelsius(double celsius) {
this.celsius = celsius;
}
public String fahrenheit() throws Exception {
try {
double fahrenheit = TemperatureConverter
.celsiusToFahrenheit(celsius);
return String.valueOf(fahrenheit);
} catch (RuntimeException e) {
return e.getLocalizedMessage();
}
}
}
作为提醒,完成后的样子应类似如下:
这个夹具应该委托给我们的真实代码,而不应该自己执行任何操作。我们决定从fahrenheit()返回String,这样我们就可以在同一个方法中返回Exception消息。
在此阶段,运行核心模块测试以确保你没有破坏任何东西(同时编译新创建的类以备后用)。
在 FitNesse 测试页面上,我们还应该定义测试所在的包。这允许在 FitNesse 中编写的测试能找到我们在 Android 项目中编写的测试夹具。在我们仍在编辑的同一页面中,添加:
|import|
|com.blundell.tut.fitnesse.fixture|
现在,我们将 Android 项目类文件添加到 FitNesse 测试的路径中。这允许 FitNesse 使用我们新编写的测试夹具和我们的TemperatureConverter;即测试中的代码。
!path /Users/blundell/AndroidApplicationTestingGuide/core/build/classes/test
!path /Users/blundell/AndroidApplicationTestingGuide/core/build/classes/main
注意
这应该适应你的系统路径。这里的重点是/core/之后的路径。这指向的是你的测试应用程序下编译的*.class文件的存放位置。注意,我们需要分别添加测试源和项目源。
完成这些步骤后,我们可以点击测试按钮运行测试,以下截图将反映结果:
我们可以通过它们的绿色来判断每个成功的测试,以及通过红色来判断失败的测试。在这个例子中,我们没有失败,所以一切都是绿色的。注意,它还显示了我们所声明的classpath和TEST_SYSTEM变量。
FitNesse 还有一个有用的功能,测试历史。所有测试运行和一定数量的结果会被保存一段时间,这样你可以在以后查看结果并进行比较,从而分析你的更改的演变。
你可以通过点击顶部菜单中工具下的列表底部的测试 历史来访问此功能。
在以下屏幕截图中,我们可以看到最近 3 次测试运行的结果,其中 2 次失败,1 次成功。同时,通过点击**+(加号)或-**(减号)标志,你可以展开或折叠视图以显示或隐藏有关测试运行的详细信息:
GivWenZen
GivWenZen 是一个框架,基于 FitNesse 和 Slim,允许用户利用行为驱动开发技术的表达方式,使用 给定-当-那么 词汇来描述测试。这些测试描述也使用 FitNesse 维基功能创建,即以纯文本表格形式在维基页面上表达测试。
这个想法简单直接,与我们之前使用 FitNesse 的工作一脉相承,但这次我们不是通过提供一个值表来编写验收测试,而是将使用行为驱动开发的三个神奇词汇 给定-当-那么 来描述我们的场景。
首先,让我们安装带有 GivWenZen 的 FitNesse。从其下载列表页面 goo.gl/o3Hlpo 下载完整发行版。解压后,GivWenZen JAR 的启动方式与 FitNesse 完全相同(因为它只是在顶部的一个修改):
$ java -jar /lib/fitnesse.jar -p 8900
更多阅读、综合说明和示例可以在维基上找到,地址是 github.com/weswilliams/GivWenZen/wiki。我们在这些示例中使用了 GivWenZen 1.0.3,但新版本应该也能工作。
GivWenZen 完整发行版包括了所有需要的依赖,包括 FitNesse,因此如果你之前运行过 FitNesse 示例,最好停止它,因为你必须使用不同的 JAR 文件来运行 GivWenZen。
启动后,将浏览器指向主页,你会看到一个熟悉的 FitNesse 首页,或者如果你像之前那样配置了端口,可以查看 http://localhost:8900/GivWenZenTests 的一些测试。你可以花些时间探索其中包含的示例。
创建测试场景
让我们为我们的温度转换器创建一个简单的场景,这样我们可以更好地理解给定-当-那么。
用纯英文句子表达,我们的场景将是:
给定 我正在使用温度转换器,当 我在摄氏度字段输入 100,那么 我在华氏度字段得到 212。
通过将这段内容添加到维基页面,其值将直接转换成 GivWenZen 场景:
-|script|
|given|I'm using the !-TemperatureConverter-!|
|when |I enter 100 into the Celsius field|
|then |I obtain 212 in the Fahrenheit field|
翻译非常直接。表格标题必须是 script,在这种情况下,前面有一个破折号 (-) 来隐藏它。然后每个 给定-当-那么 场景都放在一列中,谓词放在另一列。
在运行这个脚本之前,当整个页面被执行时,我们需要通过运行另一个脚本来初始化 GivWenZen。你通过将其添加到维基页面的顶部来实现这一点。
|import|
|org.givwenzen|
-|script|
|start|giv wen zen for slim|
我们还需要初始化类路径并为所有脚本添加相应的导入。通常,这在一个 SetUp 页面中完成,该页面在运行每个测试脚本之前执行(就像 JUnit 测试中的 setUp() 方法),但为了简单起见,我们将初始化添加到这个相同的页面:
!define TEST_SYSTEM {slim}
!path ./target/classes
!path ./target/examples
!path ./lib/clover-2.6.1.jar
!path ./lib/commons-logging.jar
!path ./lib/commons-vfs-1.0.jar
!path ./lib/dom4j-1.6.1.jar
!path ./lib/fitnesse.jar
!path ./lib/guava-18.0.jar
!path ./lib/javassist.jar
!path ./lib/log4j-1.2.9.jar
!path ./lib/slf4j-simple-1.5.6.jar
!path ./lib/slf4j-api-1.5.6.jar
!path ./givwenzen-20150106.jar
!path /Users/blundell/AndroidApplicationTestingGuide/core/build/classes/test
!path /Users/blundell/AndroidApplicationTestingGuide/core/build/classes/main
你需要更改最后两个路径变量以匹配你的 TemperatureConverter 项目;稍后你会明白为什么需要这些。
如果此时你点击测试按钮运行测试,你会收到以下信息:
__EXCEPTION__:org.givwenzen.DomainStepNotFoundException:
在表格的第二列中,我们的测试大纲保存了领域步骤,因此,出现了DomainStepNotFound异常。你需要一个步骤类,其中有一个带注解的方法与这个模式匹配:"我正在使用 TemperatureConverter"。
这种错误的典型原因如下:
-
StepClass丢失了:这是我们的错误 -
StepClass缺少了@DomainSteps注解 -
StepMethod缺少了@DomainStep注解 -
StepMethod注解中有一个正则表达式,它与你当前编写的测试步骤不匹配
一个示例步骤类可能是这样的:
@DomainSteps
public class StepClass {
@DomainStep("I'm using the TemperatureConverter")
public void domainStep() {
// TODO implement step by invoking your own code
}
}
步骤类应该放在bdd.steps包或其子包中,或者你也可以定义自己的自定义包。这个包将位于我们应用程序的/core/test/模块中。如果你注意到了,在setUp页面上方,我们将我们的应用程序添加到了路径上,因此在我们构建项目后,可以找到这个 DomainStep。
为了在我们的项目中使用@DomainStep(s)注解,我们需要在项目的测试路径上放置 GivWenZen JAR。这可以通过将givwenzen.jar文件复制到/core/libs中来实现,或者更好的方式是在build.gradle中将其作为远程依赖添加:
testCompile 'com.github.bernerbits:givwenzen:1.0.6.1'
提示
你会注意到,这个testCompile依赖并不是官方的 GivWenZen 发布版本,而是有人复制了代码并上传了。目前这对我们来说没有影响,因为我们只使用了两个注解类(我知道在这个版本中它们是相同的),但值得记住,如果它作为远程依赖发布,我们应该恢复到原始的 GivWenZen 库。
根据小轮廓示例,在我们特定的案例中,StepClass的实现将是:
package bdd.steps.tc;
import com.blundell.tut.TemperatureConverter;
import org.givwenzen.annotations.DomainStep;
import org.givwenzen.annotations.DomainSteps;
@DomainSteps
public class TemperatureConverterSteps {
private static final String CELSIUS = "Celsius";
private static final String FAHRENHEIT = "Fahrenheit";
private static final String UNIT_NAME
= "(" + CELSIUS + "|" + FAHRENHEIT + ")";
private static final String ANY_TEMPERATURE
= "([-+]?\\d+(?:\\.\\d+)?)";
private double inputTemperature = Double.NaN;
@DomainStep("I(?: a|')m using the TemperatureConverter")
public void createTemperatureConverter() {
// do nothing
}
@DomainStep("I enter " + ANY_TEMPERATURE
+ " into the " + UNIT_NAME + " field")
public void setField(double inputTemperature, String unitName) {
this.inputTemperature = inputTemperature;
}
@DomainStep("I obtain " + ANY_TEMPERATURE
+ " in the " + UNIT_NAME + " field")
public boolean verifyConversion(double expectedTemperature, String unitName) {
double outputTemperature = convertInputInto(unitName);
return Math.abs(outputTemperature - expectedTemperature) < 0.01D;
}
private double convertInputInto(String unitName) {
double convertedInputTemperature;
if (CELSIUS.equals(unitName)) {
convertedInputTemperature = getCelsius();
} else if (FAHRENHEIT.equals(unitName)) {
convertedInputTemperature = getFahrenheit();
} else {
throw new RuntimeException("Unknown conversion unit" + unitName);
}
return convertedInputTemperature;
}
private double getCelsius() {
return TemperatureConverter.fahrenheitToCelsius(inputTemperature);
}
private double getFahrenheit() {
return TemperatureConverter.celsiusToFahrenheit(inputTemperature);
}
}
在这个例子中,我们使用了bdd.steps的子包,因为默认情况下,GivWenZen 会在这个包层次结构中搜索步骤的实现。否则,需要额外的配置。
实现步骤的类应该用@DomainSteps注解,每个步骤的方法用@DomainStep注解。每个步骤方法注解接收一个字符串正则表达式作为参数。GivWenZen 使用这个正则表达式来匹配步骤。
例如,在我们的场景中,我们定义了这一步骤:
I enter 100 into the Celsius field
我们的注解如下:
@DomainStep("I enter " + ANY_TEMPERATURE
+ " into the " + UNIT_NAME + " field")
这将匹配,并且由ANY_TEMPERATURE和UNIT_NAME定义的正则表达式组值将被获取,并提供给方法的参数value和unitName:
public void setField(double inputTemperature, String unitName)
请记住,在上一章中我建议复习正则表达式,因为它们可能很有用。好吧,这可能是它们极其有用的地方之一。它允许灵活使用英语。在这里使用了 I(?: a|'m) 来允许 I am 和 I'm。在 ANY_TEMPERATURE, 中,我们匹配每个可能的温度值,包括可选的符号和小数点。因此 UNIT_NAME 匹配单位名称;即摄氏度或华氏度。
这些正则表达式用于构建 @DomainStep 注解参数。这些正则表达式中由 () 括号分隔的组被转换为方法参数。这就是 setField() 获取其参数的方式。
然后,我们有一个 verifyConversion() 方法,根据实际转换与预期转换是否在两位小数的差异内匹配,返回 true 或 false。
最后,我们有一些实际调用 TemperatureConverter 类中的转换方法的方法。
再次运行测试后,所有测试都通过了。我们可以通过分析输出消息来确认这一点:
Assertions: 1 right, 0 wrong, 0 ignored, 0 exceptions.
我们不仅应该为正常情况创建场景,还应该涵盖异常条件。比如,用纯文本来说,我们的场景是这样的:
注意
假设我正在使用温度转换器,当我将 -274 输入到摄氏度字段中时,我会得到一个 无效温度:-274.00C 低于绝对零度 的异常。
它可以转换成如下所示的 GivWenZen 表:
-|script|
|given|I am using the !-TemperatureConverter-! |
|when |I enter -274 into the Celsius field |
|then |I obtain 'Invalid temperature: -274.00C below absolute zero' exception|
通过添加一个单一的支持步骤方法,我们将能够运行它。步骤方法可以这样实现:
@DomainStep("I obtain '(Invalid temperature: " + ANY_TEMPERATURE + " C|F below absolute zero)' exception")
public boolean verifyException(String message, String value, String unit) {
try {
if ( "C".equals(unit) ) {
getFahrenheit();
} else {
getCelsius();
}
} catch (RuntimeException ex) {
return ex.getMessage().contains(message);
}
return false;
}
此方法从正则表达式中获取异常消息、温度值和单位。然后将其与实际的异常消息进行比较,以验证是否匹配。
注意
当你将 Java 代码添加到 StepClass 注解中时,不要忘记你需要重新编译该类,以便 FitNesse 可以使用新代码。这样做的一种方式是从 IDE 中运行你的 Java 测试,强制重新编译。
此外,我们可以创建其他场景,在这种情况下,将由现有的步骤方法支持。这些场景可能是:
-|script|
|given |I'm using the !-TemperatureConverter-! |
|when |I enter -100 into the Celsius field |
|then |I obtain -148 in the Fahrenheit field |
-|script|
|given |I'm using the !-TemperatureConverter-! |
|when |I enter -100 into the Fahrenheit field |
|then |I obtain -73.33 in the Celsius field |
-|script|
|given|I'm using the !-TemperatureConverter-! |
|when |I enter -460 into the Fahrenheit field |
|then |I obtain 'Invalid temperature: -460.00F below absolute zero' exception|
因为 GivWenZen 基于 FitNesse,我们可以自由地结合这两种方法,并将之前会话中的测试包含在同一个套件中。这样做,我们可以从套件页面运行整个套件,获得以下总体结果:
总结
在本章中,我们发现了行为驱动开发作为测试驱动开发(我们在前几章中检查过)的演变。
我们讨论了行为驱动开发背后的推动力。我们分析了作为基础的概念,探索了 Given-When-Then 词汇表的想法,并介绍了 FitNesse 和 Slim 作为部署测试的有用工具。
我们介绍了 GivWenZen,这是一个基于 FitNesse 的工具,它使我们能够创建近乎英文的、散文式场景,并对它们进行测试。
我们将这些技术和工具引入到了我们的示例 Android 项目中。然而,我们的测试对象仍然局限于可以在 JVM 下测试的,避免使用特定于 Android 的类和用户界面。我们将在第九章,替代测试策略中探索一些方法来克服这一限制。
下一章将讨论测试的另一面,专注于性能和剖析,这在我们应用程序按预期运行并符合我们的测试规范后,是一个自然而然的步骤。
第八章:测试和性能分析
在前面的章节中,我们研究和开发了针对 Android 应用程序的测试。这些测试让我们能够根据规范评估合规性,并通过二进制的判断确定软件是否根据这些规则正确或错误地行为。如果所有测试用例通过,意味着我们的软件表现如预期。如果其中一个测试用例失败,则软件需要修复。
在许多其他情况下,主要在我们验证软件符合所有这些规范后,我们希望向前迈进,了解这些标准是如何满足的。同时,我们还想了解系统在不同情况下的表现,以分析其他属性,如可用性、速度、响应时间和可靠性。
根据 Android 开发者指南(developer.android.com/),在设计应用程序时以下是最佳实践:
-
性能设计
-
响应性设计
-
无缝设计
遵循这些最佳实践并从一开始的设计中考虑性能和响应性至关重要。由于我们的应用程序将在计算能力有限的 Android 设备上运行,因此在构建应用程序(至少是部分构建)后确定优化目标,并应用性能测试(我们将在后面讨论)可以为我们带来更大的收益。
多年前唐纳德·克努特普及了这一观点:
"过早优化是万恶之源"。
基于猜测、直觉甚至迷信的优化,往往会在短期内影响设计,并在长期内影响可读性和可维护性。相反,微优化基于识别需要优化的瓶颈或热点,应用更改,然后再次进行基准测试以评估优化的改进。因此,我们在这里关注的是测量现有性能和优化替代方案。
本章将介绍一系列与基准测试和性能分析相关的概念,如下:
-
传统的日志语句方法
-
创建 Android 性能测试
-
使用性能分析工具
-
使用 Caliper 进行微基准测试
旧日志方法
有时,这对于现实生活场景来说过于简单,但我不想说它在某些情况下可能没有帮助,主要是因为其实施只需要几分钟,你只需要logcat文本输出就可以分析案例。在希望自动化流程或应用持续集成的场景中,这很方便,如前几章所述。
这种方法包括对方法(或其一部分)进行计时,在它前后各进行一次时间测量,并在最后记录差值:
private static final boolean BENCHMARK_TEMPERATURE_CONVERSION = true;
@Override
public void onTextChanged(CharSequence input, int start, int before, int count) {
if (!destinationEditNumber.hasWindowFocus()
|| destinationEditNumber.hasFocus() || input == null) {
return;
}
String str = input.toString();
if ("".equals(str)) {
destinationEditNumber.setText("");
return;
}
long t0;
if (BENCHMARK_TEMPERATURE_CONVERSION) {
t0 = System.currentTimeMillis();
}
try {
double temp = Double.parseDouble(str);
double result = (option == Option.C2F)
? TemperatureConverter.celsiusToFahrenheit(temp)
: TemperatureConverter.fahrenheitToCelsius(temp);
String resultString = String.format("%.2f", result);
destinationEditNumber.setNumber(result);
destinationEditNumber.setSelection(resultString.length());
} catch (NumberFormatException ignore) {
// WARNING this is generated whilst numbers are being entered,
// for example just a '-'
// so we don't want to show the error just yet
} catch (Exception e) {
sourceEditNumber.setError("ERROR: " + e.getLocalizedMessage());
}
if (BENCHMARK_TEMPERATURE_CONVERSION) {
long t = System.currentTimeMillis() - t0;
Log.v(TAG, "TemperatureConversion took " + t
+ " ms to complete.");
}
}
这非常直观。我们记录时间差。为此,我们使用Log.v()方法,并在运行应用程序时通过 logcat 查看输出。你可以通过设置你在外面定义的BENCHMARK_TEMPERATURE_CONVERSION常量为true或false来控制此基准测试的执行。
当我们设置BENCHMARK_TEMPERATURE_CONVERSION常量为true启动活动时,在 logcat 中,每次转换发生时,我们都会收到这样的消息:
TemperatureConversion took 5 ms to complete.
TemperatureConversion took 1 ms to complete.
TemperatureConversion took 5 ms to complete.
定时记录器
现在,比这更好的是android.util.TimingLogger安卓类。TimingLogger对象可以帮助你计时方法调用,而无需自己维护这些时间变量。它也比System.currentTimeMillis()有更高的准确度:
private static final String TAG = "TemperatureTag";
@Override
public void onTextChanged(CharSequence input, int start, int before, int count) {
if (!destinationEditNumber.hasWindowFocus()
|| destinationEditNumber.hasFocus() || input == null) {
return;
}
String str = input.toString();
if ("".equals(str)) {
destinationEditNumber.setText("");
return;
}
TimingLogger timings = new TimingLogger(TAG, "onTextChanged");
timings.addSplit("starting conversion");
try {
double temp = Double.parseDouble(str);
double result = (option == Option.C2F)
? TemperatureConverter.celsiusToFahrenheit(temp)
: TemperatureConverter.fahrenheitToCelsius(temp);
String resultString = String.format("%.2f", result);
destinationEditNumber.setNumber(result);
destinationEditNumber.setSelection(resultString.length());
} catch (NumberFormatException ignore) {
// WARNING this is generated whilst numbers are being entered,
// for example just a '-'
// so we don't want to show the error just yet
} catch (Exception e) {
sourceEditNumber.setError("ERROR: " + e.getLocalizedMessage());
}
timings.addSplit("finish conversion");
timings.dumpToLog();
}
如果你现在启动应用程序,你会注意到 logcat 中没有输出。这是因为TimingLogger需要你显式打开你定义的标签的日志记录。否则,方法调用将什么都不做。从终端运行以下命令:
adb shell setprop log.tag.TemperatureTag VERBOSE
提示
您可以使用getprop命令检查您的日志标签设置在什么级别:
adb shell getprop log.tag.TemperatureTag
你可以使用这个命令列出设备中的所有其他属性:
adb shell getprop
现在,当我们启动应用程序时,每次转换完成,我们都会收到这样的消息:
onTextChanged: begin
onTextChanged: 0 ms, starting conversion
onTextChanged: 2 ms, finish conversion
onTextChanged: end, 2 ms
你应该考虑的是,这些启用基准测试的常量不应该在生产版本中启用,就像使用其他常见常量,如DEBUG或LOGD一样。为了避免错误,你应该在用于自动化构建的构建过程中集成这些常量值的验证,例如 Gradle。此外,我个人会在构建发布到生产之前,删除所有基准测试或验证日志——不是注释掉,而是删除。记住,你总是可以在你的版本控制系统、历史记录或分支中找到它。
这样记录代码执行速度很简单,但对于更复杂的性能问题,你可能想要使用更详细(尽管更复杂)的技术。
安卓 SDK 的性能测试
如果之前添加日志声明的方法不适合你,还有其他方法可以从我们的应用程序获取性能测试结果。这种方法被称为分析。
当运行检测代码(就像我们的安卓检测测试用例)时,没有标准的方法从安卓应用程序获取性能测试结果,因为安卓测试使用的类在安卓 SDK 中是隐藏的,仅对系统应用程序可用,即作为主构建或系统映像的一部分构建的应用程序。这个策略对我们不可用,因此我们不会朝这个方向深入挖掘。相反,我们将关注其他可用的选择。
启动性能测试
这些测试基于我们刚才讨论的方法,Android 使用它们来测试系统应用程序。这个想法是扩展android.app.Instrumentation以提供性能快照,自动创建一个框架,我们甚至可以扩展它以满足其他需求。让我们通过一个简单的例子来更好地理解这意味着什么。
创建 LaunchPerformanceBase 检测
我们的第一步是扩展Instrumentation以提供我们需要的功能。我们使用了一个名为com.blundell.tut.launchperf的新包来组织我们的测试:
public class LaunchPerformanceBase extends Instrumentation {
private static final String TAG = "LaunchPerformanceBase";
protected Bundle results;
protected Intent intent;
public LaunchPerformanceBase() {
this.results = new Bundle();
this.intent = new Intent(Intent.ACTION_MAIN);
this.intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
setAutomaticPerformanceSnapshots();
}
/**
* Launches intent {@link #intent},
* and waits for idle before returning.
*/
protected void launchApp() {
startActivitySync(intent);
waitForIdleSync();
}
@Override
public void finish(int resultCode, Bundle results) {
Log.v(TAG, "Test results = " + results);
super.finish(resultCode, results);
}
}
我们在这里扩展了Instrumentation。构造函数初始化了此类中的两个字段:results和intent。最后,我们调用了setAutomaticPerformanceSnapshots()方法,这是创建此性能测试的关键。
launchApp()方法负责启动所需的 Activity 并在返回前等待。
finish()方法记录收到的结果,然后调用 Instrumentation 的finish()。
创建 TemperatureConverterActivityLaunchPerformance 类
这个类设置了 Intent 以调用TemperatureConverterActivity,并提供LaunchPerformanceBase类提供的架构以测试启动我们 Activity 的性能:
public class TemperatureConverterActivityLaunchPerformance
extends LaunchPerformanceBase {
@Override
public void onCreate(Bundle arguments) {
super.onCreate(arguments);
String className = "com.blundell.tut.TemperatureConverterActivity";
intent.setClassName(BuildConfig.APPLICATION_ID, className);
start();
}
@Override
public void onStart() {
super.onStart();
launchApp();
finish(Activity.RESULT_OK, results);
}
}
在这里,onCreate()按照 Android 生命周期调用super.onCreate()。然后设置了 Intent,指定了类名和包名。然后调用Instrumentation的一个方法start()。它创建并启动了一个新的线程以运行检测。这个新线程将调用onStart(),你可以在其中实现检测功能。
然后onStart()的实现遵循,调用launchApp()和finish()。
运行测试
app/build.gradle:
defaultConfig {
// other code
testInstrumentationRunner "com.blundell.tut.launchperf.TemperatureConverterActivityLaunchPerformance"
}
一切准备就绪后,我们就可以开始运行测试了。
首先,安装包含这些更改的 APK。然后,正如我们在前面的章节中回顾的那样,我们有多种运行测试的选择。在这种情况下,我们使用命令行,因为这是获取所有细节的最简单方法。如果你只有一个设备连接,使用这个:
$ adb shell am instrument -w com.blundell.tut.test/com.blundell.tut.launchperf.TermeratureConverterActivityLaunchPerformance
注意
如果你曾经想知道你的设备上安装了哪些Instrumentation测试运行器,你可以使用这个命令:
adb shell pm list instrumentation
我们通过标准输出接收了此测试的结果集:
IINSTRUMENTATION_RESULT: other_pss=7866
INSTRUMENTATION_RESULT: global_alloc_count=4009
INSTRUMENTATION_RESULT: java_allocated=7271
INSTRUMENTATION_RESULT: execution_time=347
INSTRUMENTATION_RESULT: gc_invocation_count=0
INSTRUMENTATION_RESULT: native_pss=0
INSTRUMENTATION_RESULT: received_transactions=-1
INSTRUMENTATION_RESULT: other_shared_dirty=7128
INSTRUMENTATION_RESULT: native_shared_dirty=0
INSTRUMENTATION_RESULT: java_free=4845
INSTRUMENTATION_RESULT: java_size=12116
INSTRUMENTATION_RESULT: global_freed_size=155012
INSTRUMENTATION_RESULT: java_pss=1095
INSTRUMENTATION_RESULT: pre_sent_transactions=-1
INSTRUMENTATION_RESULT: java_private_dirty=884
INSTRUMENTATION_RESULT: pre_received_transactions=-1
INSTRUMENTATION_RESULT: other_private_dirty=6228
INSTRUMENTATION_RESULT: native_private_dirty=0
INSTRUMENTATION_RESULT: cpu_time=120
INSTRUMENTATION_RESULT: sent_transactions=-1
INSTRUMENTATION_RESULT: native_allocated=10430
INSTRUMENTATION_RESULT: java_shared_dirty=8360
INSTRUMENTATION_RESULT: global_freed_count=1949
INSTRUMENTATION_RESULT: native_free=14145
INSTRUMENTATION_RESULT: native_size=10430
INSTRUMENTATION_RESULT: global_alloc_size=372992
INSTRUMENTATION_CODE: -1
我们突出了我们感兴趣的两组值:execution_time和cpu_time。它们分别表示总执行时间和使用的 CPU 时间。
在模拟器上运行此测试可能会增加测量不准确的可能性,因为宿主计算机正在运行其他进程,这些进程也占用 CPU,而模拟器并不一定能代表真实硬件的性能。
不用说,在这种情况下以及任何其他随时间变化的情况中,你应该使用测量策略并多次运行测试以获得不同的统计值,如平均值或标准差。
使用 Traceview 和 dmtracedump 平台工具
Android SDK 在其众多工具中包含了两个专门用于分析性能问题和配置文件,并可能确定优化目标的工具。Android 还为我们提供了 Dalvik 调试监控服务(DDMS),它将所有这些工具集中在一个地方。DDMS 可以通过 Android Studio 导航到 工具 | Android | 设备监控器 打开,或者通过命令行使用 monitor 命令打开。你可以在 DDMS 中使用方便的 GUI 快捷方式使用 Traceview 和其他工具。然而,在这里,我们将使用命令行选项,以便你了解 GUI 背后的工具。
这些工具相较于其他替代品有一个优势:通常,对于简单的任务,无需修改源代码。然而,对于更复杂的情况,我们需要进行一些简单的添加,这一点我们很快就会看到。
如果你不需要精确控制追踪的开始和停止,可以通过命令行或 Android Studio 来操作。例如,要从命令行开始追踪,可以使用以下命令。如果连接了多个设备,记得使用 -s 添加序列号:
$ adb shell am start -n com.blundell.tut/.TemperatureConverterActivity
$ adb shell am profile com.blundell.tut start /mnt/sdcard/tc.trace
进行一些操作,比如在摄氏度字段中输入一个温度值以强制转换,然后运行以下命令:
$ adb shell am profile com.blundell.tut stop
$ adb pull /mnt/sdcard/tc.trace /tmp/tc.trace
7681 KB/s (1051585 bytes in 0.133s)
$ traceview /tmp/tc.trace
否则,如果你需要更精确地控制分析开始的时间,可以添加编程式的风格:
@Override
public void onTextChanged(CharSequence input, int start, int before, int count) {
if (!destinationEditNumber.hasWindowFocus()
|| destinationEditNumber.hasFocus() || input == null) {
return;
}
String str = input.toString();
if ("".equals(str)) {
destinationEditNumber.setText("");
return;
}
if (BENCHMARK_TEMPERATURE_CONVERSION) {
Debug.startMethodTracing();
}
try {
double temp = Double.parseDouble(str);
double result = (option == Option.C2F)
? TemperatureConverter.celsiusToFahrenheit(temp)
: TemperatureConverter.fahrenheitToCelsius(temp);
String resultString = String.format("%.2f", result);
destinationEditNumber.setNumber(result);
destinationEditNumber.setSelection(resultString.length());
} catch (NumberFormatException ignore) {
// WARNING this is generated whilst numbers are being entered,
// for example just a '-'
// so we don't want to show the error just yet
} catch (Exception e) {
sourceEditNumber.setError("ERROR: " + e.getLocalizedMessage());
}
if (BENCHMARK_TEMPERATURE_CONVERSION) {
Debug.stopMethodTracing();
}
}
这将创建一个追踪文件,默认名为 dmtrace.trace,在 SD 卡上通过调用 Debug.startMethodTracing() 来启动方法追踪,它使用默认的日志名称和缓冲区大小开始方法追踪。完成之后,我们调用 Debug.stopMethodTracing() 来停止分析。
注意
请记住,启用分析会大大减慢应用程序的执行速度,因此结果应该根据它们的相对权重来解释,而不是绝对值。
为了能够向 SD 卡写入数据,应用程序需要在清单文件中添加 android.permission.WRITE_EXTERNAL_STORAGE 权限。
使用 DDMS 进行 Traceview 时,数据流会通过 JDWP 连接直接发送到开发计算机,因此不需要该权限。
你需要运行应用程序以获取追踪文件。这个文件需要被拉取到开发计算机上,以便使用 traceview 进行进一步分析:
$ adb pull /mnt/sdcard/dmtrace.trace /tmp/dmtrace.trace
8491 KB/s (120154 bytes in 0.013s)
$ traceview /tmp/dmtrace.trace
运行此命令后,Traceview 窗口会出现,显示所有收集到的信息,如截图所示:
窗口顶部显示了时间线面板以及每个方法的彩色区域。时间沿刻度向右增加。在彩色行下方还有小线条,显示了对选定方法的调用的范围。
我们对应用程序的一个小片段进行了分析,因此只有主线程在运行我们的进程。在其他线程在分析过程中运行的情况下,此信息也将显示。例如,这表明系统执行了一个 AsyncTask。
窗口底部显示了分析面板,执行了每个方法及其父子关系。我们将调用方法称为父方法,被调用的方法称为子方法。点击方法时,它会展开以显示其父方法和子方法。父方法显示为紫色背景,子方法显示为黄色背景。
此外,为方法选择的颜色(以循环方式完成)在方法名称之前显示。
最后,在底部有一个**查找:**字段,我们可以在其中输入筛选器以减少显示的信息量。例如,如果我们只想显示com.blundell.tut包中的方法,我们应该输入com/blundell/tut。
点击列标题将根据该列以升序或降序设置列表的顺序。
此表显示了可用的列及其描述:
| 列 | 描述 |
|---|---|
| 名称 | 方法的名称,包括其包名,正如我们刚才描述的那样,使用/(斜杠)作为分隔符。同时,显示参数和返回类型。 |
| 包含 CPU 时间百分比 | 方法使用的包含时间占总时间的百分比。这包括其所有子方法。 |
| 包含 CPU 时间 | 特定方法的包含时间,以毫秒为单位。这包括该方法及其所有子方法。 |
| 独占 CPU 时间百分比 | 方法使用的独占时间占总时间的百分比。这排除其所有子方法。 |
| 独占 CPU 时间 | 独占时间,以毫秒为单位。这是特定方法所花费的总时间。它不包括所有子方法。 |
| 包含实时百分比 | 进程执行时的包含时间加上等待时间占总时间的百分比(等待 I/O)。 |
| 包含实时 | 进程执行时的包含时间加上等待时间。 |
| 独占实时百分比 | 独占时间加上进程执行时的等待时间占总时间的百分比(等待 I/O)。 |
| 独占实时 | 独占时间加上进程执行时的等待时间。 |
| 调用+递归调用/总数 | 这一列显示了特定方法的调用次数和递归调用次数。与该方法收到的总调用次数进行比较。 |
| 每次调用 CPU 时间 | 每次调用的毫秒数时间。 |
关于 Traceview 的最后提醒是:目前 Traceview 禁止 JIT 编译器运行,这可能导致 Traceview 将时间误归到代码块,而 JIT 可能能够赢回这些时间。因此,在根据 Traceview 数据做出更改后,你一定要确保在未使用 Traceview 的情况下运行的实际代码能够更快。
Dmtracedump
Dmtracedump 是 traceview 的替代品。它允许你使用已经收集的追踪文件,以替代格式生成追踪数据,包括 HTML,以及一个调用堆栈图。后者是树状结构,树的每个节点代表堆栈中的一个调用。
您可以使用我们从设备中提取的相同 traceview 文件,并使用以下新命令:
dmtracedump –t 40 –g dmtrace.png /tmp/dmtrace.trace
要将你的追踪数据以 HTML 格式查看,请运行以下命令:
dmtracedump –h /tmp/dmtrace.trace > dmtrace.html
这个替代的 HTML 视图允许你以不同于原始 traceview GUI 的方式浏览追踪详情,并过滤每个调用的调用堆栈。
下表描述了您可以使用 dmtracedump 的额外命令行参数:
| 命令 | 描述 |
|---|---|
-d <trace-file-name> | 与此追踪文件进行比较,并打印差异。 |
-g <graph-out-file-name.png> | 在此文件中生成图表。从技术上来说,它可能不会生成 PNG 图片,但如果你将其命名为 something.png,你可以打开文件查看图表。 |
-h | 开启 HTML 输出。这将在你的控制台以 HTML 代码形式打印,因此记得将此输出重定向到一个文件,例如 example.html。 |
-o | 输出追踪文件,而不是进行性能分析。 |
-s <trace-file-name> | 可排序 JavaScript 文件的 URL 路径基础(我不确定这个参数的用途!code.google.com/p/android/issues/detail?id=53468)。 |
-t <percent> | 在图表中包含子节点的最小阈值(子节点的包含时间占父节点包含时间的百分比)。如果未使用此选项,则默认阈值为 20%。 |
微基准测试
基准测试是运行计算机程序或操作的行为,以便以产生定量结果的方式比较操作,通常是对它们进行一系列测试和试验。
基准测试可以组织为以下两大类:
-
宏基准测试
-
微基准测试
宏基准测试作为一种比较不同平台在特定领域(如处理器速度、每单位时间的浮点运算数量、图形和 3D 性能等)的手段而存在。它们通常用于硬件组件,但也可以用于测试软件特定领域,如编译器优化或算法。
与这些传统的宏观基准测试相对,微基准测试试图测量非常小段代码的性能,通常是一个单独的方法。获得的这些结果用于在选择提供相同功能的不同实现方案时决定优化路径。
这里的风险是,您可能测量到的微基准测试与您认为正在测量的内容不同。这是在使用 JIT 编译器的情况下主要需要考虑的问题,Android 从 2.2 Froyo 版本开始使用 JIT 编译器。JIT 编译器可能会以与应用程序中相同代码不同的方式编译和优化您的微基准测试。因此,在做出决定时要谨慎。
这与上一节引入的剖析策略不同,因为这种方法不考虑整个应用程序,而是一次只考虑一个方法或算法。
Caliper 微基准测试
Caliper是谷歌的开源框架,用于编写、运行和查看微基准测试的结果。在code.google.com/p/caliper它的网站上有很多示例和教程。
developer.android.com上推荐了 Caliper,谷歌用它来衡量 Android 编程语言本身的性能。我们在这里探讨其基本用法,并在下一章介绍更多与 Android 相关的使用方法。
它的核心思想是基准测试方法,主要是为了了解它们的效率如何。我们可能会决定这是我们优化的目标,或许是在通过 Traceview 分析应用程序的剖析结果之后。
Caliper 基准测试使用注解来帮助您正确构建测试。基准测试的结构与 JUnit 测试类似。以前,Caliper 在约定上模仿 JUnit3;例如,测试必须以test为前缀,基准测试以time为前缀。在最新版本中,它类似于 JUnit4,JUnit 有@Test,Caliper 使用@Benchmark。每个基准测试都接受一个 int 参数,通常命名为reps,表示对方法内部代码进行基准测试的重复次数,该代码由一个循环包围,计算重复次数。
存在setUp()方法或@Before注解,用作@BeforeExperiment。
基准测试温度转换器
让我们从在项目中创建一个新的 Java 模块开始。是的,这次不是一个 Android 模块——只是 Java。
为了保持一致性,使用com.blundell.tut包作为主包。
在/benchmark/build.gradle文件中,将此模块的依赖项添加到您核心模块上。这样您就可以访问温度转换器代码:
compile project(':core').
同时,将Caliper库作为一个依赖项添加;这是托管在 Maven 中央的。然而,在撰写这本书的时候,谷歌发布的版本是 Caliper 1.0-beta-1,它不包括我们刚刚讨论的注解。我已经尝试在code.google.com/p/caliper/issues/detail?id=291上戳他们解决这个问题,如果你觉得有倾向,可以给那个问题加星。因此,与此同时,另一个开发者已经将 Caliper 发布到 Maven 中央的他的包下,以便我们可以使用注解。这是你需要导入的:
compile 'net.trajano.caliper:caliper:1.1.1'
创建一个包含我们基准测试的TemperatureConverterBenchmark类:
public class TemperatureConverterBenchmark {
public static void main(String[] args) {
CaliperMain.main(CelsiusToFahrenheitBenchmark.class, args);
}
public static class CelsiusToFahrenheitBenchmark {
private static final double MULTIPLIER = 10;
@Param({"1", "10", "100"})
int total;
private List<Double> temperatures = new ArrayList<Double>();
@BeforeExperiment
public void setUp() {
temperatures.clear();
generateRandomTemperatures(total);
}
private void generateRandomTemperatures(int total) {
Random r = new Random(System.currentTimeMillis());
for (int i = 0; i < total; i++) {
double randomTemperature = MULTIPLIER * r.nextGaussian();
temperatures.add(randomTemperature);
}
}
@Benchmark
public void timeCelsiusToFahrenheit(int reps) {
for (int i = 0; i < reps; i++) {
for (double t : temperatures) {
TemperatureConverter.celsiusToFahrenheit(t);
}
}
}
}
}
我们有一个类似于 JUnit 测试的setUp()方法,它使用@BeforeExperiment注解。它在运行基准测试之前执行。这个方法初始化一个用于转换基准测试的随机温度集合。这个集合的大小是一个字段,在这里用@Param注解,以便 Caliper 知道它的存在。当我们运行基准测试时,Caliper 将允许我们提供这个参数的值。但是,对于这个例子,我们给 param 一些默认值"1", "10", "100"。这意味着我们将至少有三个基准测试,分别是一个、然后是 10 个、最后是 100 个温度值。
我们使用高斯分布来生成伪随机温度,因为这可以是用户现实情况的一个很好的模型。
基准测试方法本身使用@Benchmark注解,以便 Caliper 可以识别并运行这个方法,在这个timeCelsiusToFahrenheit()实例中。在这个方法内部,我们根据传递给我们的方法参数进行循环,每次调用TemperatureConverter.celsiusToFahrenheit()转换方法,这是我们希望进行基准测试的方法。
运行 Caliper。
要运行 Caliper,请右键点击该类,从菜单中选择并运行TemperatureConverterBenchmark.main()。如果你想改变总参数的默认值1, 10, 100,请编辑运行配置,在程序参数字段中输入–Dtotal=5,50,500。
无论哪种方式,这都会运行基准测试,如果一切顺利,我们将看到结果:
Experiment selection:
Instruments: [allocation, runtime]
User parameters: {total=[1, 10, 100]}
Virtual machines: [default]
Selection type: Full cartesian product
This selection yields 6 experiments.
Starting trial 1 of 6: {instrument=allocation, benchmarkMethod=timeCelsiusToFahrenheit, vm=default, parameters={total=1}}… Complete!
bytes(B): min=32.00, 1st qu.=32.00, median=32.00, mean=32.00, 3rd qu.=32.00, max=32.00
objects: min=1.00, 1st qu.=1.00, median=1.00, mean=1.00, 3rd qu.=1.00, max=1.00
….
Starting trial 6 of 6: {instrument=runtime, benchmarkMethod=timeCelsiusToFahrenheit, vm=default, parameters={total=100}}… Complete!
runtime(ns): min=158.09, 1st qu.=159.52, median=161.16, mean=162.42, 3rd qu.=163.06, max=175.13
Execution complete: 1.420m.
Collected 81 measurements from:
2 instrument(s)
2 virtual machine(s)
3 benchmark(s)
Results have been uploaded. View them at: https://microbenchmarks.appspot.com/runs/33dcd3fc-fde7-4a37-87d9-aa595b6c9224
为了帮助可视化这些结果,有一个托管在 Google AppEngine 上的服务(microbenchmarks.appspot.com),它接受你的结果数据,并让你以更好的方式可视化它。你可以在前面的输出中看到这个 URL,结果已经发布在那里。
如果你希望访问一组基准测试套件,或者随着时间的推移收集你的结果,你可以登录这个服务器并获得一个 API 密钥,以帮助汇总你的结果。一旦你获得了这个密钥,它应该被放在你主目录下的~/.caliper/config.properties文件中,下次你运行基准测试时,结果将与你登录的账户关联。
在您粘贴获得到的 API 密钥后,config.properties 文件将看起来像下面这段代码:
# Caliper config file
# Run with --print-config to see all of the options being applied
# INSTRUMENT CONFIG
# instrument.micro.options.warmup=10s
# instrument.micro.options.timingInterval=500ms
# instrument.micro.options.reportedIntervals=7
# instrument.micro.options.maxRuntime=10s
# VM CONFIG
vm.args=-Xmx3g -Xms3g
# See the Caliper webapp to get a key so you can associate results with your account
results.upload.options.key=abc123-a123-123a-b123-a12312312
结果将如下所示:
除了运行速度,生成的网站还会显示用于运行测试的 JVM 配置。蓝色和红色部分可以展开以查看更多属性,帮助您检测实际运行环境是否在影响报告的不同结果。
总结
在本章中,我们通过基准测试和代码剖析,剖析了可用于测试应用程序性能指标的可用替代方案。
在撰写本书时,Android SDK 应提供的某些选项是不可用的,而且由于 SDK 中隐藏了一些代码,无法实现 Android PerformanceTestCases。
在这些替代方案中,我们发现可以使用简单的日志声明或更复杂的扩展了插桩的代码。
随后,我们分析了剖析的替代方案,并描述和示例了 traceview 和 dmtracedump 的使用。
最后,您了解了 Caliper,这是一个支持 Android 原生的微基准测试工具。然而,我们仅介绍了其最基本的使用方法,并将更具体的 Android 和 Dalvik VM 使用方法留待下一章介绍。
为了在下一章中能够量化您的测试工作,我们将对我们的代码执行覆盖报告。我们还将介绍替代测试方法,并讨论 Android 测试领域的新兴库和主题,希望这能给您提供一些探索和继续您测试旅程的起点。
第九章:替代测试策略
到目前为止,我们已经分析了在项目中实施测试的最常见和最易获取的策略。然而,在我们的拼图中还缺少一些部分,我们希望在这最后一章中涵盖。随着 Android Studio 和 Gradle 的出现,Android 生态系统一直在向前发展。测试工具箱也总是在增加。在这一领域,我们将研究一些第三方库,它们可以帮助我们扩展测试框架;例如在 JVM 上为 Android 测试的 Robolectric,以及像 Fork 这样的潜在前沿和未来开发;想象一下为你的测试提供线程。
在本章中,我们将涵盖以下主题:
-
Jacoco 代码覆盖率
-
Robotium
-
在宿主 JVM 上进行测试
-
Robolectric
-
Fest
-
Spoon/Fork
代码覆盖率
或许 Android 的阿基里斯之踵就是文档的缺乏,以及为了找到完整版本的内容,你需要访问许多地方,或者更糟糕的是,在许多情况下,官方文档是错误的,或者没有更新以匹配当前版本。新 Gradle 构建系统的文档非常稀少,这正是大多数人尝试阅读代码覆盖率内容时的起点;那么,让我们点亮一些黑暗的角落。
代码覆盖率是软件测试中使用的一种度量,它描述了测试套件实际测试的源代码量,以及根据某些标准的程度。由于代码覆盖率直接检查代码,因此它是一种白盒测试形式。
注意
白盒测试(也称为透明盒测试、玻璃盒测试、结构测试),是一种测试软件的方法,它测试应用程序的内部结构或工作原理,而不是其功能(例如黑盒测试)。
在众多可用于提供 Java 代码覆盖率分析的工具体中,我们选择了 Jacoco,这是一个开源工具包,用于测量和报告 Java 代码覆盖率,并由 Android 项目提供支持。启动自己的项目使用它的基础设施已经就绪,因此,实现它所需的工作量最小化。Jacoco 取代了 EMMA 代码覆盖率工具,同时吸取了这一努力中学到的知识,并由同一团队构建。
Jacoco 通过独特的功能组合与其他工具区分开来;支持大规模企业软件开发,同时保持单个开发者的工作快速和迭代。这对于像 Android 这样规模的项目来说至关重要,Jacoco 在这方面表现得最为出色,为它提供了代码覆盖率。
Jacoco 特性
Java、Android Gradle 插件和 Gradle 构建系统,都对 Jacoco 有原生支持。从本书发布时最新的 Jacoco 版本来看,引用其文档,最独特的功能集如下:
-
Jacoco 可以在离线(加载之前)或动态(使用检测应用程序类加载器)为覆盖率对类进行检测。
-
支持的覆盖率类型:类、方法、行、分支和指令。Jacoco 可以检测到单个源代码行是否仅被部分覆盖。
-
覆盖率统计数据在方法、类、包和“所有类”级别进行汇总。
-
输出报告类型:纯文本、HTML、XML。所有报告类型都支持根据用户控制的详细深度进行下钻。HTML 报告支持源代码链接。
-
输出报告可以突出显示低于用户提供的阈值的覆盖率项。
-
在不同的检测或测试运行中获取的覆盖率数据可以合并在一起。
-
Jacoco 不需要访问源代码,并且随着输入类中可用的调试信息减少,其性能会优雅地降级。
-
Jacoco 相对较快;添加检测的开销很小(5 到 20%),字节码检测器本身非常快(主要受文件 I/O 速度限制)。每个 Java 类的内存开销是几百字节。
温度转换器代码覆盖率
Android Gradle 插件开箱即支持 Jacoco 代码覆盖率。设置涉及选择您想要获取覆盖率报告的构建版本,并选择您的 Jacoco 版本。我们希望对 debug 版本进行检测,这样可以在不影响发布代码的情况下获得覆盖率。
android {
…
buildTypes {
debug {
testCoverageEnabled true
}
}
jacoco {
version = '0.7.2.201409121644'
}
}
实际上,这里并不需要添加 Jacoco 的版本信息,但是目前随 Android 附带的 Jacoco 版本落后于最新版本。可以在他们的 GitHub 页面 github.com/jacoco/jacoco 或 Maven 中央仓库找到最新版本的 Jacoco 覆盖率库。因此,建议明确指定版本。
生成代码覆盖率分析报告
您需要让模拟器运行,因为 Jacoco 会检测您的 Android 测试,这些测试在设备上运行,因此使用模拟器是合适的。测试完成后,设备上会生成一个代码覆盖率报告,然后将其拉取到您的本地机器上。如果您选择使用真实设备而不是模拟器,那么设备需要获得 root 权限。否则,报告拉取会因 Permission Denied 异常而失败。
从命令行运行代码覆盖率如下:
$./gradlew build createDebugCoverageReport
如果您有多个构建版本,也可以使用以下命令:
$./gradlew build connectedCheck
以下信息验证了我们的测试已经运行,并且覆盖率数据已被检索:
:app:connectedAndroidTest
:app:createDebugCoverageReport
:app:connectedCheck
BUILD SUCCESSFUL
这将在 /app/build/outputs/reports/coverage/debug/ 目录中创建报告文件。如果您使用多个构建版本,您的路径会略有不同。
在我们继续之前,如果您还没有意识到,我们不仅为 Android 应用模块生成了报告,我们的 Java core 模块中也有代码。我们也为这个模块创建一个报告。
由于 Gradle 支持 Jacoco,我们只需将 Jacoco 插件应用到我们的code/build.gradle文件中:
apply plugin: 'jacoco''jacoco''jacoco''jacoco'''
使用与我们 Android 模块相同的闭包,可以进行更多配置。可以在 Gradle Jacoco 插件网站找到可更改属性的详细信息,网址为gradle.org/docs/current/userguide/jacoco_plugin.html。
现在,如果你运行./gradlew命令任务,你应该会看到一个新生成的 Gradle 任务,jacocoTestReport。运行此任务为我们的核心模块生成代码覆盖率:
$./gradlew jacocoTestReport
这已在/core/build/reports/jacoco/test/目录内创建了报告文件。
太棒了!现在我们既有app代码的覆盖率报告,也有core代码的覆盖率报告。
注意事项
可以将这两个报告合并为一个文件。你可能需要处理 XML 输出才能完成此操作。这留给读者作为一个任务,但可以在 Jacoco 网站和 Gradle 插件网站上寻找提示(这已经有人做过了)。
让我们打开app模块的index.html来显示覆盖率分析报告。
报告中呈现的信息包括覆盖率指标,这种方式允许以自上而下的方式深入数据,从所有类开始,一直到单个方法和源代码行(在 HTML 报告中)。
Jacoco 中代码覆盖率的基本组成部分是基本块;所有其他类型的覆盖率都是从基本块覆盖率派生出来的。行覆盖率主要用于链接到源代码。
此表描述了 Jacoco 覆盖率报告中的重要信息:
| 标签 | 描述 |
|---|---|
| 元素 | 类或包的名称。 |
| 未覆盖指令,覆盖率 | 一个视觉指示器,显示未通过测试覆盖的指令数量(红色显示),以及通过测试覆盖的指令百分比。例如:if(x = 1 && y = 2)将是两个指令但一行代码。 |
| 未覆盖分支,覆盖率 | 一个视觉指示器,显示未通过测试覆盖的分支数量(红色显示),以及覆盖的分支百分比。将 if/else 语句视为两个分支。一个方法中的分支数量是衡量其复杂度的一个好指标。 |
| 未覆盖,Cxty | 未覆盖的复杂路径(循环复杂度)数量,以及总的复杂度。一个复杂路径定义为一系列字节码指令,其中不包含任何跳转或跳转目标。在代码中添加一个分支(如一个if语句)将增加两个路径(真或假),因此复杂度会增加 1。然而,添加一个指令(如x = 1;)不会增加复杂度。 |
| 未覆盖,行数 | 任何测试未执行的行数,以及总行数。 |
| 未覆盖,方法 | 未覆盖的方法数量,以及总方法数量。这是一个由给定数量的基本路径组成的基本 Java 方法。 |
| 未覆盖,类 | 没有进行任何测试的类数量,以及总类数量。 |
我们可以从包深入到类,再到具体的方法,覆盖的行以绿色显示,未覆盖的行以红色显示,而部分覆盖的行以黄色显示。
这是对core/ TemperatureConverter类的报告示例:
在这份报告中,我们可以看到类TemperatureConverter的测试覆盖率并不是 100%。当我们查看代码时,发现是构造函数从未被测试过。
你知道为什么吗?请思考一下。
是的,因为私有构造函数从未被调用。这是一个不应该被实例化的工具类。
如果你能够想象创建一个只有一个静态方法的类,你通常不会创建私有构造函数;它会保留为不可见的默认公共构造函数。在这种情况下,我相当勤勉地编写了这个私有构造函数,因为当时我是一个好童子军(现在仍然是!)。
我们可以看到,这种分析不仅帮助我们测试代码和查找潜在的 bug,还可以改进设计。
一旦我们认为这个私有构造函数是一段不需要运行测试的合理代码,现在我们可以看到,尽管类还没有达到 100%的覆盖率,因此不是绿色的,但我们确信这个构造函数不会被其他任何类调用。
我认为这里的一个非常重要的教训是;100%的代码覆盖率不应该是你的目标。理解你的领域和应用程序的架构,可以让你对代码覆盖率的估计更加可达和现实。
-
让你有信心改变代码而不会产生副作用。
-
让你相信,你被要求交付的产品,是你已经创建的产品。
覆盖异常情况。
继续检查覆盖率报告,我们会发现另一个未被当前测试执行的代码块。这个问题出现在app/TemperatureConverterActivity中的以下 try-catch 块的最后一个 catch 中:
try {
double temp = Double.parseDouble(str);
double result = (option == Option.C2F)
? TemperatureConverter.celsiusToFahrenheit(temp)
: TemperatureConverter.fahrenheitToCelsius(temp);
String resultString = String.format("%.2f",",("%.("%."","," result);
destinationEditNumber.setNumber(result);
destinationEditNumber.setSelection(resultString.length());
} catch (NumberFormatException ignore) {
// WARNING this is generated whilst numbers are being entered,
// for example just a -''''''
// so we don'tdon'tdon'tdon't' want to show the error just yet
} catch (Exception e) {
sourceEditNumber.setError("ERROR: " + e.getLocalizedMessage());
}
首先,我们为什么要捕获通用的Exception?让我们将其更具体地针对我们期望处理的错误。这样我们就不会处理那些我们不期望的异常,而且如果有人阅读代码,他们会明确知道我们在这里试图做什么。
现在我们知道了导致我们测试覆盖率不全的代码,我们知道要编写哪些测试来抛出这个异常,并更新我们的测试套件和 Jacoco 报告。
} catch (InvalidTemperatureException e) {
sourceEditNumber.setError("ERROR: " + e.getLocalizedMessage());
}
我们应该提供一个测试,或者更好的是,对于每个温度单位都有一对测试,给定一个无效温度,验证错误是否显示。以下是 TemperatureConverterActivityTests 中的针对摄氏度情况的测试,你可以轻松地将其转换为提供华氏度情况:
public void testInvalidTemperatureInCelsius() throws Throwable {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
celsiusInput.requestFocus();
}
});
getInstrumentation().waitForIdleSync();
// invalid temp less than ABSOLUTE_ZERO_C
sendKeys("MINUS 3 8 0");");");");"
String msg = "Expected celsius input to contain an error.";.";.";.";"
assertNotNull(msg, celsiusInput.getError());
}
我们请求对测试字段进行焦点定位。像之前一样,我们应该通过在 UI 线程上使用 Runnable 来实现这一点,否则我们将收到异常。
然后设置无效温度并获取错误信息以验证它不为空。再次运行端到端流程,我们可以证实现在该路径已覆盖,实现了我们预期的完全覆盖。
这是你应该遵循的迭代过程,尽可能将代码改为绿色。如前所述,只要代码行不是绿色的,只要你考虑过选项并且仍然自信在未测试的路径中改变其他代码,那么这是可以的。
介绍 Robotium
在众多新兴的机器人生态中,Robotium (robotium.org) 是一个旨在简化测试编写的测试框架,它要求对被测应用有最少的了解。Robotium 主要定位于为 Android 应用编写强大且健壮的自动黑盒测试用例。它可以覆盖功能测试、系统测试和验收测试场景,甚至可以自动跨越同一应用中的多个 Android 活动。Robotium 也可以用来测试我们没有源代码的应用,甚至是预装的应用。
让我们开始使用 Robotium 为 TemperatureConverter 创建一些新的测试。为了使我们的测试更有条理,我们在 TemperatureConverter 项目的 androidTest 目录下创建了一个名为 com.blundell.tut.robotium 的新包。我们最初将测试 TemperatureConverterActivity,将其命名为 TemperatureConverterActivityTests 是合理的,尽管我们在另一个包中已经有了一个同名且也扩展了 ActivityInstrumentationTestCase2 的类。毕竟,这个类也将包含对此同一 Activity 的测试。
添加 Robotium
让我们在项目中添加 Robotium,我们只会在测试用例中使用它,所以它应该放在 testcase 类路径上。在撰写本文时,Robotium 的最新版本是 5.2.1。在 app/build.gradle 中,我们添加以下内容:
dependencies {
...
androidTestCompile('com.jayway.android.robotium:robotium-solo:5.2.1')
}
创建测试用例
从前面的章节中我们知道,如果我们正在为需要连接到系统基础设施的 Activity 创建测试用例,我们应该基于 ActivityInstrumentationTestCase2,这也是我们将要做的。
测试 Fahrenheit 到 Celsius 转换的 testFahrenheitToCelsiusConversion()
大多数测试用例的结构与其他基于 Instrumentation 的测试类似。主要区别在于我们需要在测试的 setUp() 中实例化 Robotium 的 Solo,并在 tearDown() 中清理 Robotium:
public class TemperatureConverterActivityTests extends
ActivityInstrumentationTestCase2<TemperatureConverterActivity> {
private TemperatureConverterActivity activity;
private Solo solo;
public TemperatureConverterActivityTests() {
super(TemperatureConverterActivity.class);
}
@Override
protected void setUp() throws Exception {
super.setUp();
activity = getActivity();
solo = new Solo(getInstrumentation(), activity);
}
@Override
protected void tearDown() throws Exception {
solo.finishOpenedActivities();
super.tearDown();
}
}
要实例化 Solo,我们必须传递对 Instrumentation 类和被测 Activity 的引用。
另一方面,为了清理 Solo,我们应该调用 finishOpenedActivities() 方法。这将结束 Solo 并完成我们的 Activity,然后我们调用 super.tearDown()。
Solo 提供了多种方法来驱动 UI 测试和一些断言。让我们从重新实现之前使用传统方法实现的 testFahrenheitToCelsiusConversion() 测试方法开始,但这次使用 Solo 的设施:
public void testFahrenheitToCelsiusConversion() {
solo.clearEditText(CELSIUS_INPUT);
solo.clearEditText(FAHRENHEIT_INPUT);
solo.clickOnEditText(FAHRENHEIT_INPUT);
solo.enterText(FAHRENHEIT_INPUT, "32.5");
solo.clickOnEditText(CELSIUS_INPUT);
double f = 32.5;
double expectedC = TemperatureConverter.fahrenheitToCelsius(f);
double actualC =
((EditNumber) solo.getEditText(CELSIUS_INPUT)).getNumber();
double delta = Math.abs(expectedC - actualC);
String msg = f + "F -> " + expectedC + "C "
+ """""but was " + actualC + "C (delta " + delta + ")";
assertTrue(msg, delta < 0.005);
}
这非常相似,然而,你可能注意到的第一个不同之处是,在这种情况下,我们没有像之前在 setUp() 方法中那样获取 UI 元素的引用,使用 findViewById() 来定位视图。然而,我们正在使用 Solo 的最大优势之一,它使用某些标准为我们定位视图。在这种情况下,标准是 EditText 出现的顺序。solo.clearEditText(int index) 方法期望一个从 0 开始的屏幕位置的整数索引。因此,我们应该将这些常量添加到测试用例中,就像在我们的用户界面中,摄氏度字段在顶部,华氏度在下方:
private static final int CELSIUS = 0;
private static final int FAHRENHEIT = 1;
其他 Robotium 方法遵循相同的约定,我们在必要时提供这些常量。这个测试与 com.blundell.tut.TemperatureConverterActivityTest 中的测试非常相似,但你可能已经注意到有一个微妙的区别。Robotium 位于更高的层次,我们不必担心许多内部或实现细节;例如,在我们之前的测试中,我们调用 celciusInput.requestFocus() 来触发转换机制,而在这里,我们只需模拟用户的行为并发出 solo.clickOnEditText(CELSIUS)。
我们合理地简化了测试,但使用 Solo 的最大优势尚未到来。
在活动之间进行测试
由于 Robotium 位于更高层次,并且我们不处理实现细节,如果点击 Android 小部件时启动了新的 Activity,这并不是我们的问题;我们仅从 UI 的角度处理这种情况。
在这里,我将从理论上讨论一些功能。这尚未创建,留给用户作为进一步步骤,如果你愿意的话。
既然我们已经有一个工作的温度转换器,如果我们能让用户决定他们想要看到的小数位数,那就太好了。通过 Android 对话框让用户更改此选项听起来是一个明智的选择。
我们的目的是将小数位数偏好值更改为五位,并验证更改确实发生了。由于 Robotium 的高层次,这个测试在没有实际实现功能的情况下也是可读和可理解的。这是实现此功能的一种 BDD 方法的例子。
下面的代码片段展示了测试的细节:
public final void testClickOptionMenuSavesDecimalPreference() {
int decimalPlaces = 5;
String numberRegEx = "^[0-9]+$";
solo.sendKey(Solo.MENU);
solo.clickOnText("Preferences");
solo.clickOnText("Decimal places");
assertTrue(solo.searchText(numberRegEx));
solo.clearEditText(DECIMAL_PLACES);
assertFalse(solo.searchText(numberRegEx));
solo.enterText(DECIMAL_PLACES, Integer.toString(decimalPlaces));
solo.clickOnButton("OK");
solo.goBack();
solo.sendKey(Solo.MENU);
solo.clickOnText("Preferences");
solo.clickOnText("Decimal places");
assertTrue(solo.searchText(numberRegEx));
int editTextDecimalPlaces =
Integer.parseInt(solo.getEditText(DECIMAL_PLACES)
.getText().toString());
assertEquals(decimalPlaces, editTextDecimalPlaces);
}
关于共享首选项和选项菜单如何实现,这里没有详细的介绍。我们只测试它的功能性。我们从按下菜单键并点击首选项开始。
哇,我们刚刚指定了菜单项标题,就完成了!
新的活动已经启动,但我们不必担心那个实现细节。我们继续点击 小数位数。
我们验证是否出现了一些包含数字的字段,即此首选项的前一个值。还记得我说的正则表达式吗?它们总会在某种程度上派上用场,来匹配任何十进制整数(任意数字后跟零个或多个数字)。然后,我们清除字段并验证它确实被清除了。
我们输入希望用作首选项的数字字符串,在这个例子中是 5。点击确定按钮,首选项即被保存。
最后,我们需要验证它实际上是否已经发生。使用相同的程序来获取菜单和字段。最后,我们验证实际的数字是否已经存在。
你可能会好奇 DECIMAL_PLACES 来自哪里。我们之前为屏幕上的字段定义了 CELSIUS 和 FAHRENHEIT 索引常量,这也是同样的情况,因为这将是我们类中应该定义的第三个 EditText。
private static final int DECIMAL_PLACES = 2;
测试可以根据你的喜好从 IDE 或命令行运行。
在宿主的 JVM 上进行测试
我们将这个主题留到了本章的末尾,因为这看起来像是 Android 平台的圣杯。
Android 基于一个名为 Dalvik 的虚拟机,以冰岛的一个村庄命名,该虚拟机针对的是资源有限的移动设备,如内存和处理速度有限。因此,它代表了移动设备,但与我们的内存丰富、速度快的宿主计算机环境肯定大不相同,后者通常拥有充足的内存和处理速度来享受。
通常,我们会在模拟器或设备上运行我们的应用程序和测试。这些目标有一个更慢的真实或模拟 CPU。因此,运行我们的测试是一个耗时的活动,特别是当我们的项目开始增长时。应用测试驱动开发技术迫使我们运行数百个测试来验证我们引入的每一项更改。
注意
值得注意的是,这项技术只能在开发过程中作为权宜之计来提高速度,绝不能替代在真实平台上的最终测试,因为 Dalvik 和 JavaSE 运行时之间的不兼容可能会影响测试的准确性。
我们已经完成了核心模块创建的一半工作。现在我们处于 Java 世界,可以在 JVM 上自由运行我们的测试(很快就可以在附近的 Android 上使用 JUnit4)。app Android 模块对核心 Java 模块有一个单向依赖。这使得我们可以摆脱 Android 测试的束缚,在 core 模块中运行测试时不受其拖累。
后来,我们应该找出一种方法,允许我们拦截在模拟器或设备上标准编译-dexing-运行的序列,并能够直接在我们的主机电脑上运行安卓。
比较性能提升。
提醒一下,与安卓仪器测试相比,运行这些仅 Java 的测试所获得的速度提升。
区别很明显。没有模拟器的启动,或者任何设备通信,因此速度的提升是重要的。通过分析证据,我们可以找出这些差异。
在我的开发电脑上运行所有测试需要 0.005 秒;有些测试耗时如此之少,以至于它们甚至没有被计算在内,显示为 0.000 秒。
如果我将这些测试移动到我们的应用模块,并在模拟器上运行相同的测试,这将使巨大的差异变得明显。这些相同的测试运行了 0.443 秒,几乎慢了 100 倍,如果你考虑到每天要运行数百次测试,这是一个巨大的差异。
还值得注意的是,除了速度提升之外,还有其他优势,包括多年来的 Java 工具、库和插件创建,包括多个模拟框架和代码分析工具。
将安卓纳入考虑范围
我们有意将安卓排除在考虑范围之外。让我们分析一下,如果在core中包含一个简单的安卓测试会发生什么。请记住,为了编译来自 SDK 的android.jar,安卓测试也应该被添加到模块的库中。
下面是我们得到的结果:
java.lang.RuntimeException: Stub!
at android.content.Context.<init>(Context.java:4)
at android.content.ContextWrapper.<init>(ContextWrapper.java:5)
at android.app.Application.<init>(Application.java:6)
注意
将android.jar添加到 core 的类路径稍微有些不协调且冗长。这不是默认会做的事情。这是一个好事,因为它阻止我们在编写核心代码时意外使用安卓特定的类。
原因在于android.jar只提供了 API,而不是实现。所有方法都有相同的实现:
throw new RuntimeException("Stub!");
如果我们想要绕过这个限制,以在安卓操作系统之外测试某些类,我们应该创建一个模拟每个类的android.jar。然而,我们也会发现对于安卓类的子类,如TemperatureConverterApplication,存在问题。这将是一项艰巨的任务,并且需要大量的工作,所以我们应该寻找另一个解决方案。
引入 Robolectric
Robolectric(robolectric.org)是一个单元测试框架,它拦截安卓类的加载并重写方法体。Robolectric 重新定义了安卓方法,使它们返回默认值,如null、0或false。如果可能,它会将方法调用转发给影子对象,模仿安卓行为。
提供了大量的阴影对象,但这远未完全覆盖,然而,它正在不断改进。这也应该让你将其视为一个不断发展的开源项目,你应当准备好为使其更好而贡献,但也应谨慎依赖它,因为你可能会发现你测试所需的功能尚未实现。这绝不是为了贬低它现有的前景。
安装 Robolectric
可以通过使用 Maven 中央仓库中的最新 Robolectric JAR 来安装 Robolectric。在撰写本文时,最新的可用版本是 2.4:
testCompile 'org.robolectric:robolectric:2.4'
通常,添加依赖项只需这一行代码,但是,对于 Robolectric 来说,需要一些小技巧才能使其与 Gradle 构建类型一起工作。
首先,Robolectric 测试需要在其自己的模块中运行。这并不是什么新鲜事。创建一个新的 Java 模块,我们称之为 robolectric-tests。包名保持不变,始终为 com.blundell.tut。现在,我们必须修改 robolectric-tests/build.gradle,以便我们可以用 Robolectric 替换 android.jar:
def androidModuleName = ":app";
def flavor = "debug"
evaluationDependsOn(androidModuleName)
apply plugin: 'java'
dependencies {
def androidModule = project(androidModuleName)
testCompile project(path: androidModuleName,
configuration: "${flavor}Compile")
def debugVariant = androidModule.android.applicationVariants
.find({ it.name == flavor })
testCompile debugVariant.javaCompile.classpath
testCompile debugVariant.javaCompile.outputs.files
testCompile files(
androidModule.plugins.findPlugin("com.android.application")
.getBootClasspath())
testCompile 'junit:junit:4.12'
testCompile 'org.robolectric:robolectric:2.4'
}
这是一大块需要理解的配置,让我们将其分解成步骤。
首先,我们为我们的 Android 应用定义模块名称,然后命名我们将要测试的版本。
EvaluationDependsOn 类告诉 Gradle 在执行我们的测试之前确保我们的应用程序模块被评估,这避免了因执行顺序的怪癖而出现任何奇怪的错误。
接下来,我们按照 Java 项目的正常约定应用 java 插件。
依赖项闭包是我们将所有 Android 依赖项添加到我们的类路径中的地方。首先,我们添加模块的选定构建变体 debug,然后是类路径及其依赖项,同时确保我们有来自 Android 插件的系统依赖项。
最后,我们将 JUnit4 和 Robolectric 作为测试依赖项应用。
注意
请记住,如果你有多个产品版本和构建类型,那么这个配置需要将完整的构建变体添加到脚本中。修改这个构建脚本将会非常简单直接。
添加资源
当你运行测试时,Robolectric 尝试查找你的 AndroidManifest.xml 以便它能为你的应用程序找到资源,并了解你的目标 SDK 版本等其他属性。由于当前 Robolectric 版本和我们对单独模块的选择,Robolectric 无法找到你的资源或你的 Android 清单。即使没有这一可选步骤,你仍然可以编写测试并获得反馈,但当你访问使用资源的类时,可能会遇到一些奇怪的现象;例如,R.string.hello_world, 在你的控制台可能会看到这样的信息:
WARNING: No manifest file found at ./AndroidManifest.xml.Falling back to the Android OS resources only. To remove this warning, annotate your test class with @Config(manifest=Config.NONE).
通过使用@Config注解按其说明操作,或者创建一个自定义测试运行器指定清单位置,或者像我们在这里选择的做法一样,创建一个配置文件并将其添加到你的类路径中,可以解决这个问题。在robolectric-tests模块内,创建文件夹/src/test/resources,并创建一个文件org.robolectric.Config.properties。这将包含我们的 Android 清单位置;它还将包含我们的最小 SDK 版本,因为我们在清单中没有声明这一点。它将包含以下内容:
manifest=../app/src/main/AndroidManifest.xml
emulateSdk = 16
注意
Robolectric 尝试在AndroidManifest.xml中查找你的最小 SDK。但是,在使用 Gradle 构建系统时,你不在那里声明,而是在app/build.gradle中声明。
我们现在已经设置好并准备编写一些 Robolectric 测试!
编写一些测试
我们将通过复现之前编写的一些测试来熟悉 Robolectric。一个好的例子是重写EditNumber测试。让我们在新建的项目中创建一个新的EditNumberTests类,并从TemperatureConverterTest项目中的EditNumberTests类复制测试:
@RunWith(RobolectricTestRunner.class)
public class EditNumberTests {
private static final double DELTA = 0.00001d;
private EditNumber editNumber;
@RunWith annotation. Then we defined the editNumber field, to hold the reference to the EditNumber class:
@Before
public void setUp() throws Exception {
editNumber = new EditNumber(Robolectric.application);
editNumber.setFocusable(true);
}
comprises the usual setup() method. In the setUp() method, we created an EditNumber with an application context, and then we set it as focusable. The context is used to create the view, and Robolectric handles this for us:
@Test
public final void testClear() {
String value = "123.45";";";";"
editNumber.setText(value);
editNumber.clear();
assertEquals("", editNumber.getText().toString());
}
@Test
public final void testSetNumber() {
editNumber.setNumber(123.45);
assertEquals("123.45", editNumber.getText().toString());
}
@Test
public final void testGetNumber() {
editNumber.setNumber(123.45);
assertEquals(123.45, editNumber.getNumber(), DELTA);
}
EditNumber tests of our previous examples.
我们强调最重要的变化。第一个是指定测试运行器 JUnit 的注解@RunWith,这将委托测试处理。在这种情况下,我们需要使用RobolectricTestRunner.class作为运行器。然后我们使用 Robolectric 上下文创建一个EditText类,因为这是一个没有帮助就无法实例化的类。最后,在testGetNumber中指定了DELTA值,因为 JUnit 4 中的浮点数需要它。此外,我们添加了@Test注解以将方法标记为测试。
原始EditNumberTests中存在的其他测试方法无法实现,或者由于各种原因简单地失败。例如,正如我们之前提到的,Robolectric 类返回默认值,如null、0、false等,Editable.Factory.getInstance()也是如此,它返回 null 并导致测试失败;因为没有其他创建Editable对象的方法,我们陷入了僵局。
同样,EditNumber设置的InputFilter是非功能的。创建一个期望某些行为的测试是徒劳的。
这些缺点的替代方法是创建Shadow类,但这需要修改 Robolectric 源代码并创建Robolectric.shadowOf()方法。如果你有兴趣将这种方法应用于你的测试,可以遵循文档中描述的此过程。
识别出这些问题后,我们可以继续运行测试,它们将在宿主的 JVM 中运行,无需启动或与模拟器或设备通信。
谷歌对阴影的进军
由于某种原因,谷歌并不喜欢 Robolectric,他们从未承认它有效,也从未说过它是解决问题的方案。如果他们忽视这个解决方案,那么意味着缓慢运行的测试问题不存在,对吧。他们似乎觉得 Robolectric 有损于 Android,因此在某种程度上公开地对其冷淡。通过忽略其存在来暗中排挤它,直到现在。
谷歌创建了一个与我们之前所说的完全一样的 android.jar 文件,其中包含了默认方法实现。这意味着在访问方法时不再出现 stub! 错误。此外,他们已经从所有类中移除了 final 修饰符,让模拟框架有了很大的发挥空间。不幸的是,在撰写本文时,这个功能尚未记录在案。不足为奇!我不想给出使用步骤,因为虽然未记录在案,但这些内容会迅速变化。然而,我要说的是,如果谷歌做对了这件事,那么对于之前描述的测试场景,Robolectric 就不再需要,我们可以使用标准的 Android 测试 SDK。同样的原则将适用,因此我认为如果你了解 Robolectric 的工作原理,这仍然是有价值的。你可以将这种理解应用到未来,而我不能。
引入 Fest
我们测试武器库中的另一个工具是更好的测试断言。你有没有注意到,有时失败的测试堆栈跟踪非常不友好和/或神秘地错误?它们没有提供关于实际失败的有用信息,你最终会感到困惑,不得不阅读整个源代码以弄清楚如何解决问题。
以这个断言为例:
org.junit.Assert.assertEquals(3, myList.size());
我们断言在执行某个任务后,对象集合的大小为三个,看看测试失败时的错误信息:
java.lang.AssertionError:
Expected :3
Actual :2
好吧,这有点道理,但有点抽象。我们的列表中缺少哪一项?我得重新运行测试才能找出答案,或者我可以添加一个自定义错误信息:
assertEquals("List not the correct size " + myList,
3, myList.size());
给我这样的错误信息:
java.lang.AssertionError: List not the correct size [A, B]
Expected :3
Actual :2
这个错误信息好多了。它显示我的列表中缺少了 C。但是,回顾一下这个断言,它的可读性越来越差。有时,一眼看去,我可能甚至会觉得我在尝试断言初始字符串是否等于其他变量,参数的顺序根本没有帮助。如果我有一个不容易实现 toString 的对象呢?我需要编写更多自定义代码来打印一个友好的错误信息,可能还会重复很多样板错误信息。
现在立刻停止所有担忧!看看我们如何使用 Fest 进行同样的断言:
org.fest.assertions.api.assertThat(myList).hasSize(3);
现在,我们的错误信息看起来像这样:
java.lang.AssertionError: expected size:<3> but was:<2> in:<['A', 'B'']>
很棒,我们没有额外付出努力,就得到了一个错误信息,它向我们展示了列表中的内容以及大小是如何区分的。回顾一下这个断言,它的可读性更强了,而且使用流畅的接口编码也变得更容易。这提高了我们测试代码的可读性,加快了调试和测试修复的速度。
在进行这个更改之后,我意识到我们实际上可能想要测试列表的内容,但由于 JUnit 断言的负担,我们没有这么做。再次,Fest 来拯救:
assertThat(myList).contains("A", ""B", ""C");
output:
java.lang.AssertionError: expecting:
<['A',]>
to contain:
<['A', 'B', 'C']>
but could not find:
<['C']>
想想如果我们用 JUnit 断言来做这件事,你就会更加欣赏 Fest 的强大。
Fest 提供了多种断言风格,适用于不同的库;这些包括前面展示的 Java 风格,以及允许你对 Android 组件(如视图和片段)进行流畅式界面断言的 Android 风格。以下是 JUnit 断言可见性的示例,然后是 Fest:
assertEquals(View.VISIBLE, layout.getVisibility());
assertThat(layout).isVisible();
将这些库添加到你的项目中,只需再添加一个 Gradle 依赖项,最新版本可以在 Maven 中央仓库找到。以下是 Java 版 Fest 的示例,截至撰写本文时的最新版本:
testCompile 'org'.easytesting:fest-assert-core:2.0M10'
注意
注意,Android Fest 已经更名为 Assert-J,并根据你的测试需求拆分为多个依赖项。断言的工作方式将完全相同。更多信息以及作为依赖项添加的说明可以在 github.com/square/assertj-android 找到。
介绍 Spoon
设备碎片化一直是 Android 的话题,这是你应该考虑的问题。不同设备和外形尺寸的数量意味着你真的需要确信你的应用程序在所有上述设备上都能良好运行。Spoon 正是为了帮助解决这个问题;Spoon (square.github.io/spoon) 是一个开源项目,提供了一个测试运行器,允许在所有连接的设备上并行运行仪器测试。它还允许你在测试运行时截图。这不仅加快了你的测试和反馈周期,还可能让你直观地看到测试出错的地方。
你可以通过添加以下依赖关系将 Spoon 加入到你的项目中:
testCompile com.squareup.spoon:spoon-client:1.1.2
然后,你可以在测试中截图,这样在断言行为的同时,你也可以看到应用程序的状态:
Spoon.screenshot(activity, "max_celcius_to_fahrenheit");
如果你在断言之前立即截图,可以利用这些截图帮助你确定失败的原因。另一个很酷的功能是,Spoon 会将一个测试中的所有截图合成为一张动画 GIF,这样你可以观看事件的发生顺序。
然后,你可以从命令行运行 Spoon,使用以下命令:
$java -jar spoon-runner-1.1.2-jar-with-dependencies.jar \
--apk androidApplicationTestGuide.apk \
--test-apk androidApplicationTestGuideTests.apk
注意
你可以在 /build/ 文件夹中找到你的 APK 文件。如果你需要更多关于这种使用 APK 文件的方式以及从命令行测试的信息,请回顾 第七章,行为驱动开发。
介绍 Fork
另一个带有幽默感的库名称,但读者请继续看下去,这种相似性并非巧合。在告诉了你 Spoon 如何通过在所有连接的设备上并行运行所有仪器测试来加速你的测试之后,现在来了 Fork,它告诉你这种天真的调度(他们的话,非我的)对你和你的 CI 来说是个负担。Fork 能让你更快地运行测试!
Fork 通过引入一个名为设备池的概念来提高你的测试速度。简单来说,想象一下你有两个完全相同的设备,都是运行 Android 5.0 的两台索尼 Xperia Z1。Fork 将获取你的测试套件并将其一分为二,在每个设备上运行一半的测试。因此,它能为你节省大约 50%的测试运行速度(大致不包括热身/设置时间)。
这些设备池有不同的类型,例如 api 级别、最小宽度、平板设备或手动池,你可以在其中声明你想使用的设备序列号。关于设备池和 fork 任务的定制参数的更多信息可以在goo.gl/cIm6GQ找到。
通过向你的构建脚本中添加插件并应用它,Fork 可以与 Gradle 一起使用:
buildscript {
dependencies {
classpath 'com'.shazam.fork:fork-gradle-plugin:0.10.0'
}
}
apply plugin: 'fork'
现在,你可以使用以下命令运行 fork 测试,而不是你正常的仪器测试:
./gradlew fork
注意
如果你的项目中有多个 flavor,你可以使用以下命令查看可用的 fork 任务:./gradlew tasks | grep fork。
Spoon 和 Fork 是强大的工具,现在结合你对仪器测试、单元测试、基准测试和代码分析的知识,你可以构建一个健壮、信息丰富且全面的测试套件,在编写 Android 应用程序时,这能给你信心和灵活性。
总结
这一章比之前的章节要深入一些,唯一目的是面对现实情况和最先进的 Android 测试技术。
我们首先通过 Jacoco 启用代码覆盖率,运行我们的测试,并获得详细的代码覆盖率分析报告。
然后我们使用这个报告来改进我们的测试套件。编写测试来覆盖我们之前没有意识到的未测试的代码。这使我们得到了更好的测试,有时也改进了被测试项目的设计。
我们引入了 Robotium,这是一个非常有用的工具,可以简化我们 Android 应用程序测试用例的创建,并且我们用它改进了一些测试。
然后我们分析了 Android 测试中一个热门话题;在开发主机 JVM 上进行测试,优化并显著减少运行测试所需的时间。当我们把测试驱动开发应用到我们的流程中时,这是非常可取的。在这个范围内,我们分析了 Robolectric,并创建了一些测试作为演示,让你开始掌握这些技术。
为了完善我们的知识,我们了解了 Fest 和一些餐具,它们可以帮助我们进行更有表现力的测试,改进反馈,并使整个测试套件更加强大。
我们已经到达了通过 Android 测试的可用的方法和工具的旅程的终点。你现在应该为将这些应用到您自己的项目中做好更充分的准备。一旦开始使用它们,效果就会立即显现。
最后,我希望您阅读这本书的乐趣与我写作它时一样多。
祝测试愉快!