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

119 阅读1小时+

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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

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

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

在本章中,我们将涵盖:

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

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

  • 如何运行 AVD

  • 如何创建无头模拟器

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

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

  • 使用 HAXM 加速你的 AVD

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

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

创建安卓虚拟设备

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

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

注意

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

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

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

创建安卓虚拟设备

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

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

  • RAM 大小/SD 卡大小

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

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

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

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

从命令行运行 AVD

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

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

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

无界面模拟器

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

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

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

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

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

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

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

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

$ adb devices
List of devices attached
emulator-5580  device

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

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

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

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

禁用键盘锁

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

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

要解锁屏幕,你可以使用以下命令:

$ adb -s emulator-5580 emu event send EV_KEY:KEY_MENU:1 EV_KEY:KEY_MENU:0

锁屏也可以通过编程禁用。在仪器测试类中,你应当在 setup() 中添加以下代码,很可能是在此函数中:

 @Override
 public void setUp() throws Exception {
   Activity activity = getActivity();
   Window window = activity.getWindow();
   window.addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
 }

这将为这些测试解除键盘锁,并且具有不需要任何额外安全权限或更改测试应用(已弃用的替代方法需要,见developer.android.com/reference/android/app/KeyguardManager.html)的优点。

清理

在某些情况下,你还需要清理在运行测试后启动的服务和进程。这防止后者的测试结果受到之前测试结束条件的影响。在这些情况下,最好从已知条件开始,释放所有已使用的内存,停止服务,重新加载资源,并重新启动进程,这可以通过热启动模拟器来实现:

$ adb -s emulator-5580 shell 'stop'; sleep 5; start'

这条命令行为我们打开模拟器的 shell,并运行停止和启动命令,正如人们所说,是将其关闭再重新打开。

这些命令的输出可以通过使用 logcat 命令来监控:

$ adb -s emulator-5580 logcat

你将看到如下信息:

D/AndroidRuntime(1):
D/AndroidRuntime(1): >>>>>>>>>> AndroidRuntime START <<<<<<<<<<
D/AndroidRuntime(1): CheckJNI is ON
D/AndroidRuntime(1): --- registering native functions ---
I/SamplingProfilerIntegration(1): Profiler is disabled.
I/Zygote  (1): Preloading classes...
I/ServiceManager(2): service 'connectivity''connectivity''connectivity''' died
I/ServiceManager(2): service 'throttle''throttle''throttle''' died
I/ServiceManager(2): service 'accessibility''accessibility''accessibility''' died

终止模拟器

当我们完成一个无头模拟器实例的工作后,我们开始使用之前提到的命令。我们使用以下命令行来杀死它:

$ adb -s emulator-5580 emu kill

这将阻止模拟器在主机计算机上释放已使用的资源并终止模拟器进程。

额外的模拟器配置

有时,我们需要测试的内容超出了创建或配置 AVD 时可以设置的选项范围。

其中一个情况可能是需要测试我们的应用程序在不同的地区设置下的表现。假设我们想要在设置为日语和日本的模拟器上测试我们的应用程序,就像是在日本手机上一样。

我们可以在模拟器命令行中传递这些属性。-prop 命令行选项允许我们设置可以在其中设置的任何属性:

$ emulator -avd testdevice -no-window -no-audio -no-boot-anim -port 5580   -prop persist.sys.language=ja -prop persist.sys.country=JP

为了验证我们的设置是否成功,我们可以使用 getprop 命令来验证它们,例如:

$ adb –s emulator-5580 shell "getprop persist.sys.language"
ja
$ adb –s emulator-5580 shell "getprop persist.sys.country"
JP

如果你想要在玩转持久设置后清除所有用户数据,你可以使用以下命令:

$ adb -s emulator-5580 emu kill
$ emulator -avd testdevice -no-window -no-audio -no-boot-anim -port 5580 -wipe-data

这之后,模拟器将会全新启动。

注意

更多关于设置模拟器硬件选项的可选属性信息,可以在developer.android.com/tools/devices/managing-avds-cmdline.html#hardwareopts找到。

模拟网络条件

在不同的网络条件下进行测试至关重要,但往往被忽视。这可能导致误解,认为应用程序因为使用了不同速度和延迟的主机网络而表现出不同的行为。

Android 模拟器支持网络限速,例如,支持更慢的网络速度和更高的连接延迟。在创建 AVD 时可以选择,也可以随时通过命令行使用-netspeed <speed>-netdelay <delay>选项在模拟器中进行设置。

支持的完整选项列表如下:

对于网络速度:

选项描述速度 [kbits/s]
-netspeed gsmGSM/CSD上传:14.4,下载:14.4
-netspeed hscsdHSCSD上传:14.4,下载:43.2
-netspeed gprsGPRS上传:40.0,下载:80.0
-netspeed edgeEDGE/EGPRS上传:118.4,下载:236.8
-netspeed umtsUMTS/3G上传:128.0,下载:1920.0
-netspeed hsdpaHSDPA上传:348.0,下载:14400.0
-netspeed full无限制上传:0.0,下载:0.0
-netspeed <num>选择上传和下载速度上传:如指定,下载:如指定
-netspeed <up>:<down>选择单独的上传和下载速度上传:指定速度,下载:指定速度

对于延迟:

选项描述延迟 [msec]
-netdelay gprsGPRS最小 150,最大 550
-netdelay edgeEDGE/EGPRS最小 80,最大 400
-netdelay umtsUMTS/3G最小 35,最大 200
-netdelay none无延迟最小 0,最大 0
-netdelay <num>选择确切的延迟延迟如指定
-netdelay <min>:<max>选择最小和最大延迟最小和最大延迟如指定

如果未指定值,模拟器将使用以下默认值:

  • 默认网络速度为无限制

  • 默认网络延迟为无延迟。

这是一个使用这些选项选择 GSM 网络速度 14.4 kbits/sec 和 GPRS 延迟 150 至 500 毫秒的模拟器示例:

$ emulator -avd testdevice -port 5580 -netspeed gsm -netdelay gprs

当模拟器运行时,你可以通过 Telnet 客户端内的 Android 控制台验证这些网络设置或交互式更改它们:

$ telnet localhost 5580
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Android Console: type 'help' for a list of commands
OK

连接之后,我们可以输入以下命令:

network status
Current network status:
 download speed:      14400 bits/s (1.8 KB/s)
 upload speed:        14400 bits/s (1.8 KB/s)
 minimum latency:  150 ms
 maximum latency:  550 ms
OK

你可以使用模拟器手动或自动测试使用网络服务的应用程序。

在某些情况下,这不仅涉及限制网络速度,还涉及更改 GPRS 连接的状态,以研究应用程序如何应对这些情况。要更改此状态,我们也可以在正在运行的模拟器中使用 Android 控制台。

例如,要从网络注销模拟器,我们可以使用:

$ telnet localhost 5580

在收到OK子提示后,我们可以通过发出以下命令将数据网络模式设置为未注册。这将关闭所有数据:

gsm data unregistered
OK
quit
Connection closed by foreign host.

在这种条件下测试应用程序后,你可以通过使用以下命令行再次连接它:

gsm data home
OK

要验证状态,你可以使用以下命令行:

gsm status
gsm voice state: home

gsm data state:  home

OK

使用 HAXM 加速你的 AVD

使用 Android 虚拟设备时,你会注意到它们并不是最响应灵敏的模拟器。这是因为 AVD 模拟器不支持硬件 GL,所以 GL 代码会被转换为 ARM 软件,并在由 QEMU(AVD 运行在顶层的托管虚拟机监控器)模拟的硬件上运行。Google 一直在解决这个问题,现在,高效使用宿主 GPU 正在提高速度(SDK 17)。在这个级别及以上的模拟器上,响应性已经得到了改善。

使用 Intel 的硬件加速执行管理器(HAXM)可以获得另一个速度提升。如果你的 AVD 运行 x86 架构,使用 HAXM 可以获得 5 到 10 倍的速度提升,因为它可以本地执行 CPU 命令。

HAXM 的工作原理是允许 CPU 命令在你的硬件上运行(即你的 Intel CPU),而在此之前,QEMU 会模拟 CPU,所有命令都是通过软件执行的,这就是原始架构为何笨拙的原因。

根据要求,你需要拥有支持 VT(虚拟化技术)的 Intel 处理器和一个基于 x86 的模拟器,最低 SDK 版本为 10(姜饼)。Intel 声称,从 2005 年开始的大多数 Intel 处理器都将支持 VT 卸载作为标准。

安装很简单;从 Android SDK 管理器的附加部分下载 HAXM,找到下载的文件,并按照安装程序说明操作。你可以通过从终端运行以下命令来确认安装成功:

kextstat | grep intel 

如果你收到包含com.intel.kext.intelhaxm的消息,说明你已经安装并可以运行你的快速 x86 模拟器了。你不需要做其他事情,只需确保你的 Android 模拟器的 CPU/ABI 是 x86,HAXM 就会在后台为你运行。

AVD 的替代方案

Android 虚拟设备并不是你运行 Android 应用的唯一方式。现在有一些解决方案可供选择。在 Google 上快速搜索可以找到这个列表(我不在这里写出来,因为它们可能会很快过时)。我个人推荐的一个是 GenyMotion 模拟器。这是一个使用 x86 架构虚拟化来提高效率的 Android 模拟器。它比 AVD 运行得更快更流畅。缺点是它仅对个人使用免费,并且截至撰写本文时,它并不能模拟设备所有的传感器,但我知道他们正在忙于解决这个问题。

运行 monkey

你可能听说过无限猴子定理。这个定理指出,一个猴子在打字机上随机按键无限次,最终会打出一段给定的文本,比如威廉·莎士比亚的完整作品。Android 版本的这个定理则是说,一个在设备上产生随机触摸的猴子可能会在远少于无限的时间里让你的应用崩溃。

Android 特性中包含一个猴子应用(goo.gl/LSWg85),它会生成随机事件,而不是使用真正的猴子。

对我们的应用程序运行猴子以生成随机事件的最简单方法是:

$ adb -e shell monkey -p com.blundell.tut -v -v 1000

你将会接收到以下输出:

Events injected: 1000
:Sending rotation degree=0, persist=false
:Dropped: keys=0 pointers=4 trackballs=0 flips=0 rotations=0
## Network stats: elapsed time=2577ms (0ms mobile, 0ms wifi, 2577ms not connected)
// Monkey finished

猴子将只向指定的包(-p)发送事件,在这种情况下是 com.blundell.tut,以非常详细的方式(-v -v)。发送的事件数量将是 1000。

客户端-服务器猴子

另外一种运行猴子命令的方法。它也提供了一个客户端-服务器模型,最终允许创建控制发送哪些事件的脚本,并不只依赖于随机生成。

通常,猴子使用的端口是 1080,但如果你更喜欢,可以使用其他端口:

$ adb -e shell monkey -p com.blundell.tut --port 1080 &

然后,我们需要重定向模拟器的端口:

$ adb -e forward tcp:1080 tcp:1080

现在,我们准备发送事件。要手动执行,我们可以使用 Telnet 客户端:

$ telnet localhost 1080

建立连接后,我们可以输入特定的猴子命令:

tap 150 200
OK

最后,退出 telnet 命令。

如果我们需要反复测试应用程序,创建一个包含我们想要发送的命令的脚本会方便得多。一个猴子脚本可能如下所示:

# monkey
tap 200 200
type HelloWorld
tap 200 350
tap 200 200
press DEL
press DEL
press DEL
press DEL
press DEL
type Monkey 
tap 200 350

注意

monkey tap 的 API 是 tap <x 像素位置> <y 像素位置>

因此,如果你运行的模拟器与记录猴子命令的分辨率不同,你可能会得到错误的触摸事件。

启动本章的示例应用后,我们可以运行这个脚本来测试用户界面。要启动应用,你可以使用模拟器窗口并点击其启动图标,或者使用命令行指定要启动的活动,如果模拟器是无头模式,这将是唯一的选择,如下所示:

$ adb shell am start -n com.blundell.tut/.MonkeyActivity

这在日志中由以下行通知:

Starting: Intent { cmp=com.blundell.tut/.MonkeyActivity}

应用程序启动后,你可以使用脚本和 netcat 实用工具发送事件:

$ nc localhost 1080 < ch_4_code_ex_10.txt

这会将脚本文件中的事件发送到模拟器。这些事件包括:

  1. 触摸并选择编辑文本输入。

  2. 输入 Hello World

  3. 点击按钮显示提示信息。

  4. 再次触摸并选择编辑文本。

  5. 删除其内容。

  6. 输入 Monkey

  7. 点击按钮显示Hello Monkey的提示信息。

这样,可以创建包含触摸事件和按键按下的简单脚本。

使用 monkeyrunner 进行测试脚本编写

Monkey 的能力相当有限,流程控制的缺失限制了其仅能用于非常简单的场景。为了绕过这些限制,创建了一个名为 monkeyrunner 的新项目。尽管如此,这个名字几乎相同,导致大量的混淆,因为它们之间没有任何关联。

Monkeyrunner,已包含在最新版本的 Android SDK 中,是一个提供 API 的工具,用于编写外部控制 Android 设备或模拟器的脚本。

Monkeyrunner 构建于 Jython 之上 (jython.org/),这是 Python 编程语言的一个版本 (python.org/),设计在 Java 平台上运行。

根据其文档,monkeyrunner 工具为 Android 测试提供了以下独特的功能。这些只是可以从 monkeyrunner 主页获取的完整功能列表、示例和参考文档的亮点 (developer.android.com/tools/help/monkeyrunner_concepts.html):

  • 多设备控制monkeyrunner API 可以在多个设备或模拟器上应用一个或多个测试套件。你可以物理连接所有设备或一次性启动所有模拟器(或两者兼有),然后以编程方式逐个连接到每个设备,并运行一个或多个测试。你也可以以编程方式启动模拟器配置,运行一个或多个测试,然后关闭模拟器。

  • 功能测试monkeyrunner可以运行一个 Android 应用的自动化从头到尾的测试。你提供通过按键或触摸事件输入的值,并以截图的形式查看结果。

  • 回归测试monkeyrunner可以通过运行应用并比较其输出截图与一组已知正确的截图来测试应用的稳定性。

  • 可扩展自动化:由于monkeyrunner是一个 API 工具包,你可以开发一整套基于 Python 的模块和程序来控制 Android 设备。除了使用monkeyrunner API 本身,你还可以使用标准的 Python OS 和 subprocess 模块调用 Android 工具,如 Android 调试桥。你还可以向monkeyrunner API 添加自己的类。这在线文档的“使用插件扩展 monkeyrunner”部分有更详细的描述。

获取测试截图。

目前,monkeyrunner 最明显的用途之一是获取待测应用的截图以供进一步分析或比较。

可以通过以下步骤获取这些截图:

  1. 导入所需的模块。

  2. 与设备建立连接。

  3. 检查设备是否已连接。

  4. 启动活动。

  5. 为活动启动添加一些延迟。

  6. 输入'hello'。

  7. 添加一些延迟以允许事件被处理。

  8. 获取截图。

  9. 将其保存到文件中。

  10. 返回退出活动。

以下是执行上述步骤所需的脚本代码:

#! /usr/bin/env monkeyrunner

import sys

# Imports the monkeyrunner modules used by this program
from com.android.monkeyrunner import MonkeyRunner, MonkeyDevice, MonkeyImage
# Connects to the current device, returning a MonkeyDevice object
device = MonkeyRunner.waitForConnection()

if not device:
    print >> sys.stderr, "Couldn't" "get connection"
    sys.exit(1)

device.startActivity(component='com'.blundell.tut/.MonkeyActivity')

MonkeyRunner.sleep(3.0)

device.type("hello")

# Takes a screenshot
MonkeyRunner.sleep(3.0)
result = device.takeSnapshot()

# Writes the screenshot to a file
result.writeToFile('/tmp/device.png')

device.press('KEYCODE_BACK', 'DOWN'_AND_UP')

脚本运行后,你可以在/tmp/device.png找到活动的截图。

记录和回放

如果你需要更简单的方法,无需手动创建这些脚本。为了简化这个过程,Android 源代码仓库中的 SDK 项目中包含的monkey_recorder.py脚本可以用来记录事件描述,这些描述稍后会被另一个名为monkey_playback.py的脚本解释。

从命令行运行monkey_recorder.py,你将看到这个用户界面:

记录和回放

这个界面有一个工具栏,工具栏上有按钮可以在记录的脚本中插入不同的命令:

按钮名描述
等待这表示需要等待多少秒。这个数字是通过一个对话框请求的。
按下按钮这会发送菜单主页最近应用搜索按钮。按下向下向上事件。
输入内容这会发送一个字符串。
滑动这会在指定方向、距离和步数发送一个滑动事件。
导出动作这会保存脚本。
刷新显示这会刷新显示的截图副本。

完成脚本后,保存它,假设文件名为script.mr,然后你可以使用以下命令行重新运行它:

$ monkey_playback.py script.mr

现在,所有的事件都将被重新播放。

总结

在本章中,我们涵盖了所有将我们的应用程序及其测试暴露于广泛条件和配置的替代方案,从不同的屏幕尺寸,到设备(如相机或键盘)的可用性,再到模拟真实网络条件以检测我们应用程序中的问题。

我们还分析了所有可用的选项,以便能够在模拟器脱离窗口时远程控制它们。这为进行测试优先开发奠定了基础,我们将在第六章,实践测试驱动开发中回到这个话题。

我们讨论了 AVD 的速度,并看到了如何改进这一点,以及如何在 GenyMotion 和 HAXM 中查看模拟器选择。最后,介绍了一些脚本编写替代方案,并提供了一些入门示例。

在下一章中,我们将探索持续集成——一种依赖于自动运行所有测试套件以及配置、启动和停止模拟器以自动化完整构建过程的工作方式。

第五章:探索持续集成

持续集成是软件工程中的一种敏捷技术,旨在通过持续的应用集成和频繁的测试,而不是在开发周期结束时采用更传统的集成和测试方法,来提高软件质量并减少集成更改所需的时间。

持续集成已经得到了广泛的应用,商业工具和开源项目的激增是其成功的明显证明。这不难理解,因为任何在职业生涯中参与过使用传统方法的软件开发项目的人都可能经历过所谓的集成地狱,即集成更改所需的时间超过了做出更改的时间。这让你想起了什么?相反,持续集成是频繁且小步骤集成更改的做法。这些步骤是微不足道的,如果注意到错误,它如此之小以至于可以立即修复。最常见的做法是在每次提交到源代码仓库后触发构建过程。

这种实践还意味着除了源代码需要由版本控制系统(VCS)维护之外的其他要求:

  • 构建应当通过运行单个命令来自动化。这个特性已经被如makeant这样的工具支持了很长时间,并且最近也被mavengradle支持。

  • 构建应当自我测试,以确认新构建的软件符合开发者的预期。

  • 构建工件和测试结果应当易于查找和查看。

当我们为 Android 项目编写测试时,我们希望利用持续集成。为了实现这一点,我们想要创建一个与传统 IDE 环境和 Android 构建工具共存模型,这样无论在 CI 环境箱、IDE 还是在手动环境下,我们都能运行和安装我们的应用。

在本章中,我们将讨论以下内容:

  • 自动化构建过程

  • 引入版本控制系统到流程中

  • 使用 Jenkins 进行持续集成

  • 自动化测试

在本章之后,你将能够将持续集成应用到你的项目中,无论项目规模大小,无论是雇佣数十名开发人员的中大型软件项目,还是你一个人编程。

注意

关于持续集成的原始文章是由 Martin Fowler 在 2000 年撰写的(www.martinfowler.com/articles/continuousIntegration.html),描述了在一个大型软件项目上实施持续集成的经验。

使用 Gradle 手动构建 Android 应用程序

如果我们的目标是将在开发过程中整合持续集成,那么第一步将是手动构建 Android 应用程序,因为我们可以将集成机器与手动构建技术相结合来自动化这一过程。

通过这种方式,我们打算保持项目与 IDE 和命令行构建过程兼容,这就是我们将要做的。自动化构建是一个很大的优势,它通过立即构建并最终显示项目中可能存在的错误,从而加快开发过程。在编辑生成中间类的资源或其他文件时,CI 是一个无价的工具;否则,在构建过程中一些简单的错误可能会发现得太晚。遵循“经常失败,快速失败”的格言是推荐的做法。

幸运的是,Android 支持使用现有工具进行手动构建,并且在同一项目中合并手动 IDE 构建和自动 CI 构建并不需要太多努力。在这种情况下,支持在 IDE 中使用 Gradle 手动构建。然而,像 Ant 这样不再默认支持的选项,以及 Maven 或 Make 等不支持开箱即用的选项也存在。

注意

Gradle 是构建自动化的演进。Gradle 将 Ant 的强大和灵活性以及 Maven 的依赖管理和约定融合成更有效的构建方式。

更多信息可以在其主页上找到,gradle.org/

在撰写本文时,基于 Android Gradle 的项目至少需要 Gradle 2.2 或更新版本。

值得注意的是,整个 Android 开源项目并非由 Gradle 构建,而是由极其复杂的 make 文件结构构建,这种方法甚至用于构建平台中包含的应用程序,如计算器、联系人、设置等。

使用 Android Studio 创建新项目时,模板项目已经使用 Gradle 进行构建。这意味着你从命令行手动构建项目。在项目根目录执行 ./gradlew tasks 将提供可以运行的所有任务列表。最常用的任务如下表所示:

目标描述
build组装并测试此项目
clean删除构建目录
tasks显示可以从根项目 x 运行的任务(其中一些显示的任务可能属于子项目)
installDebug安装调试版本
installDebugTest为调试版本安装测试版本
connectedAndroidTest在连接的设备上为构建调试安装并运行测试
uninstallDebug卸载调试版本

前缀为 ./gradlew 的命令使用的是实际上包含在项目源代码中的 Gradle 安装。这称为 gradle 包装器。因此,你不需要在本地机器上安装 Gradle!但是,如果你在本地安装了 Gradle,所有使用包装器的命令都可以替换为 ./gradle。如果有多台设备或模拟器连接到构建机器,这些命令将在它们上面全部运行/安装。这对于我们的 CI 设置来说非常棒,意味着我们可以在所有提供的设备上运行我们的测试,以便处理多种配置和 Android 版本。如果你出于其他原因只想在其中一个上安装,通过设备提供商 API 是可以实现的,但这超出了本书的范围。我鼓励你在 tools.android.com 阅读更多内容,并查看广泛可用的 Gradle 插件,以帮助你完成这些工作。

现在我们可以运行这个命令来安装我们的应用程序:

$ ./gradlew installDebug

这是生成的输出开始和结束的部分:

Configuring > 3/3 projects
…
:app:assembleDebug 
:app:installDebug
Installing APK 'app'-debug.'apk' on 'emulator-5554'Installing APK 'app'-debug.'apk'on 'Samsung'Galaxy 'S4'
Installed on 2 devices.

BUILD SUCCESSFUL
Total time: 11.011 secs

运行前述提到的命令后,将执行以下步骤:

  • 编译源代码,包括资源、AIDL 和 Java 文件

  • 将编译的文件转换为原生的 Android 格式

  • 打包和签名

  • 安装到给定的设备或模拟器上

一旦我们安装了 APK,并且现在所有操作都从命令行进行,我们甚至可以启动如 EspressoActivity 的活动。使用 am start 命令和一个使用 MAIN 动作和我们感兴趣启动的活动作为组件的 Intent,我们可以创建如下命令行:

adb -s emulator-5554 shell am start -a android.intent.action.MAIN -n com.blundell.tut/.EspressoActivity

活动已经启动,你可以在模拟器中验证。现在要做的下一件事是安装我们应用程序的测试项目,然后使用命令行运行这些测试(如前几章所述)。最后,当它们完成后,我们应该卸载应用程序。如果你仔细阅读了命令列表,可能会注意到幸运的是,connectedAndroidTest Gradle 任务已经为我们完成了这些操作。

运行命令后,我们将获得测试结果。如果通过,输出如下所示:

:app:connectedAndroidTest
BUILD SUCCESSFUL
Total time: 9.812 secs

然而,如果它们失败了,输出将更加详细,并提供一个链接到文件,你可以查看完整的堆栈跟踪以及每个测试失败的原因:

:app:connectedAndroidTest
com.blundell.tut.ExampleEspressoTest > testClickingButtonShowsImage[emulator-5554]FAILED 
android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
 at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:6024)
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:connectedAndroidTest.
> There were failing tests. See the report at: file:///AndroidApplicationTestingGuide/app/build/outputs/reports/androidTests/connected/index.html
…
BUILD FAILED
Total time: 15.532 secs.

我们通过调用一些简单的命令从命令行完成了一切操作,这正是我们想要做的,以便将这个过程引入持续集成流程。

Git – 快速版本控制系统

Git 是一个免费且开源的分布式版本控制系统,旨在以速度和效率处理从小型到非常大型的各种项目。它非常容易设置,因此我强烈建议即使是个人项目也使用它。没有任何一个项目简单到无法从这个工具的应用中受益。你可以在 git-scm.com/ 找到信息和下载。

版本控制系统或版本控制系统(VCS)(也称为源代码管理或SCM)是涉及多个开发人员开发项目的不可避免元素,即使单人编码也是最佳实践。此外,尽管在没有 VCS 的情况下也可以应用持续集成(因为 VCS 不是 CI 的必要条件),但避免这样做并不是一个合理或推荐的做法。

在版本控制系统领域还有其他一些可能更传统的选择(见遗留系统),如 Subversion 或 CVS,如果你觉得更舒适,可以自由使用。否则,Git 被广泛应用于 Android 项目,托管谷歌自己的代码和示例,因此至少花时间了解基础知识是值得的。

说到这里,考虑到这是一个非常广泛的主题,足以证明其自身需要一本书(确实有一些好书关于这个主题),我们在这里讨论的是最基本的话题,并提供示例,以帮助那些还没有开始实践的人入门。

创建本地 Git 仓库

这些是最简单的命令,用于创建本地仓库并用我们项目的初始源代码填充它。在这个例子中,我们再次使用之前章节创建和使用的 AndroidApplicationTestingGuide 项目。我们复制了之前手动构建时使用的代码:

$ mkdir AndroidApplicationTestingGuide
$ cd AndroidApplicationTestingGuide
$ git init
$ cp -a <path/to/original>/AndroidApplicationTestingGuide/
$ gradlew clean
$ rm local.properties
$ git add .
$ git commit -m "Initial commit"

我们创建新的项目目录,初始化 Git 仓库,复制初始内容,清理并删除我们之前自动生成的文件,移除 local.properties 文件,将所有内容添加到仓库,并提交。

提示

local.properties 文件绝不能被检入版本控制系统,因为它包含特定于你本地配置的信息。你可能还想看看创建一个 .gitignore 文件。这个文件允许你定义哪些文件不被检入(例如自动生成的文件)。.gitignore 文件的示例可以在 github.com/github/gitignore 找到。

在这一点上,我们的项目仓库包含了我们应用程序的初始源代码以及所有的测试。我们没有改变结构,所以项目仍然与我们 IDE 和 Gradle 兼容,以便我们继续本地开发、构建和持续集成。

下一步是让我们的项目在我们每次提交源代码更改后自动构建和测试。

使用 Jenkins 进行持续集成

Jenkins 是一款开源、可扩展的持续集成服务器,具有构建和测试软件项目或监控外部作业执行的能力。Jenkins 安装和配置简单,因此被广泛使用。这使得它成为学习持续集成的理想示例。

安装和配置 Jenkins

我们提到 Jenkins 的优点之一是易于安装,而且安装过程确实简单。从 jenkins-ci.org/ 下载你选择操作系统的原生包。所有主要的服务器和桌面操作系统都有原生包。在以下示例中,我们将使用版本 1.592。下载 .war 文件后,我们将运行它,因为这不需要管理员权限。

完成后,将 war 文件复制到选择的目录 ~/jenkins,然后运行以下命令:

$ java -jar ~/jenkins/jenkins-1.592.war

这将展开并启动 Jenkins。

默认配置使用 8080 端口作为 HTTP 监听端口,因此将你的浏览器指向 http://localhost:8080 应该会显示 Jenkins 主页。如果需要,你可以通过访问 管理 Jenkins 屏幕来验证和更改 Jenkins 的操作参数。我们应该在此配置中添加所需的插件,以实现 Git 集成、使用 Gradle 构建、检查测试结果以及在构建过程中支持 Android 模拟器。这些插件分别是 Git 插件Gradle 插件JUnit 插件Android Emulator 插件

下面的屏幕截图展示了你可以通过 Jenkins 插件管理页面上的链接获取的插件信息:

安装和配置 Jenkins

安装并重新启动 Jenkins 后,这些插件就可以使用了。我们的下一步是创建构建项目所需的任务。

创建任务

让我们从 Jenkins 主页上的 新建项目 开始创建 AndroidApplicationTestingGuide 任务。以项目命名。可以创建不同类型的任务;在这种情况下,我们选择 自由风格项目,允许你将任何 SCM 连接到任何构建系统。

点击 确定 按钮后,你将看到具体的任务选项,如下表所述。这是任务属性页面顶部的如下内容:

创建任务

新建项目屏幕中的所有选项都有关联的帮助文本,因此这里我们只解释需要输入的部分:

选项描述
项目名称给项目赋予的名称。
描述可选描述。
丢弃旧构建这可以帮助你通过管理构建记录(如控制台输出、构建工件等)的保留时间来节省磁盘消耗。
此构建是参数化的这允许你配置传递给构建过程的参数,以创建参数化构建,例如,使用 $ANDROID_HOME 而不是硬编码路径。
源代码管理 也称为 VCS,项目的源代码在哪里?在这种情况下,我们使用 Git 和一个仓库,该仓库的 URL 是我们之前创建的仓库的绝对路径。例如,/git-repo/AndroidApplicationTestingGuide
构建触发器如何自动构建这个项目。在这种情况下,我们希望源代码的每次更改都触发自动构建,所以我们选择轮询 SCM。另一个选项是使用定时构建。这个特性主要用于将 Jenkins 作为cron的替代,对于持续构建软件项目来说并不理想。当人们第一次开始持续集成时,他们常常习惯于定期的构建,如每晚/每周,因此使用这个特性。然而,持续集成的要点是在做出更改后立即开始构建,以便为更改提供快速的反馈。这个选项可以用于长时间运行的构建,比如测试套件,可能在构建运行 1 小时时测试性能(将其配置为在午夜运行)。它还可以用于每晚或每周发布新版本。
计划这个字段遵循Cron的语法(有一些小的差异)。具体来说,每行由五个由制表符或空格分隔的字段组成:分钟 小时 天 月 星期几。例如,如果我们想在每个小时的 30 分钟进行持续轮询,指定为:30 * * * *。查看文档以获取所有选项的完整解释。
构建环境这个选项允许你为构建环境和可能在进行构建时运行的 Android 模拟器指定不同的选项。
构建这个选项描述了构建步骤。我们选择调用 Gradle 脚本,因为我们重现了之前手动构建和测试项目的步骤。我们将选择使用 Gradle 包装器,这样我们的项目就不依赖于内置在 Gradle 版本的 CI 机器。然后,在任务框中,我们希望输入 clean connectedAndroidTest
构建后操作这些是在构建完成后我们可以执行的一系列操作。我们希望保存 APK 文件,因此我们启用归档工件,然后定义它们的路径为要归档的文件;在这个具体的情况下,它是 **/*-debug.apk
保存保存我们刚刚做的更改并完成构建任务创建。

既然我们的持续集成(CI)构建已经设置好了,有以下两个选项:

  • 你可以使用立即构建来强制构建。

  • 或者对源代码进行一些更改,使用 Git 提交,并等待它们被我们的轮询策略检测到。

无论哪种方式,我们都能构建我们的项目,并准备好工件以供其他用途,例如依赖项目或质量保证(QA)。不幸的是,如果你运行了 CI 构建,它会因为未连接设备而彻底失败。你可以选择连接一个真实设备,或者使用我们刚刚安装的 Android 模拟器插件。我们使用插件。在 Jenkins 中,转到我们刚刚创建的任务并点击配置

选项描述
构建环境我们的目标是在模拟器上安装并运行测试。因此,对于我们的构建环境,我们使用 Android Emulator 插件 提供的设施。如果你希望在构建步骤执行之前自动启动你选择的 Android 模拟器,并在构建完成后停止模拟器,这将非常方便。你可以选择启动预定义的、现有的 Android 模拟器实例(AVD)。或者,插件可以自动在构建从机上创建一个新的模拟器,并在此处指定属性。在任何情况下,logcat 输出都将自动捕获并归档。选择 使用属性运行模拟器。然后,选择 4.4 作为 Android OS 版本320 DPI 作为 屏幕密度,以及 WQVGA 作为 屏幕分辨率。请随意实验并选择更适合你需求的选项。
常见模拟器选项我们希望在启动时重置模拟器状态以清除用户数据并禁用显示模拟器窗口,这样就不会显示模拟器窗口。

配置并构建此项目后,我们将在目标模拟器上安装 APK 并运行测试。

获取 Android 测试结果

测试运行后,结果将保存为 XML 文件,位于项目构建文件夹内的 /AndroidApplicationTestingGuides/app/build/outputs/androidTest-results/connected/

它们在那里对我们没有好处。如果我们能在 Jenkins 中读取我们的测试结果,并以漂亮的 HTML 格式显示它们,那就太好了;另一个 Jenkins 插件来拯救。JUnit 插件启用了一个构建后操作,询问你的 JUnit 报告存储在哪里,并将它们检索出来,以便在 Jenkins 的项目屏幕上轻松查看测试结果。在这种情况下,我们在作业配置页面也使用了构建后操作。 |

完成之前描述的所有步骤后,只剩下强制构建以查看结果。选项描述
发布 JUnit 测试结果报告当配置了此选项时,Jenkins 上的 JUnit 插件可以提供有关测试结果的实用信息,如历史测试结果趋势、用于查看测试报告的 Web UI、跟踪失败等。它需要一个正则表达式来查找 JUnit 结果文件。我建议使用 **/TEST*.xml。这个正则表达式应该匹配所有的 JUnit 测试结果,包括 Android 连接测试的结果;这里的研究赞誉归功于 Adam Brown。如果你更改了正则表达式,确保不要将任何非报告文件包含在此模式中。运行几个带有测试结果的构建后,你应该会开始看到一些趋势图表,显示测试的发展演变。

点击 立即构建,几分钟后,你将看到你的测试结果和统计数据以类似以下截图的方式显示:

获取 Android 测试结果

从这里,我们可以轻松了解项目状态。点击最新测试结果,你可以看到有多少测试失败以及原因。你可以搜索失败的测试,还可以找到详尽的错误信息堆栈跟踪选项。

通过评估不同的趋势来了解项目的演变也确实很有帮助,而 Jenkins 能够提供这类信息。每个项目都使用类似天气的图标展示当前趋势,从阳光明媚(项目健康度提高 80%)到雷暴(健康度低于 20%)。此外,对于每个项目,测试成功与失败比例的趋势演变也会在图表中显示。下面是失败的测试图表:

获取 Android 测试结果

在这个例子中,我们可以看到在构建 9 时,有四个测试失败了,其中三个在构建 10 中修复,最后一个在构建 11 中修复。

为了看到项目状态如何通过强制失败而改变,让我们添加一个如下所示的失败测试。别忘了推送你的提交,以触发 CI 构建,如下所示:

  public final void testForceFailure() {
    fail("fail test is fail");
  }

另一个非常有趣且值得一提的功能是 Jenkins 能够保存和显示时间轴和构建时间趋势,如下面的截图所示:

获取 Android 测试结果

这个页面展示了带有链接的构建历史,你可以通过这些链接查看每个特定构建的详细信息。现在我们不必担心太多,每当开发团队的成员将变更提交到仓库时,我们知道这些变更将立即集成,整个项目将被构建和测试。如果我们进一步配置 Jenkins,我们甚至可以通过电子邮件接收状态。为此,请在作业配置页面启用电子邮件通知,并输入所需的收件人

总结

本章通过实际应用介绍了持续集成的概念,提供了有价值的信息,以便你尽快将其应用到项目中,无论项目规模大小,无论你是独立开发还是大型公司团队的一员。

所介绍的技术关注于 Android 项目的特性,维护并支持广泛使用的开发工具,如 Android Studio 和 Android Gradle 插件。

我们引入了现实世界中的示例和工具,这些工具来自丰富的开源武器库。我们使用 Gradle 自动化构建过程,使用 Git 创建一个简单的版本控制系统仓库来存储我们的源代码和管理变更,最后安装并配置了 Jenkins 作为我们选择的持续集成工具。

在 Jenkins 中,我们详细介绍了创建作业的过程,以自动化创建我们的 Android 应用程序及其测试,并强调了持续集成框与其设备/模拟器之间的关系。

最后,我们意识到了与安卓相关的测试结果,并实施了一项策略,以获得一个吸引人的界面来监视测试的运行、它们的结果和现有的趋势。

下一章将带领我们走上测试驱动开发的道路;你最终将开始理解为什么我在迄今为止的所有示例中都在谈论温度,这对于一个真实的项目来说非常重要。因此,建立持续集成设置非常完美,可以帮助我们编写优秀的代码,并相信我们的持续集成构建的 APK 已经准备好发布。

第六章:实践测试驱动开发

本章介绍了测试驱动开发TDD)的纪律。我们将从广义上的 TDD 实践开始,随后转移到与 Android 平台更相关的概念和技术。

这是一个代码密集的章节,所以准备好边阅读边输入,这将帮助你从提供的示例中获得最大收益。

在本章中,我们将学习以下主题:

  • 引入并解释测试驱动开发(Test-driven Development)。

  • 分析其优点。

  • 引入一个现实生活中的例子。

  • 通过编写测试来理解项目需求。

  • 通过应用 TDD 在项目中不断发展。

  • 获得一个完全符合要求的程序。

开始使用 TDD。

简而言之,测试驱动开发是边开发边编写测试的策略。这些测试用例是在预期将满足它们的代码之前编写的。

我们首先编写一个测试,然后编写满足这个测试编译所需的代码,接着实现测试规定的应有的行为。我们持续编写测试和实现,直到测试检查完所有期望行为的完整集合。

这与其他开发过程方法形成对比,那些方法是在所有编码完成后的末期编写测试。

提前编写满足它们的代码的测试具有以下优点:

  • 测试以这样或那样的方式编写,而如果将测试留到开发末期,很可能会永远写不出来。

  • 当开发者在编写代码时需要考虑测试时,他们会对自己工作的质量承担更多责任。

设计决策分小步进行,之后通过重构改进满足测试的代码。记住,这需要在测试运行的情况下进行,以确保预期行为没有回归。

测试驱动开发通常通过以下类似的图表来解释,以帮助我们理解这个过程:

开始使用 TDD

以下各节将详细展开与 TDD 相关的红、绿、重构循环的个别行动。

编写一个测试用例。

我们从编写一个测试用例开始开发过程。这显然是一个简单的过程,会在我们脑海中启动一些机制。毕竟,如果我们对问题领域及其细节没有清晰的理解,就不可能编写一些代码,测试它或不测试。通常,这一步会让你直接面对你不理解的问题方面,如果你想建模和编写代码,就需要掌握这些方面。

运行所有测试。

编写测试后,下一步是运行它,以及到目前为止我们编写的所有其他测试。在这里,拥有内置测试环境支持的 IDE 的重要性可能比其他情况下更加明显,可以大幅缩短开发时间。我们预期,首先,我们新编写的测试会失败,因为我们还没有编写任何代码。

为了完成我们的测试,我们编写额外的代码并做出设计决策。编写的额外代码是最少的,以使我们的测试能够编译。在这里请注意,不能编译就是失败。

当我们让测试编译并运行,如果测试失败了,那么我们会尝试编写最少的代码以使测试成功。这在目前听起来可能有些别扭,但本章接下来的代码示例将帮助你理解这个过程。

可选地,你可以先只运行新增加的测试,以节省一些时间,因为有时在模拟器上运行测试可能会相当慢。然后再运行整个测试套件,以验证一切是否仍然正常工作。我们不想在添加新功能时破坏代码中已有的任何功能。

重构代码

当测试成功时,我们会重构添加的代码,以保持其整洁、干净,并且是可维护和可扩展应用程序所需的最小代码量。

我们再次运行所有测试,以验证重构是否破坏了任何功能,如果测试再次通过且无需进一步重构,那么我们就完成了任务。

重构后运行测试是这种方法提供的一个非常安全的保障。如果我们重构算法时犯了错误,提取变量,引入参数,改变签名,或者无论重构机制是什么,这个测试基础设施都会发现问题。此外,如果某些重构或优化对每个可能的情况都不适用,我们可以通过作为测试用例表达的应用程序中的每个案例来验证它。

TDD 的优点

就我个人而言,到目前为止看到的主要优点是它能快速让你专注于编程目标,而且不容易分心或急躁,在软件中实施那些永远不会被使用的选项(有时被称为镀金)。这种实施不必要功能的做法是浪费你宝贵的发展时间。正如你可能已经知道的,谨慎地管理这些资源可能是成功完成项目与否则之间的区别。

另一个优点是,你始终有一个安全网来保护你的更改。每次你更改一段代码时,只要有关联的测试验证条件没有改变,你就可以完全确定系统的其他部分没有受到影响。

请记住,TDD 不能随意应用于任何项目。我认为,像任何其他技术一样,你应该运用你的判断力和专业知识来识别它适用的地方和不适用的地方。始终记住:没有银弹

理解需求

要能够编写关于任何主题的测试,我们首先应该了解被测试的主题,这意味着要分解你试图实现的要求。

我们提到,其中一个优点是你可以快速关注一个目标,而不是围绕需求这个庞大而难以克服的整体旋转。

将需求翻译成测试,并相互参照,可能是理解需求最佳方式,以确保所有需求都有实现和验证。此外,当需求发生变化(这在软件开发项目中非常常见)时,我们可以更改验证这些需求的测试,然后更改实现以确保所有内容都被正确理解和映射到代码中。

创建示例项目 - 温度转换器

你可能已经从迄今为止的一些代码片段中猜到了,我们的 TDD 示例将围绕一个极其简单的 Android 示例项目展开。它不试图展示所有花哨的 Android 功能,而是专注于测试,并逐步从测试中构建应用程序,应用之前学到的概念。

假设我们收到了一个开发 Android 温度转换应用程序的需求列表。虽然过于简化,但我们将按照正常步骤来开发此类应用程序。但是,在这种情况下,我们将在过程中引入测试驱动开发技术。

需求列表

通常(让我们诚实一点),需求列表非常模糊,有很多细节没有完全覆盖。

举个例子,假设我们收到了这个列表:

  • 该应用程序将温度从摄氏度转换为华氏度,反之亦然。

  • 用户界面提供了两个输入温度的字段;一个用于摄氏度,另一个用于华氏度。

  • 当在一个字段中输入温度时,另一个字段会自动更新为转换后的值。

  • 如果有错误,应向用户显示,最好使用相同的字段。

  • 用户界面应保留一些空间用于屏幕键盘,以便在进行多次转换输入时简化应用程序的操作。

  • 输入字段应从空开始

  • 输入的值是带有两位小数的十进制值

  • 数字右对齐

  • 应用程序暂停后,最后输入的值应保持不变。

用户界面概念设计

假设我们从用户界面设计团队收到了这个概念性的用户界面设计(我现在就为我的缺乏想象力和技巧向所有设计师道歉):

用户界面概念设计

创建项目

我们的第一步是创建项目。现在,由于我们已经为前五个章节做过这个,我认为我不需要为你提供一步一步的指导。只需通过 Android Studio 新项目向导,选择带有你的包名的 Android 移动项目,加上其他样板文件,不要 Activity 模板。Android Studio 会自动为你创建一个示例AndroidApplicationTestCase。记住,如果你遇到困难,可以参考本书的代码附录。创建后,它应该看起来像这样:

创建项目

现在,让我们快速创建一个名为TemperatureConverterActivity的新 Activity(我们没有使用模板生成器,因为它添加了很多现在不需要的代码),不要忘记将 Activity 添加到你的AndroidManifest文件中。狂热的 TDD 实践者现在可能正激动地挥舞着拳头,因为实际上你应该在测试中需要时才创建这个 Activity,但我同时也在尝试用一些熟悉的内容引导你。

创建一个 Java 模块

在这个模板项目之上,我们想要添加另一个代码模块。这将是一个仅 Java 的模块,并将作为我们主 Android 模块的依赖或库。

这里的想法有两方面。首先,它允许你将仅 Java 的代码(不依赖于 Android)分离出来,在一个大项目中,这可以是你的核心领域;运行你的应用程序的业务逻辑,重要的是你要模块化这部分,这样你就可以在不考虑 Android 的情况下工作。

其次,正如我们之前所说,拥有一个仅 Java 的模块,在测试时可以让你调用 Java 作为一门成熟编程语言的丰富历史。Java 模块的测试快速、简单、便捷。你可以为 JVM 编写 JUnit 测试,并在几毫秒内运行它们(我们将会这样做!)。

在 Android Studio 中,导航到文件 | 新建模块,这将弹出创建新模块对话框。在更多模块下,选择Java 库,然后点击下一步。给你的库命名为core,确保包名与你的 Android 应用程序相同,然后点击完成。最后一个界面应该看起来像这样:

创建一个 Java 模块

创建后,你需要从你的 Android :app模块添加单向依赖到:core模块。在/app/build.gradle中,添加对核心模块的依赖:

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:appcompat-v7:21.0.2'

    compile project(':core')
}

这允许我们从 Android 应用程序内部引用核心模块中的文件。

创建TemperatureConverterActivityTests

通过选择主测试包名com.blundell.tut.继续创建第一个测试。这在 AndroidStudio 项目视图中的src/androidTest/Java下,或者在 AndroidStudio Android 视图中的app/java/(androidTest)下。然后在这里右键点击,选择新建 | Java 类,将其命名为TemperatureConverterActivityTests

创建类之后,我们需要将其转变为一个测试类。我们应该根据要测试的内容和方式来选择我们的超类。在第二章《理解使用 Android SDK 的测试》中,我们回顾了可用的选择。在尝试决定使用哪个超类时,可以将其作为参考。

在这个特定情况下,我们正在测试单个 Activity 并使用系统基础设施,因此,我们应该使用ActivityInstrumentationTestCase2。还请注意,由于ActivityInstrumentationTestCase2是一个泛型类,我们还需要模板参数。这是我们测试的 Activity,在我们的例子中,是TemperatureConverterActivity

我们现在注意到,在运行之前需要修复类中的一些错误。否则,这些错误将阻止测试运行。

我们需要修复的问题在第二章《理解使用 Android SDK 的测试》中的无参数构造函数部分已经描述过。根据此模式,我们需要实现:

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

  public TemperatureConverterActivityTests(String name) {
    super(TemperatureConverterActivity.class);
    setName(name);
  }

到目前为止,我们执行了以下步骤:

  • 我们添加了一个无参数构造函数TemperatureConverterActivityTests()。从这个构造函数中,我们调用了需要名称作为参数的构造函数。

  • 最后,在这个给定名称的构造函数中,我们调用了超构造函数并设置名称。

为了验证是否一切已经设置好并就位,你可以通过右键点击类,选择运行 | 测试类的名称来运行测试。目前还没有测试可运行,但至少我们可以验证支持我们测试的基础设施已经就位。它应该会以未找到测试警告失败。以下是运行测试类的步骤,以防你错过了:

创建 TemperatureConverterActivityTests 类

创建夹具

我们可以通过向setup()方法中添加测试所需的元素来开始创建测试夹具。在这种情况下,几乎不可避免的是要使用待测试的 Activity,因此让我们为此情况做好准备,并将其添加到夹具中:

@Override  
public void setUp() throws Exception {
    super.setUp();
    activity = getActivity();
}

引入上述代码后,使用 AndroidStudio 的重构工具创建activity字段以节省时间。(F2查看下一个错误,Alt + Enter快速修复,Enter创建字段,Enter再次确认字段类型,完成!)

ActivityInstrumentationTestCase2.getActivity()方法有一个副作用。如果测试的活动没有运行,它将被启动。如果我们在测试中多次将getActivity()作为简单的访问器,并且由于某种原因活动在测试完成前结束或崩溃,这可能会改变测试的意图。我们将会无意中重新启动活动,这就是为什么在测试中我们不鼓励使用getActivity(),而倾向于在夹具中拥有它,这样我们隐式地为每个测试重新启动活动。

创建用户界面

回到我们的测试驱动开发轨道,从我们简洁的需求列表中可以看出,分别有两个条目用于摄氏度和华氏度温度。因此,让我们将它们添加到我们的测试夹具中。

它们尚未存在,我们甚至还没有开始设计用户界面布局,但我们知道肯定需要有两个这样的条目。

这是你应该添加到setUp()方法中的代码:

celsiusInput = (EditText)
  activity.findViewById(R.id.converter_celsius_input);
fahrenheitInput = (EditText)
  activity.findViewById(R.id.converter_fahrenheit_input);

有一些重要的事情需要注意:

  • 我们选择名称converter_celsius_input,因为converter_是此字段(在TemperatorConverter活动中)的位置,celsius_是字段代表的内容,最后 input 是字段的行为方式。

  • 我们使用EditText为我们的夹具定义字段

  • 我们使用之前创建的活动通过 ID 查找视图

  • 即使这些 ID 不存在,我们仍然在主项目中使用R

测试用户界面组件的存在

一旦我们在setUp()方法中添加了它们,如前一部分所示,我们可以编写我们的第一个测试并检查视图的存在:

  public final void testHasInputFields() {
    assertNotNull(celsiusInput);
    assertNotNull(fahrenheitInput);
  }

我们还不能运行测试,因为我们必须先解决一些编译问题。我们应该修复R类中缺失的 ID。

创建了引用我们尚未拥有的用户界面元素和 ID 的测试夹具后,测试驱动开发范式要求我们添加所需的代码以满足我们的测试。我们应该做的第一件事是让测试编译,这样如果我们有测试未实现功能的测试,它们将失败。

获取定义的 ID

我们首先要定义用户界面元素在R类中的 ID,这样引用未定义常量R.id.converter_celsius_inputR.id.converter_fahrenheit_input产生的错误就会消失。

作为经验丰富的 Android 开发者,你将知道如何操作。不管怎样,我会为你提供一个复习。在布局编辑器中创建一个activity_temperature_converter.xml布局,并添加所需的用户界面组件,以得到类似于之前在用户界面概念设计部分介绍的设计,如下代码所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="vertical">

  <TextView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginBottom="@dimen/margin"
    android:text="@string/message" />

  <<TextView
    android:id="@+id/converter_celsius_label"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="@dimen/margin"
    android:text="@string/celsius" />

  <EditText
    android:id="@+id/converter_celsius_input"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_margin="@dimen/margin"  />

  <TextView
    android:id="@+id/converter_fahrenheit_label"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="@dimen/margin"
    android:text="@string/fahrenheit"  />

  <EditText
    android:id="@+id/converter_fahrenheit_input"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_margin="@dimen/margin"  />
</LinearLayout>

这样做,我们让测试编译(别忘了添加字符串和尺寸),运行测试,它们通过了吗?不,它们不应该!你需要挂接你的新活动布局(我敢打赌你已经领先一步了):

public class TemperatureConverterActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_temperature_converter);
    }
}

再次运行测试,你应该得到以下结果:

  • testHasInputFields测试成功

  • 现在一切变绿了

测试输出的结果如下所示:

获取定义的 ID

这清楚地意味着我们正在按照 TDD 的路线进行。

你可能也注意到了,我们在用户界面中添加了一些装饰性非功能性项目,我们没有测试(比如填充),主要是为了让我们的示例尽可能简单。在实际场景中,你可能还想为这些元素添加测试。

将需求翻译为测试

测试具有双重特性。它们验证我们代码的正确性,但在有时,特别是在 TDD 中,它们可以帮助我们理解设计并消化我们正在实现的内容。为了能够创建测试,我们需要了解我们正在处理的问题,如果我们不了解,我们至少应该有一个大致的问题轮廓,以便我们可以开始处理它。

在很多情况下,用户界面背后的需求并没有明确表达,但你应该能够从线框 UI 设计中理解它们。如果我们假设这是这种情况,那么我们可以通过首先编写我们的测试来抓住设计。

空字段

从我们的一项需求中,我们得到:输入字段应该从空开始。

为了在测试中表达这一点,我们可以编写以下内容:

    public void testFieldsShouldStartEmpty() {
        assertEquals("", celsiusInput.getText().toString());
        assertEquals("", fahrenheitInput.getText().toString());
    }

在这里,我们只需将字段的初始内容与空字符串进行比较。

这个测试立即通过,太好了!尽管 TDD 的一个原则是从红色测试开始,你可能想要做一个快速的正确性检查,为EditText的 XML 添加一些文本并运行测试,当它在你删除添加的文本后再次变红变绿时,你知道你的测试正在验证你期望的行为(而不是因为你不期望的副作用而变绿)。我们成功地将一个需求转换为测试,并通过获取测试结果来验证它。

视图属性

同样,我们可以验证组成我们布局的视图的其他属性。我们可以验证其他事项,例如:

  • 字段(如预期出现在屏幕上)

  • 字体大小

  • 边距

  • 屏幕对齐

让我们先验证字段是否在屏幕上:

    public void testFieldsOnScreen() {
        View origin = activity.getWindow().getDecorView();

        assertOnScreen(origin, celsiusInput);
        assertOnScreen(origin, fahrenheitInput);
    }

如前所述,我们从这里使用一个断言:ViewAssertsassertOnScreen

注意

静态导入及其最佳使用方式在第二章中进行了说明,理解使用 Android SDK 的测试。如果你之前没有做过,现在是时候了。

assertOnScreen()方法需要一个起点来寻找其他视图。在这种情况下,因为我们要从最高级别开始,我们使用getDecorView(),它获取包含标准窗口框架和装饰的顶级窗口视图,以及客户端内容。

通过运行这个测试,我们可以确保输入字段出现在屏幕上,正如 UI 设计所规定的那样。在某种程度上,我们已经知道具有这些特定 ID 的视图存在。也就是说,我们通过将视图添加到主布局中,使装置得以编译,但我们并不确定它们是否真的出现在屏幕上。因此,仅需要这个测试的存在,以确保将来不会改变这个条件。如果我们因为某些原因移除了其中一个字段,这个测试会告诉我们它缺失了,不符合 UI 设计。

继续我们的需求列表,我们应该测试视图是否按照我们期望的方式在布局中排列:

    public void testAlignment() {
        assertLeftAligned(celsiusLabel, celsiusInput);
        assertLeftAligned(fahrenheitLabel, fahrenheitInput);
        assertLeftAligned(celsiusInput, fahrenheitInput);
        assertRightAligned(celsiusInput, fahrenheitInput);
    }

我们继续使用ViewAssert中的断言——在这种情况下,使用assertLeftAlignedassertRightAligned。这些方法验证指定视图的对齐方式。为了运行这个测试,我们必须在setUp()方法中为标签 TextView 添加两个查找:

celsiusLabel = (TextView)
  activity.findViewById(R.id.converter_celsius_label);
fahrenheitLabel = (TextView)
  activity.findViewById(R.id.converter_fahrenheit_label);

我们默认使用的LinearLayout类以我们期望的方式排列字段。再次强调,虽然我们不需要向布局中添加任何东西以满足测试,但这将作为一个保护条件。

一旦我们验证它们正确对齐,我们应该验证它们是否覆盖了整个屏幕宽度,如原理图所指定。在这个例子中,只需验证LayoutParams具有正确的值就足够了:

    public void testCelciusInputFieldCoversEntireScreen() {
     LayoutParams lp;
     int expected = LayoutParams.MATCH_PARENT;
     lp = celsiusInput.getLayoutParams();  
     assertEquals("celsiusInput layout width is not MATCH_PARENT", expected, lp.width);
    }

    public void testFahrenheitInputFieldCoversEntireScreen() {
     LayoutParams lp;
     int expected = LayoutParams.MATCH_PARENT;
     lp = fahrenheitInput.getLayoutParams();
     assertEquals("fahrenheitInput layout width is not MATCH_PARENT", expected, lp.width);
    }

我们使用自定义信息以便在测试失败时轻松识别问题。

运行这个测试,我们得到以下信息,表明测试失败了:AssertionFailedError: celsiusInput 布局宽度不是 MATCH_PARENT,预期:<-1> 但实际:<-2>

这引导我们到布局定义。我们必须将layout_width从 Celsius 和 Fahrenheit 字段更改为match_parent

<EditText
    android:id="@+id/converter_celsius_input"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="@dimen/margin"
    android:gravity="end|center_vertical" /> 

Fahrenheit 字段也是如此——更改完成后,我们重复这个循环,并再次运行测试,以验证现在它是否成功。

我们的方法开始显现。我们创建测试用以验证需求中描述的条件。如果条件不满足,我们就改变问题产生的原因,并再次运行测试,验证最新的更改是否解决了问题,或许更重要的是,这个更改没有破坏现有的代码。

接下来,让我们验证字体大小是否符合我们的要求:

    public void testFontSizes() {
        float pixelSize = 24f;
        assertEquals(pixelSize, celsiusLabel.getTextSize());
        assertEquals(pixelSize, fahrenheitLabel.getTextSize());
    }

在这种情况下,获取字段使用的字体大小就足够了。

默认字体大小不是24px,因此我们需要将其添加到我们的布局中。一个好的做法是将相应的尺寸添加到资源文件中,然后在布局中需要的地方使用它。所以,让我们在res/values/dimens.xml中添加label_text_size,值为24sp。然后在标签celsius_labelfahrenheit_labelText大小属性中引用它。

现在,测试可能通过也可能不通过,这取决于你使用的设备或模拟器的分辨率。这是因为我们在测试中断言像素大小,但在dimens.xml中我们声明使用sp(与缩放无关的像素)。让我们加强这个测试。为了解决这个问题,我们可以在测试类中将px转换为sp,或者在测试中使用sp值。我选择在测试中使用sp,尽管你也可以为另一种方法争论:

    public void testFontSizes() {
        float pixelSize = getFloatPixelSize(R.dimen.label_text_size);

        assertEquals(pixelSize, celsiusLabel.getTextSize());
        assertEquals(pixelSize, fahrenheitLabel.getTextSize());
    }

    private float getFloatPixelSize(int dimensionResourceId) {
        return getActivity().getResources()
                 .getDimensionPixelSize(dimensionResourceId);
    }

最后,让我们验证边距是否按照用户界面设计中的描述进行了解释:

    public void testCelsiusInputMargins() {
        LinearLayout.LayoutParams lp =
(LinearLayout.LayoutParams) celsiusInput.getLayoutParams();

        assertEquals(getIntPixelSize(R.dimen.margin), lp.leftMargin);
        assertEquals(getIntPixelSize(R.dimen.margin), lp.rightMargin);
    }

    public void testFahrenheitInputMargins() {
        LinearLayout.LayoutParams lp =
(LinearLayout.LayoutParams) fahrenheitInput.getLayoutParams();

        assertEquals(getIntPixelSize(R.dimen.margin), lp.leftMargin);
        assertEquals(getIntPixelSize(R.dimen.margin), lp.rightMargin);
    }

这与之前的案例类似(我跳过了测试原始像素值的步骤)。我们需要在布局中添加边距。让我们在资源文件中添加边距尺寸,然后在布局中需要的地方引用它。在res/values/dimens.xml中将margin尺寸设置为8dp。然后,在celsiusfahrenheit两个字段的layout_margin_start属性以及标签的start margin中引用它。

获取从资源dimen中整数像素大小的helper方法,只是包装了之前讨论过的float方法:

    private int getIntPixelSize(int dimensionResourceId) {
        return (int) getFloatPixelSize(dimensionResourceId);
    }

还有一件事是剩下的,就是验证输入值的对齐(调整)。我们很快就会验证输入,只允许输入允许的值,但现在让我们关注对齐。意图是让小于整个字段的值右对齐并垂直居中:

public void testCelsiusInputJustification() {
  int expectedGravity = Gravity.END | Gravity.CENTER_VERTICAL;
  int actual = celsiusInput.getGravity();
  String errorMessage = String.format(
"Expected 0x%02x but was 0x%02x", expectedGravity, actual);
  assertEquals(errorMessage, expectedGravity, actual);
}

public void testFahrenheitInputJustification() {
  int expectedGravity = Gravity.END | Gravity.CENTER_VERTICAL;
  int actual = fahrenheitInput.getGravity();
  String errorMessage = String.format(
"Expected 0x%02x but was 0x%02x", expectedGravity, actual);
  assertEquals(errorMessage, expectedGravity, actual);
}

在这里,我们像往常一样验证gravity值。然而,我们使用自定义消息来帮助我们识别可能出错的值。由于Gravity类定义了几个常量,其值以十六进制表示更容易识别,我们在消息中将这些值转换为这种基数。

如果这个测试因为字段使用的默认重力而失败,那么剩下的就是改变它。转到布局定义并更改这些gravity值,以便测试成功。

这正是我们需要添加的内容:

android:gravity="end|center_vertical"

屏幕布局

现在,我们希望验证是否已经满足规定屏幕空间应保留足够空间以显示键盘的要求。

我们可以编写如下测试:

   public void testVirtualKeyboardSpaceReserved() {
        int expected = getIntPixelSize(R.dimen.keyboard_space);
        int actual = fahrenheitInput.getBottom();
String errorMessage = 
  "Space not reserved, expected " + expected + " actual " + actual;
        assertTrue(errorMessage, actual <= expected);
    }

这验证了屏幕上最后一个字段fahrenheitInput的实际位置是否不低于建议值。

我们可以再次运行测试,验证一切是否再次变绿。运行你的应用程序,你应该会有一个由测试支持的完整用户界面,如下截图所示:

屏幕布局

添加功能

用户界面已经就位。现在,我们可以开始添加一些基本功能。这些功能将包括处理实际温度转换的代码。

温度转换

从需求列表中,我们可以得到这个声明:在一个字段中输入一个温度时,另一个字段应自动更新为转换后的温度。

按照我们的计划,我们必须实现这个测试以验证正确功能的存在。我们的测试可能看起来像这样:

@UiThreadTest
public void testFahrenheitToCelsiusConversion() {
  celsiusInput.clear();
  fahrenheitInput.clear();
  fahrenheitInput.requestFocus();
  fahrenheitInput.setText("32.5");
  celsiusInput.requestFocus();
  double f = 32.5;
  double expectedC = TemperatureConverter.fahrenheitToCelsius(f);
  double actualC = celsiusInput.getNumber();
  double delta = Math.abs(expectedC - actualC);
  String msg = "" + f + "F -> " + expectedC + "C but was " 
    + actualC + "C (delta " + delta + ")";
  assertTrue(msg, delta < 0.005);
}

让我们一步一步地执行这个操作:

  1. 首先,正如我们已经知道的,为了与 UI 交互并更改其值,我们应该在 UI 线程上运行测试,因此由于我们使用EditText.setText,测试被注解为@UiThreadTest

  2. 其次,我们使用一个专门的类来替换EditText,提供一些便捷方法,如clear()setNumber()。这将改善我们的应用设计。

  3. 接下来,我们调用一个名为TemperatureConverter的转换器,这是一个工具类,提供不同的方法来转换不同的温度单位,并使用不同的温度值类型。

  4. 最后,由于我们将截断结果以在用户界面中以合适的格式呈现,我们应该与一个增量比较来断言转换值的准确性。

这样创建测试将迫使我们按照计划路径执行。我们的第一个目标是添加所需的方法和代码以使测试能够编译,然后满足测试的需求。

EditNumber 类

在我们的主包中(不是在测试包中,也不是在/androidTest/下的那个),我们应该创建一个继承EditTextEditNumber类,因为我们需要扩展其功能。创建类后,我们需要更改测试类成员类型的字段类型:

public class TemperatureConverterActivityTests extends ActivityInstrumentationTestCase2<TemperatureConverterActivity> {

  private TemperatureConverterActivity activity;  
  private EditNumber celsiusInput;
  private EditNumber fahrenheitInput;
  private TextView celsiusLabel;
  private TextView fahrenheitLabel;

然后,更改测试中存在的任何强制类型转换。你的 IDE 会高亮这些代码;按下F2在类中找到它们。

在能够编译测试之前,我们还需要解决两个问题:

  • 我们在EditNumber中仍然没有clear()setNumber()方法

  • 我们还没有TemperatureConverter工具类

在测试类的内部,我们可以使用 IDE 来帮助我们创建方法。再次按下F2,你应被带到**无法解析方法 clear()**的错误处。现在按下Alt + Enter,在EditNumber类型中创建clear()方法。getNumber()方法同理。

最后,我们必须创建TemperatureConverter类。这个类将包含摄氏度和华氏度的数学转换,不包含任何 Android 代码。因此,我们可以在/core/模块内创建此包。如先前讨论的,它将位于相同的包结构下,只是这个模块不知道 Android,因此我们可以编写运行速度更快的 JVM 测试。

提示

确保在核心模块中创建它,并且与你的主代码位于同一包下,而不是测试包。

下面是在核心模块中创建该类,以及我们应用程序当前状态的方法:

EditNumber 类

完成此操作后,在我们的测试中,创建了fahrenheitToCelsius方法。

这解决了我们之前的问题,并引导我们进行一个现在可以编译和运行的测试。是的,你将会看到红色的 Lint 错误,但这些并不是"编译"错误,因此测试仍然可以运行。(AndroidStudio 的智能程度实在是太高了。)

出乎意料的是,当我们运行测试时,它们会因为异常而失败:

java.lang.ClassCastException:
android.widget.EditText cannot be cast to com.blundell.tut.EditNumber
at com.blundell.tut.TemperatureConverterActivityTests.setUp(
TemperatureConverterActivityTests.java:36)
at android.test.AndroidTestRunner.runTest(
AndroidTestRunner.java:191)

这是因为我们更新了所有的 Java 文件以包含我们新创建的EditNumber类,但忘记了更改布局 XML。

让我们继续更新我们的 UI 定义:

<com.blundell.tut.EditNumber
    android:id="@+id/converter_celsius_input"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="@dimen/margin"
    android:gravity="end|center_vertical" />

也就是说,我们用扩展原始EditText类的com.blundell.tut.EditNumber视图替换了原来的EditText类。

现在,我们再次运行测试,发现所有测试都通过了。

但等一下;我们在新的EditNumber类中还没有实现任何转换或处理值的方法,所有测试都顺利通过了。是的,它们通过了,因为我们的系统没有足够的限制,现有的限制仅仅相互抵消了。

在继续之前,让我们分析一下刚才发生了什么。我们的测试调用了fahrenheitInput.setText("32.5")方法来设置华氏度字段输入的温度,但我们的EditNumber类在输入文本时什么也不做,功能尚未实现。因此,摄氏度字段保持为空。

expectedC的值——预期的摄氏度温度,是通过调用TemperatureConverter.fahrenheitToCelsius(f)计算的,但这是一个空方法。在这种情况下,因为我们知道方法的返回类型,所以我们让它返回一个常数0。因此,expectedC变成了0

然后,从用户界面获取转换的实际值。在这种情况下,从EditNumber调用getNumber()。但这个方法是自动生成的,为了满足其签名所施加的限制,它必须返回一个值,即0

Δ值再次为0,由Math.abs(expectedC - actualC)计算得出。

最后,我们的断言assertTrue(msg, delta < 0.005)true,因为delta=0满足了条件,测试通过。

那么,我们的方法有缺陷吗,因为它无法检测到这种情况?

不,完全不是,这里的问题是我们的限制不够,而这些限制被自动生成方法使用的默认值满足了。一种替代方法可能是对所有自动生成的方法抛出异常,比如RuntimeException("尚未实现"),以检测在未实现时使用它的情况。我们将在系统中增加足够的限制,以便轻松捕捉这种双重零条件。

温度转换器的单元测试

从我们之前经验来看,似乎默认实现的转换总是返回0,因此我们需要更健壮的东西。否则,我们的转换器只有在参数取 32F(32F == 0C)的值时才会返回有效的结果。

TemperatureConverter 类是一个与 Android 基础架构无关的实用类,因此一个标准的单元测试就足以测试它。

由于这是我们即将编写的第一个核心测试,我们需要进行一些设置。首先,从项目视图开始;在你的项目结构中,通过选择 新建 | 目录 并使用名称 test/core/src 下创建一个 test 文件夹。在这个文件夹内,通过选择 新建 | 目录 并使用名称 java 创建一个 java 文件夹。由于 Gradle 的神奇之处,它会明白这是你想要添加测试的地方,文件夹应该会变成绿色(绿色表示该文件夹是测试类路径的一部分)。现在添加一个新的包,从技术上来说并不是新的,因为我们将再次使用 com.blundell.tut,通过选择 新建 | 并使用名称 com/blundell/tut

现在,在我们新的文件夹和包中创建我们的测试。我们通过选择 新建 | Java 类 并将其命名为 TemperatureConverterTests 来创建测试。你的项目现在应该看起来像这样:

温度转换器单元测试

让我们创建第一个测试,在 TemperatureConverterTests 内,按 Ctrl + Enter 弹出 生成 菜单,如下面的截图所示:

温度转换器单元测试

选择 测试方法 测试,然后选择 JUnit4 将为我们生成我们想要的测试模板方法,将其命名为 testFahrenheitToCelsius()。记住这个快捷方式,因为它在创建新测试时非常有用。一旦你生成了这个测试,你会注意到我们在 JUnit 4 导入的代码行上有编译错误。哎呀!我们忘记将 JUnit 库添加到我们核心模块的测试类路径中。打开 /core/build.gradle 文件,并添加 JUnit 依赖。你的核心 build.gradle 现在应该看起来像这样:

apply plugin: 'java''java'

dependencies {
    compile fileTree(dir: 'libs''libs', include: [''''*.jar'])

    testCompile 'junit'junit:junit:4.+''''
}

注意

注意,这里我们从 JUnit3 跳到了 JUnit4,主要区别在于我们现在可以使用注解来告诉我们的测试运行器,类中的哪些方法是测试方法。因此,从技术上讲,我们不再需要像 testFooBar() 那样以 test 开头的方法名,但为了在我们两者之间切换时保持清醒,我们还是会这样做(Android 对 JUnit4 的支持即将到来!)。

通过选择 项目同步 来进行项目同步,我们现在可以编译并准备编码。让我们开始编写测试:

@Test
public void testFahrenheitToCelsius() {
    for (double knownCelsius : conversionTable.keySet()) {
        double knownFahrenheit = conversionTable.get(knownCelsius);

        double resultCelsius =
TemperatureConverter.fahrenheitToCelsius(knownFahrenheit);

        double delta = Math.abs(resultCelsius - knownCelsius);
        String msg = knownFahrenheit + "F -> " + knownCelsius + "C"+ "but is " + resultCelsius;
        assertTrue(msg, delta < 0.0001);
     }
}

创建一个带有不同温度转换值的转换表,我们知道从其他来源驱动这个测试是一个好方法:

Map<Double, Double> conversionTable = new HashMap<Double, Double>() {
  // initialize (celsius, fahrenheit) pairs
  put(0.0, 32.0);
  put(100.0, 212.0);
  put(-1.0, 30.20);
  put(-100.0, -148.0);
  put(32.0, 89.60);
  put(-40.0, -40.0);
  put(-273.0, -459.40);
}};

要在核心模块中运行测试,我们可以右键点击项目视图中的文件,并选择 运行。正如截图也显示的那样,你可以使用快捷键 Cmd + Shift + F10

温度转换器单元测试

当这个测试运行时,我们验证它失败,并给我们留下这条轨迹:

java.lang.AssertionError: -40.0F -> -40.0C but is 0.0
 at org.junit.Assert.fail(Assert.java:88)
 at org.junit.Assert.assertTrue(Assert.java:41)
 at com.blundell.tut.TemperatureConverterTests.testFahrenheitToCelsius(TemperatureConverterTests.java:31).

注意

看看这些核心测试运行得多快!尽量将应用程序逻辑移到核心模块中,这样在进行测试驱动开发时,你可以利用这个速度。

好吧,这是我们预料之中的事情,因为我们的转换总是返回0。实现我们的转换时,我们发现我们需要一个ABSOLUTE_ZERO_F常量:

    private static final double ABSOLUTE_ZERO_F = -459.67d;

    private static final String ERROR_MESSAGE_BELOW_ZERO_FMT =       "Invalid temperature: %.2f%c below absolute zero";

    private TemperatureConverter() {
        // non-instantiable helper class
    }

    public static double fahrenheitToCelsius(double fahrenheit) {
        if (fahrenheit < ABSOLUTE_ZERO_F) {
            String msg = String.format(ERROR_MESSAGE_BELOW_ZERO_FMT,               fahrenheit, 'F''F');
            throw new InvalidTemperatureException(msg);
        }
        return ((fahrenheit - 32) / 1.8d);
    }

绝对零度是熵达到最小值的理论温度。根据热力学定律,要达到这个绝对零度的状态,系统应该与宇宙的其余部分隔离。因此,这是一个无法达到的状态。然而,按照国际协议,绝对零度定义为开尔文量表上的 0K,摄氏量表上的-273.15°C,或者华氏量表上的-459.67°F。

我们正在创建一个自定义异常InvalidTemperatureException,以指示在转换方法中提供有效温度失败。这个异常与 Android 无关,因此也可以放在我们的核心模块中。通过扩展RuntimeException来创建它:

public class InvalidTemperatureException extends RuntimeException {

  public InvalidTemperatureException(String msg) {
    super(msg);
  }

}

再次运行核心测试,我们发现testFahrenheitToCelsius测试成功了。因此,我们回到 Android 测试,运行这些测试发现testFahrenheitToCelsiusConversion测试失败了。这告诉我们,现在转换器类正确处理了转换,但 UI 处理这个转换仍然存在一些问题。

注意

不必对运行两个单独的测试类感到绝望。对你来说,选择运行哪些测试是常见的;这在进行 TDD 时部分是学习到的技能。但是,如果你愿意,可以编写自定义测试运行器来运行所有的测试。此外,使用 Gradle 运行build connectedAndroidTest将一次性运行所有测试,这建议在你认为完成了一个功能或想要提交到上游版本控制时执行。

仔细查看testFahrenheitToCelsiusConversion失败的追踪信息,可以发现有些地方在不应返回0的情况下仍然返回了0

这提醒我们,我们仍然缺少一个合适的EditNumber实现。在继续实现上述方法之前,让我们创建相应的测试来验证我们正在实现的内容是否正确。

EditNumber测试

从前一章可以确定,我们自定义视图测试的最佳基类是AndroidTestCase,因为我们需要一个模拟的Context类来创建自定义视图,但我们不需要系统基础结构。

创建EditNumber的测试,我们称之为EditNumberTests,并扩展AndroidTestCase。提醒一下,这位于应用模块下的androidTest路径中。

我们需要添加构造函数以反映我们之前识别的给定名称模式:

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

 public EditNumberTests(String name) {
 setName(name);
    }

下一步是创建测试夹具。在这种情况下,这是一个简单的EditNumber类,我们将对其进行测试:

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

        editNumber = new EditNumber(mContext);
        editNumber.setFocusable(true);
    }

模拟上下文是从AndroidTestCase类中受保护的字段mContext获取的(developer.android.com/reference/android/test/AndroidTestCase.html#mContext)。

setUp方法的最后,我们将editNumber设置为可聚焦的视图,这意味着它将能够获得焦点,因为它将参与许多模拟可能需要显式请求其焦点的 UI 的测试。

接下来,我们测试testClear()方法中所需clear()功能的正确实现:

@UiThreadTest
public void testClear() {
String value = "123.45";
          editNumber.setText(value);

          editNumber.clear();

          assertEquals("", editNumber.getText().toString());
} 

运行测试,我们验证它确实失败了:

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

我们需要正确实现EditNumber.clear()

这是一个简单的案例,只需将此实现添加到EditNumber中,我们就可以满足测试:

  public void clear() {
    setText("");
  }

运行测试并继续。我们将在EditNumber中添加一个新方法。这里,我们已经有了getNumber(),我们现在添加setNumber()以便稍后使用。现在让我们完成testSetNumber()实现的编写:

           public void testSetNumber() {

        editNumber.setNumber(123.45);

        assertEquals("123.45", editNumber.getText().toString());
    }

除非我们实现了类似于以下实现的EditNumber.setNumber(),否则会失败:

    private static final String DEFAULT_FORMAT = "%."%.2f";";

    public void setNumber(double number) {
        super.setText(String.format(DEFAULT_FORMAT, number));
    }

我们使用了一个常量DEFAULT_FORMAT来保存转换数字所需的格式。这可以稍后转换为属性,也可以在字段的 XML 布局定义中指定。

同样适用于testGetNumber()getNumber()这一对:

      public void testGetNumber() {

        editNumber.setNumber(123.45);

        assertEquals(123.45, editNumber.getNumber());
    }

getNumber()方法如下所示:

    public double getNumber() {
        String number = getText().toString();
        if (TextUtils.isEmpty(number)) {
            return 0D;
        }
        return Double.valueOf(number);
    }

这些测试成功了,所以运行你的其他测试来看我们进行到哪一步;我在命令行中运行了gradlew build cAT命令来做到这一点。这运行了我们到目前为止编写的所有测试;但testFahrenheitToCelsiusConversion()失败了。我们已经有很多经过良好测试的代码,退一步,反思一下。

以下是我们 Android 测试的结果:

EditNumber 测试

以下是我们核心的 Java 测试结果:

EditNumber 测试

如果仔细分析testFahrenheitToCelsiusConversion()测试用例,你就能发现问题所在。

明白了吗?

我们的测试方法期望当焦点发生变化时自动进行转换,正如我们需求列表中指定的那样:“在一个字段中输入一个温度时,另一个字段会自动更新为转换后的值”。

记住,我们没有按钮或任何其他东西来转换温度值,所以一旦输入了值,转换应该是自动进行的。

这让我们回到了TemperatureConverterActivity类,以及它处理转换的方式。

TemperatureChangeWatcher 类

实现不断更新另一个温度值所需行为的一种方式,是在原始值发生变化时通过TextWatcher。从文档中,我们可以理解TextWatcher是附加到Editable的类型的一个对象;当文本更改时,将调用其方法(developer.android.com/reference/android/text/TextWatcher.html)。

这似乎是我们需要的。

我们将这个类实现为TemperatureConverterActivity的内部类。这样做的想法是,因为我们直接作用于 Activity 的 Views,将其作为内部类显示了这种关系,并且如果有人想要更改此 Activity 的布局,这会使关系变得清晰。如果你实现了最小的TextWatcher,你的 Activity 将如下所示:

public class TemperatureConverterActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_temperature_converter);
    }

    /**
     * Changes fields values when the text changes; applying the correlated conversion method.
     */
    static class TemperatureChangedWatcher implements TextWatcher {

        @Override
        public void beforeTextChanged(CharSequence s, int start, int count, int after) {

        }

        @Override
        public void onTextChanged(CharSequence s, int start, int before, int count) {

        }

        @Override
        public void afterTextChanged(Editable s) {
        }
    }
}

现在我们对最近创建的类进行一些添加后的代码如下:

/**
 * Changes fields values when the text changes;
 * applying the correlated conversion method.
 */
static class TemperatureChangedWatcher implements TextWatcher {

private final EditNumber sourceEditNumber;
private final EditNumber destinationEditNumber;
private final Option option;

private TemperatureChangedWatcher(Option option,
EditNumber source,
EditNumber destination) {
this.option = option;
   this.sourceEditNumber = source;
   this.destinationEditNumber = destination;
}

static TemperatureChangedWatcher newCelciusToFehrenheitWatcher(EditNumber source, EditNumber destination) {
return new TemperatureChangedWatcher(Option.C2F, source, destination);
}

static TemperatureChangedWatcher newFehrenheitToCelciusWatcher(EditNumber source, EditNumber destination) {
return new TemperatureChangedWatcher(Option.F2C, source, destination);
}

@Override
public void onTextChanged(CharSequence input, int start, int before, int count) {
if (!destinationEditNumber.hasWindowFocus()
|| destinationEditNumber.hasFocus()
|| input == null) {
       return;
}

   String str = input.toString();
   if ("".equals(str)) {
       destinationEditNumber.setText("");
          return;
}

   try {
      double temp = Double.parseDouble(str);
      double result = (option == Option.C2F)
? TemperatureConverter.celsiusToFahrenheit(temp)
: TemperatureConverter.fahrenheitToCelsius(temp);
    String resultString = String.format("%.2f", result);
    destinationEditNumber.setNumber(result);
    destinationEditNumber.setSelection(resultString.length());
   } catch (NumberFormatException ignore) {
      // WARNING this is generated whilst 
 // numbers are being entered,
 // for example just a '-' 
 // so we don''t want to show the error just yet
   } catch (Exception e) {
     sourceEditNumber.setError("ERROR: " + e.getLocalizedMessage());
   }
}

@Override
public void afterTextChanged(Editable editable) {
// not used
}

@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
// not used
}
}

我们将使用相同的TemperatureChangeWatcher实现,用于摄氏度和华氏度这两个字段;因此我们保留了作为源和目标字段以及更新它们值的操作的引用。为了指定此操作,我们引入了enum,它是纯 Java,因此可以放入核心模块中:

/**
 * C2F: celsiusToFahrenheit
 * F2C: fahrenheitToCelsius
 */
public enum Option {
    C2F, F2C
}

此操作在创建工厂方法中指定,并根据需要选择源和目标EditNumber。这样我们可以为不同的转换使用相同的观察者。

我们感兴趣的TextWatcher接口的方法是onTextChanged。只要文本发生变化,就会调用它。起初,我们避免潜在的循环,检查谁具有焦点,并在条件不满足时返回。

如果源为空,我们也应将目标字段设置为空字符串。

最后,我们尝试将调用相应转换方法得到的结果值设置到目标字段。我们根据需要标记错误,避免在转换被部分输入的数字调用时显示过早的错误。

我们需要在TemperatureConverterActivity.onCreate()中设置输入字段的监听器:

@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_temperature_converter);
  EditNumber celsiusEditNumber =
  (EditNumber) findViewById(R.id.converter_celsius_input); 
  EditNumber fahrenheitEditNumber =
  (EditNumber) findViewById(R.id.converter_fahrenheit_input);
  celsiusEditNumber
  .addTextChangedListener(
newCelciusToFehrenheitWatcher(celsiusEditNumber, fahrenheitEditNumber));

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

为了能够运行测试,我们应该编译它们。要编译,我们至少需要定义尚未定义的celsiusToFahrenheit()方法。

更多温度转换器测试

我们需要实现celsiusToFahrenheit,像往常一样,我们从测试开始。

这与其他转换方法fahrenheitToCelsius相当等效,我们可以使用在创建此测试时设计的基础架构:

@Test
    public void testCelsiusToFahrenheit() {
        for (double knownCelsius : conversionTable.keySet()) {
            double knownFahrenheit = conversionTable.get(knownCelsius);

            double resultFahrenheit = 
TemperatureConverter.celsiusToFahrenheit(knownCelsius);

            double delta = Math.abs(resultFahrenheit - knownFahrenheit);
            String msg = knownCelsius + "C -> " + knownFahrenheit + "F"
+ " but is " + resultFahrenheit;
            assertTrue(msg, delta < 0.0001);
        }
    }

我们使用转换表通过不同的转换来练习该方法,并验证误差小于预定义的增量。

然后,TemperatureConverter类中的相应转换实现如下:

    static final double ABSOLUTE_ZERO_C = -273.15d;

    public static double celsiusToFahrenheit(double celsius) {
        if (celsius < ABSOLUTE_ZERO_C) {
            String msg = String.format(
ERROR_MESSAGE_BELOW_ZERO_FMT, celsius, 'C');
            throw new InvalidTemperatureException(msg);
        }
        return (celsius * 1.8d + 32);
    }

现在,所有测试都通过了,但我们仍然没有测试所有常见条件。我的意思是,到目前为止我们只检查了正常路径。你应该检查是否正确生成了错误和异常,除了我们到目前为止创建的所有正常情况。

创建这个测试,以检查在转换中使用绝对零度以下的温度时,是否正确生成了异常:

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

在这个测试中,我们递减绝对零度温度,以获得更小的值,然后尝试转换。我们在核心模块中编写了此测试,因此使用了 JUnit4,它允许我们使用注解来断言我们期望抛出异常。如果你想在 JUnit3 中做同样的事情,你不得不使用 try catch 块,并且如果代码没有进入 catch 块,则测试失败:

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

同样地,我们测试当尝试转换涉及低于绝对零的摄氏度温度时,是否抛出了异常。

输入过滤器测试

另一个错误要求可能是:我们希望过滤掉转换工具接收到的输入,这样不会有垃圾到达这个点。

EditNumber类已经过滤了有效输入,否则将生成异常。我们可以通过在TemperatureConverterActivityTests中创建一个新测试来验证这个条件。我们选择这个类,因为我们是像真实用户一样向输入字段发送键:

public void testInputFilter() throws Throwable {
        runTestOnUiThread(new Runnable() {
            @Override
            public void run() {
                celsiusInput.requestFocus();
            }
        });
        getInstrumentation().waitForIdleSync();

        sendKeys("MINUS 1 PERIOD 2 PERIOD 3 PERIOD 4");
        double number = celsiusInput.getNumber();

        String msg = "-1.2.3.4 should be filtered to -1.234 " 
          + "but is " + number;
        assertEquals(msg, -1.234d, number);
    }

这个测试使用之前回顾的模式,请求将焦点移到摄氏度字段。这允许我们在 UI 线程中运行测试的一部分,并向视图发送键输入。发送的键是一个包含多个点的无效序列,这对于一个格式良好的十进制数是不被接受的。预期当过滤器启用时,这个序列将被过滤,只有有效字符到达字段。断言celsiusInput.getNumber()返回的值,在过滤后是我们所期望的。

要实现这个过滤器,我们需要向EditNumber添加InputFilter。因为应该将其添加到所有构造函数中,所以我们创建了一个额外的init()方法,从每个构造函数中调用它。为了实现我们的目标,我们使用了DigitsKeyListener的实例,接受数字、符号和十进制点如下:

   public EditNumber(Context context) {
        super(context);
        init();
   }
   public EditNumber(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
   }

   public EditNumber(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        init();
   }

   private void init() {
    // DigistKeyListener.getInstance(true, true)
    // returns an instance that accepts digits, sign and decimal point
    InputFilter[] filters =
      new InputFilter[]{DigitsKeyListener.getInstance(true, true)};
       setFilters(filters);
   }

这个init方法从每个构造函数中调用,这样如果这个视图是程序化使用或从 XML 中使用,我们仍然有我们的过滤器。

重新运行测试,我们可以验证所有测试都已通过,现在一切又都变绿了。

查看我们的最终应用程序

干得好!现在我们有了满足所有要求的应用程序。

在以下屏幕截图中,我们展示了这些要求中的一个,即检测尝试转换低于摄氏度绝对零温度(-1000.00C)的温度的企图:

查看我们的最终应用程序

UI 遵循提供的指南;可以通过在相应单位字段中输入温度来进行转换。

回顾一下,这是我们已实现的需求列表:

  • 应用程序可以在摄氏度和华氏度之间转换温度

  • 用户界面提供了两个输入温度的字段,一个用于摄氏度,另一个用于华氏度

  • 当在一个字段中输入一个温度时,另一个字段会自动更新为转换后的温度

  • 如果有错误,应该向用户显示,可能使用相同的字段

  • 用户界面中应保留一些空间用于屏幕键盘,以便在输入多个转换时简化应用程序的操作

  • 输入字段应从空开始

  • 输入的值是小数点后两位的十进制值

  • 数字右对齐

更重要的是,我们现在可以确信应用程序不仅满足了需求,而且没有明显的问题或错误。我们通过分析测试结果,一步步解决问题,确保任何发现的错误一旦经过测试和修复,就不会再次出现。

总结

我们介绍了测试驱动开发,解释了其概念,并在一个潜在的实际问题中逐步应用它们。

我们从一个简洁的需求列表开始,描述了温度转换应用程序。

我们按照测试代码的顺序实现了每一个测试,以此满足需求。通过这种方式,我们实现了应用程序的行为及其展示,进行测试以确保我们设计的 UI 遵循规范。

由于有了测试,我们分析了运行它们的不同可能性。在上一章的基础上,现在我们的持续集成机器可以运行测试,以确保团队的任何更改仍然会产生一个经过良好测试的应用程序。

下一章将介绍行为驱动开发,并继续我们的目标:无错误的、经过良好测试的代码,这次的重点是行为和团队间的共识,即需求在整个团队中的意义。