安卓敏捷教程-全-

安卓敏捷教程(全)

原文:Agile Android

协议:CC BY-NC-SA 4.0

一、简介

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-9701-8_​1) contains supplementary material, which is available to authorized users.

一段时间以来,敏捷开发一直是 Android 开发者的问题。已经有很多测试用户界面(UI)的方法,比如 Robotium 或 Monkey Runner,但是在 Android Studio 1.1 之前,单元测试很难使用,很难配置,并且在 Android 平台上实现很有挑战性。

毫无疑问,Google 会说在过去你可以使用 JUnit3 风格的单元测试。但是对于任何从事传统 Java 开发的人来说,这是一个戏剧性的倒退。开发人员会不小心使用许多第三方工具拼凑出一个 JUnit4 开发环境。更有可能的是,他们会简单地放弃,因为越来越多的互不兼容的库依赖最终会把他们拖垮。因为对于 Android 开发者来说根本没有工具箱,移动平台上的敏捷开发是不成熟的,让人想起了 21 世纪初的 Java 开发。

谢天谢地,这一切都改变了——Android 现在支持 JUnit4,Android 开发人员现在可以回到单元测试上来了。Android JUnit4 测试世界还处于早期阶段,文档还很少,所以在本书中,我们将展示使用 Android Studio 启动和运行单元测试的实用方法。我们还将看看如何通过其他特定于 UI 的 Android 测试库(如 Espresso)来补充这一点,从而为 Android 开发人员创建一个完整的敏捷测试框架。

你好,世界单元测试

在我们继续之前,让我们看一个简单的单元测试。出于演示的目的,我们可以使用 Google Calculator 示例中的 Add 方法,该方法可从 https://github.com/googlesamples/android-testing 获得(参见清单 1-1 )。

Listing 1-1. Add Method from Google’s Calculator Example

public double add(double firstOperand, double secondOperand) {

return firstOperand + secondOperand;

}

清单 1-2 展示了一个非常简单的单元测试,它测试 Add 方法是否能正确地将两个数相加。

Listing 1-2. Test Method for Add Method from Calculator Example

@Test

public void calculator_CorrectAdd_ReturnsTrue() {

double resultAdd = mCalculator.add(3, 4);

assertEquals(7, resultAdd, 0);

}

单元测试使用断言来确保方法提供预期的结果。在这种情况下,我们使用assertEquals来查看当 3 加 4 时Add方法是否返回 7。如果测试成功,那么我们应该会看到一个积极的或绿色的结果,如果没有,那么我们会在 Android Studio 中看到一个红色的结果。

了解使用敏捷方法进行 Android 开发的好处

如果你是敏捷开发的新手,你可能想知道敏捷如何改进开发过程。

最基本的,敏捷的,特别是单元测试,可以帮助你

  • 在开发过程的早期发现更多的错误
  • 自信地做出更多改变
  • 内置回归测试
  • 延长代码库的寿命

如果你编写单元测试,并且它们覆盖了你代码的重要部分,那么你将会发现更多的错误。您可以进行简单的修改来整理代码,或者进行更大范围的架构修改,运行您的单元测试,并且,如果它们都通过了,确信您没有引入任何微妙的缺陷。你写的单元测试越多,无论何时你改变代码,你就越能回归测试你的应用。一旦你有了大量的单元测试,那么它就变成了一个回归测试套件,让你有信心去做你不会尝试的事情。

单元测试意味着你不必再抱着“不要管”的心态去编程。您现在可以进行重大更改(更改为新的数据库、更新您的后端应用编程接口(API)、更改为新的材料设计主题等)。)并确信您的应用的行为与您做出更改之前是一样的,因为所有的测试都会执行,没有任何错误。

探索 Android 的敏捷测试金字塔

在你的测试套件中,有几种类型的测试是你需要的,以确保你的应用得到充分的测试。您应该对组件或方法级别的功能进行单元测试,对任何后端 RESTful APIs 进行 API 或验收测试,对 Android 活动和一般应用工作流进行 GUI(图形用户界面)测试。

经典的敏捷测试金字塔最早出现在 Mike Cohn 所著的《成功运用敏捷》(Pearson Education,2010)一书中。这是你的应用需要的每种测试的相对数量的一个很好的指南(见图 1-1 )。

A978-1-4842-9701-8_1_Fig1_HTML.jpg

图 1-1。

Agile Test Pyramid

在 Android 中创建 Hello World 单元测试

在下面的例子中,我们展示了如何在 Android Studio 中创建简单的单元测试示例。假设在计算器 Android 应用中添加两个数字工作正常,这应该返回 true。

若要设置和运行单元测试,您需要执行以下任务:

  • 先决条件:Gradle 版本 1.1.x 的 Android 插件
  • 创建src/test/java文件夹
  • build.gradle (app)文件中添加 JUnit:4:12 依赖
  • 在构建变体中选择单元测试的测试工件
  • 创建单元测试
  • 右键单击测试运行测试

点击文件➤项目结构,并确保 Android 插件版本高于 1.1。在图 1-2 中,Android 插件版本是 1.2.3,所以我们准备好了。

A978-1-4842-9701-8_1_Fig2_HTML.jpg

Figure 1-2.

接下来我们需要为我们的单元测试代码创建src/test/java文件夹。目前,这似乎是硬编码到这个目录。所以切换到项目视图来查看文件结构并创建文件夹(见图 1-3 )。或者,在 Windows 中使用文件资源管理器创建文件夹,或者在 Mac 上使用终端窗口中的命令行进行更改。当你回到 Android Studio 中的 Android 视图时,如果文件夹没有显示出来,也不用担心。当我们在“构建变体”窗口中切换到单元测试时,它们就会显示出来。

A978-1-4842-9701-8_1_Fig3_HTML.jpg

图 1-3。

Change to Project view

junit库添加到build.gradle (app)文件的依赖项部分,如图 1-4 所示。

A978-1-4842-9701-8_1_Fig4_HTML.jpg

图 1-4。

Modify the build.gradle file

在构建变体中选择单元测试测试工件,并使用调试构建(参见图 1-5 )。当你在应用的 Android 视图中时,测试代码目录现在也应该出现了。

A978-1-4842-9701-8_1_Fig5_HTML.jpg

图 1-5。

Choose Unit Tests in Build Variant

为我们的简单示例创建单元测试代码。我们需要导入org.junit.Before,这样我们就可以创建一个Calculator对象。我们需要导入org.junit.Test来告诉 Android Studio 我们正在进行单元测试。由于我们要做一个assertEquals,我们还需要导入org.junit.Assert.assertEquals(参见清单 1-3 )。

Listing 1-3. Unit Test Code

package com.riis.calculatoradd;

import org.junit.Before;

import org.junit.Test;

import static org.junit.Assert.assertEquals;

public class CalculatorTest {

private``Calculator``mCalculator

@Before

public void setUp() {

mCalculator``=``new

}

@Test

public void calculator_CorrectAdd_ReturnsTrue() {

double``resultAdd =``mCalculator

assertEquals("adding 3 + 4 didn’t work this time", 7, resultAdd, 0);

}

}

右键单击CalculatorTest java 文件并选择 Run‘calculator test’来运行测试(参见图 1-6 )。

A978-1-4842-9701-8_1_Fig6_HTML.jpg

图 1-6。

Running the unit test

您可以在运行窗口中看到测试结果(参见图 1-7 )。您可能还想单击配置齿轮并选择 Show Statistics 来查看测试需要多长时间。

A978-1-4842-9701-8_1_Fig7_HTML.jpg

图 1-7。

Test results

如果测试成功,它们显示为绿色,任何产生错误的都显示为红色。在您继续任何编码之前,您的所有测试都应该是绿色的。

GUI 测试

单元测试的真正美妙之处在于,你不需要仿真器或物理设备来进行测试。但是,如果我们回头看看我们的敏捷测试金字塔(图 1-1 ,我们知道我们将需要一些 GUI 测试。请记住,GUI 测试是对活动的测试,而单元测试是对代码中个别方法的测试。我们不需要像单元测试那样多的 GUI 测试,但是我们仍然需要测试每一个活动,包括快乐的路径和不快乐的路径。

当谈到测试 GUI 时,我们有几个框架可供选择:我们可以使用 Android JUnit3 框架、Google 的 Espresso、UIAutomator、Robotium 或一些黄瓜类型的 Android 框架,如 Calabash。在本书中,我们将使用谷歌的 Espresso,因为它快速而容易设置,并且它也支持 Gradle 和 Android Studio。但是你的作者在过去使用过其他的框架,它们都有它们的好处。

Espresso 有三个组件:视图匹配器、视图操作和视图断言。ViewMatchers 用于查找视图,ViewActions 允许您对视图做一些事情,ViewAssertions 类似于单元测试断言——它们允许您断言视图中的值是否是您所期望的。

清单 1-4 展示了一个简单的 Espresso GUI 测试示例。我们再次添加两个数字,但是这一次我们是通过与 GUI 交互来完成的,而不是调用底层方法。

Listing 1-4. Adding Two Numbers Using Espresso

public void testCalculatorAdd() {

onView(withId(R.id.``operand_one_edit_text``)).perform(typeText(``THREE

onView(withId(R.id.``operand_two_edit_text``)).perform(typeText(``FOUR

onView(withId(R.id.``operation_add_btn

onView(withId(R.id.``operation_result_text_view``)).check(matches(withText(``RESULT

}

在这个例子中,withId(R.id.operand_one_edit_text)是代码中的一个视图匹配器,而perform(typeText(THREE)是一个 ViewAction。最后check(matches(withText(RESULT))是 ViewAssertion。

创建 Hello,World GUI 测试

这次我们将展示如何在 Android Studio 中创建简单的 GUI 测试示例。与单元测试一样,假设计算器 Android 应用中的两个数字相加正常,那么这个测试应该返回 true。

要设置和运行 GUI 测试,您需要执行以下任务:

  • 先决条件:安装 Android 支持库
  • 将测试类放在src/androidTest/java文件夹中
  • build.gradle (app)文件中添加 Espresso 依赖
  • 在构建变体中选择 Android 测试工具测试工件
  • 创建 GUI 测试
  • 右键单击测试运行测试

点击工具➤安卓➤ SDK 管理器,点击 SDK 工具选项卡,确保安装了安卓支持库(见图 1-8 )。

A978-1-4842-9701-8_1_Fig8_HTML.jpg

图 1-8。

Android SDK Manager

默认情况下,当您使用项目向导创建项目时,Android Studio 会创建一个src/androidTest/java文件夹,因此您不必创建任何新目录。如果你看不到它,那么检查构建变体窗口中的测试工件是否被设置为 Android Instrumentation Tests(参见图 1-9 )。

A978-1-4842-9701-8_1_Fig9_HTML.jpg

图 1-9。

Build Variant test artifacts

将下面的 Espresso 库(参见清单 1-5 )添加到依赖部分的build.gradle (app)文件中,然后单击立即同步链接。打开 Gradle 控制台,因为这可能需要一两分钟的时间。

Listing 1-5. Espresso Libraries

dependencies {

androidTestCompile ’com.android.support.test:testing-support-lib:0.1’

androidTestCompile ’com.android.support.test.espresso:espresso-core:2.0’

}

清单 1-6 中的代码展示了我们如何设置和运行 GUI 测试来添加 3 + 4,以及我们如何断言这是 7.0。为了测试 Android 活动,我们需要用ActivityInstrumentationTestCase2类扩展CalculatorAddTest。这允许你控制活动。我们在使用getActivity()调用的setUp()方法中实现了这一点。

Listing 1-6. Adding Two numbers Using Espresso

import android.test.ActivityInstrumentationTestCase2;

import static``android.support.test.espresso.Espresso.``onView

import static android.support.test.espresso.action.ViewActions.click;

import static``android.support.test.espresso.action.ViewActions.``typeText

import static``android.support.test.espresso.assertion.ViewAssertions.``matches

import static android.support.test.espresso.matcher.ViewMatchers.withId;

import static android.support.test.espresso.matcher.ViewMatchers.withText;

public class``CalculatorAddTest``extends

public static final String THREE = "3"

public static final String FOUR = "4"

public static final String RESULT = "7.0"

public CalculatorAddTest() {

super``(CalculatorActivity.``class

}

@Override

protected void``setUp()``throws

super .setUp();

getActivity();

}

public void testCalculatorAdd() {

onView``(``withId``(R.id.``operand_one_edit_text``)).perform(``typeText``(``THREE``));

onView``(``withId``(R.id.``operand_two_edit_text``)).perform(``typeText``(``FOUR``));

onView ( withId (R.id. operation_add_btn )).perform( click ());

onView``(``withId``(R.id.``operation_result_text_view``)).check(``matches``(``withText``(``RESULT

}

}

在代码中,我们首先连接到 Calculator 活动,然后使用 ViewMatcher 和 ViewActions 将数字 3 和 4 放在正确的文本字段中。代码然后使用 ViewAction 单击 Add 按钮,最后我们使用 ViewAssertion 确保答案是预期的 7.0。请注意,GUI 将结果显示为双精度值,因此它是 7.0,而不是您可能期望的 7(参见图 1-10 )。

A978-1-4842-9701-8_1_Fig10_HTML.jpg

图 1-10。

Calculator app

图 1-11 显示了结果。在这种情况下,它们看起来非常类似于单元测试中的那些,但是模拟器需要更长的时间来启动。

A978-1-4842-9701-8_1_Fig11_HTML.jpg

图 1-11。

Espresso results

摘要

在这一章中,我们看了 Android 平台上单元测试和 GUI 测试的当前状态。在本书的其余部分,我们将更详细地探讨敏捷测试,这样你就可以看到如何将这些技术应用到你的应用中,以产生更干净、更快、缺陷更少的代码。

二、Android 单元测试

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-9701-8_​2) contains supplementary material, which is available to authorized users.

在 Android Studio 整合 JUnit4 之前,Google 的实现是标准和特定于 Android 的单元测试的奇怪混合。JUnit4 的当前版本是 JUnit 标准的一个更加普通的实现(更多信息请参见 http://junit.org ,源代码请参见 https://github.com/junit-team/junit )。我们在 build.gradle 文件中加载的 JUnit 的当前推荐版本是 4.12

Android 断言

在我们的 Hello,World 示例中,我们使用了assertEquals断言,但是在 JUnit 4.12 中我们也可以使用其他断言(参见表 2-1 )。

表 2-1。

Assertions

| 主张 | 描述 | | --- | --- | | `assertEquals` | 测试两个值是否相同 | | `assertTrue` | 测试布尔条件为真 | | `assertFalse` | 测试布尔条件为假 | | `assertNull` | 检查对象是否为空 | | `assertNotNull` | 检查对象是否不为空 | | `assertSame` | 测试两个值是否引用同一个对象引用 | | `assertNotSame` | 测试两个值是否不引用同一个对象引用 | | `assertThat` | 测试第一个值(对象)是否匹配第二个值(或匹配器) | | `fail` | 测试应该总是失败 |

如果您添加了 Hamcrest、AssertJ 或任何其他断言库,还可以使用许多其他断言。但是现在让我们从基本的 JUnit 断言开始。

assertTrueassertFalse用于检查布尔条件的值。提供了assertFalse而不是assertTrue(!somethingYouExpectToReturnFalse)(例如assertTrue (5 < 6)assertFalse (5>6))。

assertNullassertNotNull检查对象是否为空(如assertNull(Calculator)assertNotNull(Calculator))。

assertSameassertNotSame检查这两个对象是对assertSame的同一个对象的引用还是对assertNotSame的同一个对象的引用。这与 equals 不同,equals 比较两个对象的值,而不是对象本身。

assertThat 可以像assertEquals一样使用,我们现在可以说assertThat(is(7), mCalculator.add(3, 4)),而不是说assertEquals(7, mCalculator.add(3,4), 0)

失败仅仅是一个失败的测试,代表那些不应该被触及的代码,或者告诉你“这里有龙”

命令行

可以从命令行使用以下命令运行单元测试:gradlew test --continue.``gradlew任务运行单元测试,continue告诉gradlew如果任何单元测试失败,不要停止,这正是我们想要的。

C:\AndroidStudioProjects\BasicSample>gradlew test --continue

Downloadinghttps://services.gradle.org/distributions/gradle-2.2.1-all.zip

............................................................................

..................................................

Unzipping C:\Users\godfrey\.gradle\wrapper\dists\gradle-2.2.1-all\6dibv5rcnnqlfbq9klf8imrndn\gradle-2.2.1-all.zip to C:\Users\godfrey\.gradle\wrapper\dists\gradle-2.2.1-all\6dibv5rcnnqlfbq9klf8imrndn

Downloadhttps://jcenter.bintray.com/com/google/guava/guava/17.0/guava-17.0.jar

Downloadhttps://jcenter.bintray.com/com/android/tools/lint/lint-api/24.2.3/lint-api-24.2.3.jar

Downloadhttps://jcenter.bintray.com/org/ow2/asm/asm-analysis/5.0.3/asm-analysis-5.0.3.jar

Downloadhttps://jcenter.bintray.com/com/android/tools/external/lombok/lombok-ast/0.2.3/lombok-ast-0.2.3.jar

:app:preBuild UP-TO-DATE

:app:preDebugBuild UP-TO-DATE

:app:checkDebugManifest

:app:prepareDebugDependencies

:app:compileDebugAidl

:app:compileDebugRenderscript

.

.

.

:app:compileReleaseUnitTestSources

:app:assembleReleaseUnitTest

:app:testRelease

:app:test

BUILD SUCCESSFUL

Total time: 3 mins 57.013 secs

您可能希望从命令行运行您的测试,尤其是第一次运行单元测试时,使用gradlew test --continue命令以便您可以看到发生了什么,或者打开 Android Studio 中的 gradle 控制台。否则你可能会奇怪为什么 Android Studio 下载了运行单元测试所需的所有文件却什么也没发生。

如果您使用持续集成构建工具,如 Jenkins,命令行测试执行也非常有用。

JUnit 选项

JUnit4 有以下注释

  • @Before
  • @After
  • @Test
  • @BeforeClass
  • @AfterClass
  • @Test(timeout=ms)

@Test用于注释所有的测试方法(见清单 2-1 ,没有它,方法将不能作为测试运行。@Test(timeout=ms)是标准标注上的一点小皱纹;如果测试花费的时间超过了以毫秒为单位定义的超时时间,它只是说放弃。

Listing 2-1. @Test Method

@Test

public void calculator_CorrectSub_ReturnsTrue() {

assertEquals(1, mCalculator.sub(4, 3),0);

}

@Before@After用于您需要的任何设置和拆卸功能。例如,@Before可以包含代码来写入日志文件,或者创建测试中使用的对象,或者打开数据库,然后用测试数据播种数据库。@After通常用于撤销任何这些@Before变更,比如删除数据库中的测试行,等等(参见清单 2-2 )。

Listing 2-2. @Before and @After Annotations

public class CalculatorTest {

private Calculator mCalculator;

@Before

public void setUp() {

mCalculator = new Calculator();

}

@Test

public void calculator_CorrectAdd_ReturnsTrue() {

assertEquals(7, mCalculator.add(3, 4),0);

}

@Test

public void calculator_CorrectSub_ReturnsTrue() {

assertEquals(1, mCalculator.sub(4, 3),0);

}

@Test

public void calculator_CorrectMul_ReturnsTrue() {

assertEquals(12, mCalculator.mul(3, 4),0);

}

@Test

public void calculator_CorrectDiv_ReturnsTrue() {

assertEquals(3, mCalculator.div(12, 4),0);

}

@After

public void tearDown() {

mCalculator = null;

}

}

@Before@After在每次测试前被调用,但是如果你想在所有测试前和所有测试后分别改变一次设置,那么你应该使用@BeforeClass@AfterClasssetUp方法现在是setUpBeforeClass而不是setUpBeforeTest。在我们下面的@BeforeClass示例中,setUptearDown方法现在被声明为公共静态。Calculator被定义为静态的(参见清单 2-3 ,所以现在只有一个Calculator的实例,而不是每个测试一个。

Listing 2-3. Using @BeforeClass Annotation Instead of @Before

private static Calculator mCalculator;

@BeforeClass

public static void setUp() {

mCalculator = new Calculator();

}

HTML 输出

JUnit 在<path_to_your_project>/app/build/test-results/debug目录中输出 HTML 和 XML 风格的报告。当您试图准确跟踪一个或多个类何时开始失败,或者某个包或类比其他包或类更容易失败时,这些报告主要用作参考(见图 2-1 )。

A978-1-4842-9701-8_2_Fig1_HTML.jpg

图 2-1。

HTML reporting

如果您需要将结果导入到另一个工具中,同一目录中还有一个 XML 输出。

分组测试

随着你的单元测试的增长,根据它们需要的时间将它们分成小型、中型或大型测试并不是一个坏主意。当您编码时,编写和执行单元测试应该非常快,但是可能有更全面的测试,您可能希望每天运行一次或者在构建被签入时运行。

图 2-2 摘自一个旧的谷歌测试博客(见 http://googletesting.blogspot.com/2010/12/test-sizes.html ),它很好地展示了什么时候你应该将你的测试分成中型或大型测试,这样它们就不会减慢开发过程。

A978-1-4842-9701-8_2_Fig2_HTML.jpg

图 2-2。

Grouping unit tests into categories

小型测试将是普通的基于方法的单元测试,带有模拟的数据库或网络访问(稍后将详细介绍)。因为 Espresso 测试需要模拟器或设备来运行,所以它们会自动被视为中型或大型测试。

清单 2-4 展示了用必要的import语句标注测试是小型还是中型的正常方式。

Listing 2-4. Classic Unit Testing Grouping

导入 Android . test . suite builder . annotation . small test;

导入 Android . test . suite builder . annotation . medium test;

@SmallTest

public void calculator_CorrectAdd_ReturnsTrue() {

assertEquals(mCalculator.add(3, 4),7,0);

}

@SmallTest

public void calculator_CorrectSub_ReturnsTrue() {

assertEquals(mCalculator.sub(4, 3),1,0);

}

@MediumTest

public void calculator_CorrectMul_ReturnsTrue() {

assertEquals(mCalculator.mul(3, 4),12,0);

}

@MediumTest

public void calculator_CorrectDiv_ReturnsTrue() {

assertEquals(mCalculator.div(12, 4),3,0);

}

参数化测试

如果我们想测试我们的计算器,我们将不得不做更多的测试,而不仅仅是数字 3 和 4 的加、减、乘、除组合。清单 2-5 还有一些测试,让我们对我们的实现更有信心。运行测试,他们都通过了。

Listing 2-5. Adding More Test Conditions

@Test

public void calculator_CorrectAdd_ReturnsTrue() {

assertEquals``(7,``mCalculator

assertEquals``(7,``mCalculator

assertEquals``(10,``mCalculator

assertEquals``(3,``mCalculator

assertEquals``(3260,``mCalculator

}

如果你正在编写单元测试,我猜你总是在寻找编写更好代码的方法,你会认为清单 2-5 中的代码很糟糕。所有那些硬编码看起来都不对,即使是测试代码。我们可以使用 JUnit 的参数化测试来解决这个问题。

重构代码以添加参数化测试,如下所示:

  • 在类的顶部添加@RunWith(Parameterized.class),告诉编译器我们正在使用参数进行测试
  • 添加import语句,import static org.junit.runners.Parameterized.Parameters;
  • 创建您的测试参数集合,在本例中是operandOneoperandTwoexpectedResult
  • 添加该类的构造函数
  • 使用这些参数来支持您的测试

清单 2-6 显示了完整的代码。为了简单起见,我们将代码转换为只处理整数。

Listing 2-6. Paramaterized Testing Example

import org.junit.Before;

import org.junit.Test;

import org.junit.runner.RunWith;

import org.junit.runners.Parameterized;

import java.util.Arrays;

import java.util.Collection;

import static org.junit.Assert.assertEquals;

import static org.junit.runners.Parameterized.Parameters;

@RunWith(Parameterized.``class

public class CalculatorParamTest {

private int mOperandOne ;

private int mOperandTwo ;

private int mExpectedResult ;

private``Calculator``mCalculator

/* Array of tests */

@Parameters

public static Collection<Object[]> data() {

return Arrays. asList ( new

{3, 4, 7},

{4, 3, 7},

{8, 2, 10},

{-1, 4, 3},

{3256, 4, 3260}

});

}

/* Constructor */

public CalculatorParamTest( int mOperandOne, int mOperandTwo, int mExpectedResult) {

this``.``mOperandOne

this``.``mOperandTwo

this``.``mExpectedResult

}

@Before

public void setUp() {

mCalculator``=``new

}

@Test

public void testAdd_TwoNumbers() {

int resultAdd = mCalculator .add( mOperandOne , mOperandTwo );

assertEquals(resultAdd,``mExpectedResult

}

}

当代码运行时,我们在统计框架中得到以下结果(参见图 2-3 )。

A978-1-4842-9701-8_2_Fig3_HTML.jpg

图 2-3。

Parameterized test results

摘要

在这一章中,我们更详细地研究了单元测试。在下一章中,我们将会看到一些你想要添加到你的单元测试工具带上的第三方工具。在本书的后面,我们将回到单元测试,展示如何在 TDD(测试驱动开发)环境中编写单元测试。

三、第三方工具

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-9701-8_​3) contains supplementary material, which is available to authorized users.

JUnit 本身可能就是您所需要的,但是您可以将许多优秀的第三方工具附加到 JUnit 上,让您的 Android 测试大放异彩。

在本章中,我们将了解以下工具:

  • Hamcrest 寻找更好的断言
  • 这样我们可以测量我们的 JUnit 代码覆盖率
  • 这样我们就可以将单元测试集中在代码上
  • 这样我们就可以测试我们的机器人活动
  • Jenkins 让我们的测试自动化

哈姆克雷斯特断言

除了简单的 Hello,World-type 应用可能还需要比 JUnit 4.x 更好的断言。它还提供了更多的灵活性,允许您现在包括范围,而不仅仅是单个值。正如 Hamcrest 文档所说,Hamcrest 允许您创建“可以组合起来创建灵活的意图表达的匹配器”表 3-1 列出了大多数可用的 Hamcrest 断言,您也可以编写自己的断言。

表 3-1。

Hamcrest Assertions

| 包裹 | 断言 | | --- | --- | | 核心匹配者 | `allOf, any, anyOf, anything, array, both, containsString, describedAs, either, endsWith, equalTo, everyItem, hasItem, hasItems, instanceOf, is, isA, not, notNullValue, nullValue, sameInstance, startsWith, theInstance` | | 匹配项 | `allOf, any, anyOf, anything, array, arrayContaining, arrayContainingInAnyOrder, arrayWithSize, both, closeTo, comparesEqualTo, contains, containsInAnyOrder, containsString, describedAs, either, empty, emptyArray, emptyCollectionOf, emptyIterable, emptyIterableOf, endsWith, equalTo, equalToIgnoringCase, equaltToIgnoringWhiteSpace, eventFrom, everyItem, greaterThan, greaterThanOrEqualTo, hasItem, hasItemInArray, hasItems, hasKey, hasProperty, hasSize, hasToString, hasValue, hasXPath, instanceOf, is, isA,isEmptyOrNullString, isIn, isOneOf, iterableWithSize, lessThan, lessThanOrEqualTo, not, notNullValue, nullValue, sameInstance, samePropertyValueAs, startsWith, stringContainsInOrder, theInstance, typeCompatibleWith` | | 情况 | `and, matched, matching, notMatched, then` | | 火柴插入 | `assertThat` |

清单 3-1 展示了如何将 Hamcrest 库添加到您的build.gradle文件中,以便在您的应用中包含 Hamcrest 功能。记得点击“立即同步”按钮。

Listing 3-1. Adding Hamcrest Library Dependency

dependencies {

testCompile ’junit:junit:4.12’

testCompile ’org.hamcrest:hamcrest-library:1.3’

}

现在我们重构我们的测试,让它们读起来更像英语(见清单 3-2 )。

Listing 3-2. Hamcrest Assertions

@Test

public void calculator_CorrectHamAdd_ReturnsTrue() {

assertThat("Calculator cannot add 3 plus 4", is(7), mCalculator.add(3, 4));

}

我们还可以使用greaterThanLessThan断言向我们的测试添加范围(参见清单 3-3 )。

Listing 3-3. greaterThan and lessThan

public void calculator_CorrectHamAdd_ReturnsTrue() {

assertThat("Greater than failed", greaterThan(6), mCalculator.add(3, 4));

assertThat("Less than failed", lessThan(8), mCalculator.add(3, 4));

}

或者,我们可以使用both命令将两者结合起来(参见清单 3-4 )。

Listing 3-4. Using the both Matcher

@Test

public void calculator_CorrectHamAdd_ReturnsTrue() {

assertThat("Number is out of range", both(greaterThan(6)).and(lessThan(8)), mCalculator.add(3, 4),);

}

我们只是触及了 matchers 的皮毛,但毫无疑问,您可以看到 Hamcrest 可以让我们的测试变得多么强大。

杰柯

单元测试需要某种形式的代码覆盖来找到代码中任何未测试的部分。代码覆盖工具输出代码度量报告和带注释的代码,以显示哪些代码已经过单元测试(绿色),哪些没有被单元测试覆盖(红色)。图 3-1 显示了取自eclemma.org网站的 JaCoCo 的代码覆盖率数据。

A978-1-4842-9701-8_3_Fig1_HTML.jpg

图 3-1。

Code coverage example

代码覆盖率度量测量了多少源代码已经过单元测试。就我个人而言,我不太相信在 Android 项目中有一个代码覆盖度量目标;它应该作为一个指南,而不是一个强制性的要求。然而,如果一个项目只有 5%的代码覆盖率,那么你就不是在真正地进行单元测试,而只是口头上支持这项技术。

Android Studio 将调用 JaCoCo 来完成单元测试的代码覆盖报告,但是您需要执行以下任务:

  • build.gradle文件中设置testCoverageEnabled为真
  • 将代码覆盖率运行器更改为 JaCoCo
  • 使用代码覆盖率运行单元测试
  • 查看代码覆盖率

要在 Android 项目中包含代码覆盖率,在build.gradle文件中的调试buildTypes中将testCoverageEnabled设置为 true(参见清单 3-5 ,并在做出更改后点击 Sync now。

Listing 3-5. build.gradle JaCoCo Changes

buildTypes {

debug {

testCoverageEnabled true

}

}

要编辑配置,转到运行➤编辑配置(见图 3-2 )。

A978-1-4842-9701-8_3_Fig2_HTML.jpg

图 3-2。

Choose Edit Configurations

点击 Code Coverage 选项卡,并将 coverage runner 更改为 JaCoCo(参见图 3-3 )。

A978-1-4842-9701-8_3_Fig3_HTML.jpg

图 3-3。

Changing coverage runner

通过右键单击方法并选择 Run CalculatorTest with Coverage 来运行测试(参见图 3-4 )。

A978-1-4842-9701-8_3_Fig4_HTML.jpg

图 3-4。

Run Calculator Test with Coverage

代码覆盖率报告显示在覆盖率选项卡中(见图 3-5 ,在这里你可以看到我们的Calculator方法有 50%的代码覆盖率。

A978-1-4842-9701-8_3_Fig5_HTML.jpg

图 3-5。

Code coverage tests

方法中显示了红色/绿色的代码覆盖率,尽管可能很难看到(参见图 3-6 )。Android Studio 中的代码覆盖集成是新的。毫无疑问,在未来的版本中会更容易看到红/绿覆盖。

A978-1-4842-9701-8_3_Fig6_HTML.jpg

图 3-6。

Code coverage

莫基托

在第二章的“分组测试”部分,我们讨论了小型、中型和大型测试。实际上,单元测试应该总是小测试。但是如果我们进行网络连接或者从文件系统或数据库中读取数据,那么根据定义,我们不是在执行小单元测试。我们还假设第三方 web 服务或数据库可能不会在我们每次运行测试时运行。因此,最坏的情况是,我们的测试将会失败,但原因是错误的(例如,网络中断)。我们使用模仿框架来模仿任何与外部资源对话的代码,并将我们所有的单元测试返回给更小的团队。Mockito 与 Android Studio 配合得非常好,所以我们将在本章和后续章节中使用该工具。

清单 3-6 展示了如何通过包含 testCompile ’org.mockito:mockito-core:1.10.19’库来将 Mockito 库添加到您的build.gradle文件中。再次记住,完成后点击“立即同步”链接。

Listing 3-6. Adding Mockito Library

dependencies {

testCompile ’junit:junit:4.12’

testCompile ’org.hamcrest:hamcrest-library:1.3’

testCompile ’org.mockito:mockito-core:1.10.19’

}

谷歌的 Android 样本有一个名为 NetworkConnect 的网络应用,你可以在 https://github.com/googlesamples/android-NetworkConnect 找到它。图 3-7 显示了返回谷歌网页 HTML 的应用的基本功能。

A978-1-4842-9701-8_3_Fig7_HTML.jpg

图 3-7。

NetworkConnect app

在模拟代码之前,我们需要将网络访问代码剪切并粘贴到它自己的类中(参见清单 3-7 ,我们称之为DownloadUrl)。

Listing 3-7. DownloadUrl Code

public class DownloadUrl {

public String loadFromNetwork(String urlString) throws IOException {

InputStream stream = null;

String str ="";

try {

stream = downloadUrl(urlString);

str = readIt(stream, 88);

} finally {

if (stream != null) {

stream.close();

}

}

return str;

}

public InputStream downloadUrl(String urlString) throws IOException {

URL url = new URL(urlString);

HttpURLConnection conn = (HttpURLConnection) url.openConnection();

conn.setReadTimeout(10000 /* milliseconds */);

conn.setConnectTimeout(15000 /* milliseconds */);

conn.setRequestMethod("GET");

conn.setDoInput(true);

conn.connect();

InputStream stream = conn.getInputStream();

return stream;

}

public String readIt(InputStream stream, int len) throws IOException, UnsupportedEncodingException {

Reader reader = null;

reader = new InputStreamReader(stream, "UTF-8");

char[] buffer = new char[len];

reader.read(buffer);

return new String(buffer);

}

}

MainActivity现在如下调用DownloadUrl(参见清单 3-8 )。

Listing 3-8. Updated NetworkConnect MainActivity Code

private class DownloadTask extends AsyncTask<String, Void, String> {

DownloadUrl htmlStr = new DownloadUrl();

@Override

protected String doInBackground(String… urls) {

try {

return htmlStr.loadFromNetwork(urls[0]);

} catch (IOException e) {

return getString(R.string.connection_error);

}

}

/**

* Uses the logging framework to display the output of the fetch

* operation in the log fragment.

*/

@Override

protected void onPostExecute(String result) {

Log.i(TAG, result);

}

}

我们现在可以编写一个单元测试,看看DownloadUrl代码是否在我们的单元测试中返回 HTML(参见清单 3-9 )。

Listing 3-9. Network Connect Unit Test

public class DownloadUrlTest {

DownloadUrl tDownloadUrl;

String htmlStr;

@Before

public void setUp() {

try {

htmlStr = tDownloadUrl.loadFromNetwork("http://www.google.com

} catch (IOException e) {

// network error

}

}

@Test

public void downloadUrlTest_ReturnsTrue() {

assertThat(htmlStr,containsString("doctype"));

}

}

因为我们正在进行网络调用,所以我们应该使用 Mockito 模拟网络访问。对于这个例子,我们只需要做几件事来模拟 web 服务器调用。首先模拟这个类,这样 Mockito 就知道它需要替换什么功能。接下来,告诉 Mockito 当使用Mockito.when().thenReturn()格式调用您正在测试的方法时要返回什么,格式如下:Mockito.when(tDownloadUrl.loadFromNetwork(" http://www.google.com ")。然后回车("<!doctype html><html itemscope=\"\" itemtype=\" http://schema.org/WebPage\ " lang=\"en\"><head>");

现在,当进行loadFromNetwork调用时,它将返回我们的部分网页,而不是 www.google.com 网页的实际 HTML(参见清单 3-10 )。您可以通过打开和关闭网络访问来测试这一点。

Listing 3-10. Mocked Network Access

@RunWith(MockitoJUnitRunner.class)

public class DownloadUrlTest {

public DownloadUrl tDownloadUrl = Mockito.mock(DownloadUrl.class);

@Before

public void setUp() {

try {

Mockito.when(tDownloadUrl.loadFromNetwork(" http://www.google.com”)。然后返回("/root > html><html itemscope=\"\" itemtype=\" http://schema.org/WebPage\ " lang=\"en\"><head>");

} catch (IOException e) {

// network error

}

}

@Test

public void downloadUrlTest_ReturnsTrue() {

try {

assertThat(tDownloadUrl.loadFromNetwork("http://www.google.com

} catch (IOException e) {

//

}

}

}

我们将在下一章回到 Mockito,并向您展示如何模拟数据库和共享首选项访问,以及如何使用其他工具来扩展 Mockito 功能,以帮助分离或分离您的代码。

机器人电器

除非测试安卓活动,否则无法测试安卓应用。你可以使用像 Mockito 和 JUnit 这样的工具来测试它,但是如果你不测试它的活动,你就错过了应用的一个关键元素。如果你不测试这些活动向你的用户显示了什么,你就不能确定你的应用显示了正确的信息。使用模拟器测试框架,如 Espresso 或葫芦,这相对容易。但是,如果我们使用 Robolectric,我们也可以在没有仿真器的情况下测试它。

要安装 Robolectric 3.0,请将以下依赖项添加到 build.gradle 文件中(参见清单 3-11 )。

Listing 3-11. Adding Robolectric Library Dependency

dependencies {

testCompile ’junit:junit:4.12’

testCompile ’org.robolectric:robolectric:3.0’

}

您还需要对您的应用配置进行更改。转到运行-编辑配置,如果你在 Mac 或 Linux 上运行,然后将工作目录更改为$MODULE_DIR$,或者如果你在 Windows 机器上运行,在工作目录的末尾添加一个\app(参见图 3-8 )。

A978-1-4842-9701-8_3_Fig8_HTML.jpg

图 3-8。

Robolectric Working Directory fix

清单 3-12 显示了一个使用 Robolectric 测试 Hello World 显示在MainActivity上的单元测试。请注意将目标 SDK 设置为 API 21 并告诉 Robolectric 在哪里可以找到AndroidManifest.xml文件的配置信息。

Listing 3-12. Robolectric Hello World

@RunWith(RobolectricGradleTestRunner.class)

@Config(constants = BuildConfig.class, sdk = 21, manifest = "src/main/AndroidManifest.xml")

public class RobolectricUnitTest {

@Test

public void shouldHaveHappySmiles() throws Exception {

String hello = new MainActivity().getResources().getString(R.string.hello_world);

assertThat(hello, equalTo("Hello world!"));

}

}

以与运行单元测试相同的方式运行测试,方法是右键单击测试类名并选择“运行 RobolectricTest”。不需要仿真器就可以通过测试。相对而言,Robolectric 测试比 JUnit4 测试耗时更长,但仍比模拟器测试快得多。

A978-1-4842-9701-8_3_Fig9_HTML.jpg

图 3-9。

Robolectric Hello World test passes

詹金斯

转向敏捷过程会产生相当大的开销。令人欣慰的是,我们不再需要担心模拟器花这么长时间来启动普通的 JUnit 测试。现在需要几秒钟而不是几分钟。然而,随着应用的增长,相应的单元测试数量也在增长,最终手动运行测试将需要时间。正确构建和测试应用的步骤也将开始变得更加复杂。由于人类不擅长繁琐的多步骤过程,因此使用持续集成(CI)服务器来尽可能地自动化该过程以减少任何不必要的测试错误是有意义的。

出于我们的目的,我们将使用 Jenkins,因为它有如此多的可用插件。但是,如果您更熟悉这些 CI 环境,还有许多其他选项,如 Travis、TeamCity 或 Bamboo,也可以同样很好地工作。

安装

http://jenkins-ci.org/ 下载 Jenkins 服务器。安装并转到http://localhost:8080,你应该会看到如图 3-10 所示的屏幕。

A978-1-4842-9701-8_3_Fig10_HTML.jpg

图 3-10。

Jenkins start-up screen

配置 Jenkins

为了让它在我们的 Android 环境中有用,我们需要添加一些插件。点击管理詹金斯➤管理插件(见图 3-10 ),搜索并添加 Gradle 和 GIT 插件或您使用的任何其他源代码管理系统。当你完成后,你安装的插件屏幕应该看起来如图 3-11 所示。

A978-1-4842-9701-8_3_Fig11_HTML.jpg

图 3-11。

Installed plug-ins

接下来我们需要配置 Jenkins,这样它就知道你在哪里安装了 Android。点击管理詹金斯➤配置系统,向下滚动到全局属性,点击环境变量复选框,输入安装 Android 的目录ANDROID_HOME(见图 3-12 )。

A978-1-4842-9701-8_3_Fig12_HTML.jpg

图 3-12。

Setting Environment variables

创建自动化作业

现在我们已经配置了 Jenkins,我们需要创建我们的第一个自动化作业。返回仪表板,点击创建新工作(图 3-10 )。输入您的项目名称并选择自由式项目(参见图 3-13 )。

A978-1-4842-9701-8_3_Fig13_HTML.jpg

图 3-13。

Creating a new item

我们需要告诉詹金斯哪里能找到密码。在这个例子中,我们使用 Git 作为我们的源代码管理系统。这里我们再次使用 Google NetworkConnect 示例。输入 Git 存储库 URL。因为是公开回购,没有凭证,所以我们要跳过这一步。也只有一个分支,所以我们可以把分支说明符留为master(见图 3-14 )。

A978-1-4842-9701-8_3_Fig14_HTML.jpg

图 3-14。

Enter Network Connect repository details

向下滚动到构建部分并选择调用 Gradle 脚本(参见图 3-15 )。

A978-1-4842-9701-8_3_Fig15_HTML.jpg

图 3-15。

Invoke Gradle script

在构建步骤中,选择使用 Gradle Wrapper,选中使 gradlew 可执行并从根构建脚本目录。在开关部分输入--refresh-dependencies--profile。在这种情况下,在 Tasks 部分输入 assemble。点击保存(见图 3-16 )。

A978-1-4842-9701-8_3_Fig16_HTML.jpg

图 3-16。

Configure the Build

现在我们已经准备好构建我们的应用了。点击项目页面上的立即构建(见图 3-17 )。

A978-1-4842-9701-8_3_Fig17_HTML.jpg

图 3-17。

Project page

一旦构建开始,您将看到一个进度指示器,以了解您的任务进展如何。如果你想知道发生了什么,点击构件号(见图 3-18 )。

A978-1-4842-9701-8_3_Fig18_HTML.jpg

图 3-18。

View Build progress

现在点击控制台输出,你可以看到发生了什么,就像你从命令行运行应用一样(见图 3-19 )。

A978-1-4842-9701-8_3_Fig19_HTML.jpg

图 3-19。

Click Console Output

在我们的示例中,没有错误,构建成功(参见图 3-20 )。如果不是这种情况,那么控制台输出页面对于查看失败的原因非常有帮助。

A978-1-4842-9701-8_3_Fig20_HTML.jpg

图 3-20。

Console Output

我们将在本书的后面使用 Jenkins 来自动化我们的 JUnit 和 Espresso 测试。

摘要

在这一章中,我们已经看到了一些工具,我们将在整本书中使用它们来使我们的测试更加有效和高效。在最近的过去,让这个堆栈运行起来是一件非常令人沮丧的任务,但幸运的是,现在不再是这样了。

四、模拟

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-9701-8_​4) contains supplementary material, which is available to authorized users.

无论是否在 Android 平台上,主要目标之一是隔离我们正在测试的代码。当我们编写测试时,我们希望测试特定类的方法,而不与应用中的其他类或任何外部元素(如 web 服务)进行任何相关的交互。我们应该测试一个单独的方法,而不是它的依赖项,并且这个方法应该是测试覆盖的唯一代码,其他的都被模拟了。

模拟这些第三方交互是一种很好的方式,可以帮助我们在方法周围设置围栏,这样我们在进行测试时就不依赖于网络或设备的位置或美国或英国时间。测试失败的唯一原因是因为代码有问题,而不是因为外部依赖(如 wifi)不起作用。

但是我们想要使用模拟框架还有另一个主要的特定于 Android 的原因,那是因为我们希望我们所有的测试都是可以在没有仿真器的情况下运行的测试。模仿依赖关系可以让您的测试运行速度比可怕的替代方法快几个数量级,后者是等待几分钟模拟器启动。当然有时你需要使用仿真器,比如当你测试活动时(见第五章),但是如果你不测试活动,mocking 给你信心将你的测试注释为@SmallTest而没有仿真器开销。

在这一章中,我们将看看如何使用 Mockito 来模拟以下测试隔离和更快测试执行的交互。

  • 共享偏好
  • 时间
  • 设置
  • SQLite 数据库

我们也已经在第二章中简要介绍了 web 服务。

共享偏好

共享偏好通常以 xml 文件的形式存储在设备的/data/data/<name of your package>文件夹中。正常情况下,任何需要文件系统访问的测试都意味着使用仿真器,除非我们使用 Mockito。

在我们的示例中,为了展示这是如何工作的,我们将使用一个简单的登录应用。除了让你用用户名、密码和电子邮件地址登录,然后在第二页上显示这些信息外,它没有做太多事情(见图 4-1 )。

A978-1-4842-9701-8_4_Fig1_HTML.jpg

图 4-1。

Registration app

在我们的假应用中,我们希望显示用户已经注册,因此用户第一次登录时,我们将写入应用的共享首选项。清单 4-1 显示了写入共享首选项文件的代码。该方法将活动和字符串作为其参数。完整的代码可在 Apress 网站的源代码/下载区获得。

Listing 4-1. Saving to the Shared Preferences

public void saveSharedPreferences(Activity activity, String spValue) {

SharedPreferences preferences = activity.getPreferences(Activity.MODE_PRIVATE);

preferences.edit().putString(SHAREDPREF, spValue).apply();

}

清单 4-2 显示了查看存储在我们共享首选项中的值的调用。

Listing 4-2. Reading from Shared Preferences

public String getSharedPreferences(Activity activity) {

SharedPreferences preferences = activity

.getPreferences(Activity.MODE_PRIVATE);

return preferences.getString(SHAREDPREF, "Not registered");

}

在 Android 模拟器上运行应用,并输入您的登录凭据。您可以通过在模拟器上运行adb shell命令来查看共享首选项中存储的内容(参见清单 4-3 )。它也可以在根设备上工作。

Listing 4-3. Login App’s Shared Preference

>adb shell

root@generic:/ # cd /data/data/com.riis.hellopreferences/shared_prefs

root@generic:/data/data/com.riis.hellopreferences/shared_prefs # ls

MainActivity.xml

root@generic:/data/data/com.riis.hellopreferences/shared_prefs # cat MainActivity.xml

<?xml version=’1.0’ encoding=’utf-8’ standalone=’yes’ ?>

<map>

<string name="registered">true</string>

</map>

共享首选项内置于 Android 功能中,这意味着我们不需要测试它。在一个真实的应用中,我们可能想要测试我们的代码,假设用户在应用被测试时已经注册。清单 4-4 展示了对getSharedPreferences方法sharedPreferencesTest_ReturnsTrue的模拟调用。

Listing 4-4. Mocked getSharedPreferences

// Annotation to tell compiler we’re using Mockito

@RunWith(MockitoJUnitRunner.class)

public class UserPreferencesTest {

// Use Mockito to initialize UserPreferences

public UserPreferences tUserPreferences = Mockito.mock(UserPreferences.class);

private Activity tActivity;

@Before

public void setUp() {

// setup the test infrastructure

// Use Mockito to declare the return value of getSharedPreferences()

Mockito.when(tUserPreferences.getSharedPreferences(tActivity)).thenReturn("true");

}

@Test

public void sharedPreferencesTest_ReturnsTrue() {

// Perform test

Assert.assertThat(tUserPreferences.getSharedPreferences(tActivity), is("true"));

}

}

总是返回 true,这样我们就可以绕过共享的偏好,继续我们测试中重要的事情。在本例中,我们修改了共享首选项代码,使其始终返回 true。主要是因为它从来没有真正运行过共享首选项代码。setup 块告诉 Mockito 您希望它如何运行,该类的模拟版本将按照指示运行,总是返回 true。

时间

利用接口可能是一种非常有用的模仿技术。例如,如果我们有一个Clock接口,它调用一个Clock实现类来告诉时间,那么我们使用 Mockito 模仿接口Clock类来提供我们自己的 Android 日期/时间环境。接口抽象允许我们隐藏实现,这样我们可以完全控制时区和一天中的时间,并创建更多的边缘案例测试来真正运行我们的代码。这是一个简单的“编码到接口”的例子。接口是我们在编写代码时试图满足的契约。然而,当测试实现时,接口可以与真实的实现、模拟的实现或者两者的组合进行对话。

清单 4-5 显示了Clock接口代码。

Listing 4-5. Clock Interface

import java.util.Date;

public interface Clock {

Date getDate();

}

清单 4-6 显示了Clock的实现代码。

Listing 4-6. ClockImpl Code

import java.util.Date;

public class ClockImpl implements Clock {

@Override

public Date getDate() {

return new Date();

}

}

这里的概念就像共享偏好一样。我们不必测试任何java.util.Date功能;我们只想测试我们编写的使用它的代码。清单 4-7 有几个简单的方法,以毫秒为单位将时间加倍到三倍。

Listing 4-7. Timechange Code

public class TimeChange {

private final Clock dateTime;

public TimeChange(final Clock dateTime) {

this.dateTime = dateTime;

}

public long getDoubleTime(){

return dateTime.getDate().getTime()*2;

}

public long getTripleTime(){

return dateTime.getDate().getTime()*3;

}

}

在我们的测试代码中(参见清单 4-8 ,我们模拟出Clockjava.util.Date类,这允许我们将时间设置为我们想要的任何值,并运行一些断言来确保我们的doubleTimetripleTime方法按预期运行。

Listing 4-8. TimeChangeTest Code

// Tell Android we’re using Mockito

@RunWith(MockitoJUnitRunner.class)

public class TimeChangeTest {

private TimeChange timeChangeTest;

@Before

public void setUp() {

// Mock the Date class

final Date date = Mockito.mock(Date.class);

Mockito.when(date.getTime()).thenReturn(10L);

// Mock the Clock class interface final Clock dt = Mockito.mock(Clock.class);

Mockito.when(dt.getDate()).thenReturn(date);

timeChangeTest = new TimeChange(dt);

}

@Test

public void timeTest() {

final long doubleTime = timeChangeTest.getDoubleTime();

final long tripleTime = timeChangeTest.getTripleTime();

assertEquals(20, doubleTime);

assertEquals(30, triple Time);

}

}

系统属性

如果我们想避免使用模拟器进行测试,我们需要伪造任何 Java 或内置的 Android 功能。在大多数情况下,这正是我们正在寻找的;正如我们在前面的例子中看到的,我们没有测试共享偏好功能或日期功能。同样,我们也不想测试 Android 设置(比如音频管理器)。

我们的AudioHelper代码只有一个方法maximizeVolume。清单 4-9 显示了我们最大化音量的代码。

Listing 4-9. Testing the Max-Min Limits of Our Code

import android.media.AudioManager;

public class AudioHelper {

public void maximizeVolume(AudioManager audioManager) {

int max = audioManager.getStreamMaxVolume(AudioManager.STREAM_RING);

audioManager.setStreamVolume(AudioManager.STREAM_RING, max, 0);

}

}

清单 4-10 显示了我们将铃声设置到最大音量的测试代码。

Listing 4-10. Max Volume Limits

/**

* Unit tests for the AudioManager logic.

*/

// Define the test as SmallTest for grouping tests

@SmallTest

public class AudioHelperTest {

private final int MAX_VOLUME = 100;

@Test

public void maximizeVolume_Maximizes_Volume() {

// Create a mockAudioManager object using Mockito

AudioManager audioManager = Mockito.mock(AudioManager.class);

// Inform Mockito what to return when audioManager.getStreamMaxVolume is called Mockito.when(audioManager.getStreamMaxVolume(AudioManager.STREAM_RING)).thenReturn(MAX_VOLUME);

// Run method we’re testing, passing mock AudioManager

new AudioHelper().maximizeVolume(audioManager);

//verify with Mockito that setStreamVolume to 100 was called.

Mockito.verify(audioManager).``setStreamVolume

}

}

我们创建 mock AudioManager对象,并告诉我们的测试代码在我们进行调用时返回MaxVolume,然后我们验证 Mockito 在进行调用时将音量设置为最大。

数据库ˌ资料库

共享首选项对于将参数、URL(统一资源定位器)或 API(应用编程接口)密钥存储到第三方库非常有用,但对于大量的表格数据就不那么好了。如果你想在手机上保存 Android 中的大量电子表格类型的数据,那么更常见的是使用 SQLite 数据库进行存储,因为它是免费的、轻量级的,并且可以处理数十到数千行数据。如果您需要升级到更大的数据集,那么您更有可能将它们存储在后端服务器上,而不是设备本身。

使用我们的示例应用(再次参见图 4-1 ,我们可以将用户名和电子邮件添加到 SQLite 数据库中。要写入 SQLite 数据库,你需要SQLHelper代码(参见清单 4-11 )。这是用于 Android SQLite 应用的典型样板代码。它创建并升级数据库及其表。在这种情况下,Users表中有一列是自动生成的 ID 以及用户名和电子邮件地址。

Listing 4-11. SQLite Code to Create User Database

public class SQLHelper extends SQLiteOpenHelper {

private static final int DATABASE_VERSION = 1;

private static final String DATABASE_NAME = "UserDb";

private static final String TABLE_USERS = "Users";

private static final String KEY_ID = "id";

private static final String KEY_FIRST_NAME = "firstName";

private static final String KEY_LAST_NAME = "lastName";

private static final String[] COLUMNS = {KEY_ID, KEY_FIRST_NAME, KEY_LAST_NAME};

public SQLHelper(Context context) {

super(context, DATABASE_NAME, null, DATABASE_VERSION);

}

@Override

public void onCreate(SQLiteDatabase db) {

String CREATE_USER_TABLE = "CREATE TABLE Users ( " +

"id INTEGER PRIMARY KEY AUTOINCREMENT, " +

"firstName TEXT, "+

"lastName TEXT )";

db.execSQL(CREATE_USER_TABLE);

}

@Override

public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {

db.execSQL("DROP TABLE IF EXISTS Users");

this.onCreate(db);

}

public void addUser(User user){

SQLiteDatabase db = this.getWritableDatabase();

ContentValues values = new ContentValues();

values.put(KEY_FIRST_NAME, user.getFirstName());

values.put(KEY_LAST_NAME, user.getLastName());

db.insert(TABLE_USERS, null, values);

db.close();

}

public User getUser(int id){

SQLiteDatabase db = this.getReadableDatabase();

Cursor cursor = db.query(TABLE_USERS, COLUMNS, " id = ?", new String[] { String.valueOf(id) }, null, null, null, null);

if (cursor != null) {

cursor.moveToFirst();

}

User user = new User();

user.setId(Integer.parseInt(cursor.getString(0)));

user.setFirstName(cursor.getString(1));

user.setLastName(cursor.getString(2));

return user;

}

}

}

过去,开发人员在测试期间通过使用内存中的 SQLite 数据库来隔离他们的数据库。您可以通过将DATABASE_NAME保留为空(即super(context, null, null, DATABASE_VERSION);)来实现这一点。不幸的是,这对我们不起作用,因为它仍然需要一个模拟器,所以我们将不得不依靠我们的模拟。

清单 4-12 显示了我们想要测试的UserOperations代码:这是我们的创建、读取、更新、删除(CRUD)代码。

Listing 4-12. CRUD Code for Our Database Calls

public class UserOperations {

private DataBaseWrapper dbHelper;

private String[] USER_TABLE_COLUMNS = { DataBaseWrapper.USER_ID, DataBaseWrapper.USER_NAME, DataBaseWrapper.USER_EMAIL };

private SQLiteDatabase database;

public UserOperations(Context context) {

dbHelper = new DataBaseWrapper(context);

}

public void open() throws SQLException {

database = dbHelper.getWritableDatabase();

}

public void close() {

dbHelper.close();

}

public User addUser(String name, String email) {

ContentValues values = new ContentValues();

values.put(DataBaseWrapper.USER_NAME, name);

values.put(DataBaseWrapper.USER_EMAIL, email);

long userId = database.insert(DataBaseWrapper.USERS, null, values);

Cursor cursor = database.query(DataBaseWrapper.USERS,

USER_TABLE_COLUMNS, DataBaseWrapper.USER_ID + " = "

+ userId, null, null, null, null);

cursor.moveToFirst();

}

public void deleteUser(User comment) {

long id = comment.getId();

database.delete(DataBaseWrapper.USERS, DataBaseWrapper.USER_ID

+ " = " + id, null);

}

public List getAllUsers() {

List users = new ArrayList();

Cursor cursor = database.query(DataBaseWrapper.USERS,

USER_TABLE_COLUMNS, null, null, null, null, null);

cursor.moveToFirst();

while (!cursor.isAfterLast()) {

User user = parseUser(cursor);

users.add(user);

cursor.moveToNext();

}

cursor.close();

return users;

}

public String getUserEmailById(long id) {

User regUser = null;

String sql = "SELECT " + DataBaseWrapper.USER_EMAIL + " FROM " + DataBaseWrapper.USERS

+ " WHERE " + DataBaseWrapper.USER_ID + " = ?";

Cursor cursor = database.rawQuery(sql, new String[] { id + "" });

if (cursor.moveToNext()) {

return cursor.getString(0);

} else {

return "N/A";

}

}

private User parseUser(Cursor cursor) {

User user = new User();

user.setId((cursor.getInt(0)));

user.setName(cursor.getString(1));

return user;

}

}

在我们的测试中,我们将模拟一个addUser(name, email调用(参见清单 4-13 )。

Listing 4-13. testMockUser Code

/**

* Unit tests for the User Database class.

*/

@SmallTest

public class DatabaseTest {

private User joeSmith = new User("Joe", "Smith");

private final int USER_ID = 1;

@Test

public void testMockUser() {

//mock SQLHelper

SQLHelper dbHelper = Mockito.mock(SQLHelper.class);

//have mockito return joeSmith when calling dbHelper getUser

Mockito.when(dbHelper.getUser(USER_ID)).thenReturn(joeSmith);

//Assert joeSmith is returned by getUser

assertEquals(dbHelper.getUser(USER_ID), joeSmith);

}

}

在设置中,我们模拟出了dbHelper类以及底层的SQLiteDatabase。在testMockUser我们做了一个简单的测试呼叫,返回的用户是乔·史密斯。

詹金斯

在理想的环境中,我们希望在每次使用持续集成服务器(如 Jenkins)检入代码时自动运行测试,我们在第三章中讨论了这一点。

要在 Jenkins 中运行单元测试,单击添加构建步骤➤调用 Gradle 脚本并添加 testCompile 任务,如图 4-2 所示。

A978-1-4842-9701-8_4_Fig2_HTML.jpg

图 4-2。

Running unit tests in Jenkins

摘要

在这一章中,我们已经看了许多使用 Mockito 将我们的测试与任何底层 Android 和 Java 依赖隔离的场景。我们这样做的原因是为了确保我们只测试我们想要测试的代码,而不是任何与之交互的代码。您编写的代码都应该经过单元测试,包括模拟其依赖关系的交互的模拟。

本章的工作代码可以在 Apress 网站上找到。

五、Espresso

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-9701-8_​5) contains supplementary material, which is available to authorized users.

除了简单的逻辑错误之外,Android 应用失败的原因还有很多。在最基本的情况下,该应用可能无法正确安装,或者当您从横向移动到纵向再返回时可能会出现问题。由于存在碎片,这种布局可能无法在您没有时间测试的任何数量的设备上工作,或者如果网络中断,它可能会挂起。

使用单元测试来测试这些条件是不可能的。我们将不得不使用另一个测试工具来测试我们的 GUI(图形用户界面)或活动。不幸的是,这也意味着我们又回到了使用设备和仿真器来进行测试。

有很多选择,比如 UIAutomator、葫芦、Robotium 和 Selenium。直到最近,我一直在使用 Calabash,因为它的 Given/When/Then 编写格式非常适合商业用户。然而,使用浓缩咖啡有显著的优势,这是很难抗拒的。

所有这些其他产品都是第三方产品,而 Espresso 是谷歌的第一方产品。通常这不会是任何一种优势,但是因为 Espresso 能够挂钩到 Android 生命周期中,它可以很好地准确知道活动何时准备好执行您的测试。Android 中的 GUI 测试通常充满了sleep()命令,以确保活动准备好接受您的数据。有了意式浓缩咖啡,你根本不需要等待或睡觉;它只是在应用准备好接受输入数据时触发测试。UI 线程和 Espresso 之间的同步意味着测试运行起来比使用其他工具更可靠。如果测试失败了,那是因为你的代码中有错误,而不是你需要给sleep()命令增加更多的时间。

onView

虽然我们已经在第一章中看到了意式浓缩咖啡,但还是有必要回到基础,做一个真正的 Hello,World Espresso 测试。

在第一章中,我们展示了如何设置 Espresso 环境,如下所示:

  • 先决条件:安装 Android 支持库
  • build.gradle (app)文件中添加 Espresso 依赖
  • 在构建变体中选择 Android 测试工具测试工件
  • src/androidTest/java文件夹中创建 GUI 测试
  • 右键单击测试运行测试

Espresso 使用 OnView 格式,而不是 JUnit 或 Hamcrest 匹配器和断言。它有三个部分,即在我们测试的活动中查找元素的 ViewMatcher,执行操作(例如,click)的 ViewAction,以及确保文本匹配和测试通过的 ViewAssertion。

onView(ViewMatcher)

.perform(ViewAction)

.check(ViewAssertion);

你好世界

清单 5-1 显示了标准 Android Hello World 应用的代码。

Listing 5-1. Hello World

public class MainActivity extends Activity {

private TextView mLabel;

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

}

}

图 5-1 显示了在仿真器上运行的应用。我们简单的 Espresso 测试将找到文本,并确保它真的在说 Hello world!

A978-1-4842-9701-8_5_Fig1_HTML.jpg

图 5-1。

Hello world!

清单 5-2 显示了简单测试的代码。该测试被标注为@LargeTest,因为我们需要模拟器来运行 Espresso 测试。我们使用 JUnit4 规则来启动主活动(参见@Rule注释)。

一旦我们访问了活动,我们就使用 onView 代码来查找我们的 Hello World 文本和一个.check来查看该文本是否与在strings.xml文件中定义的一样。在这种情况下,不需要.perform步骤,所以省略了。

Listing 5-2. Hello World Espresso Test

@RunWith(AndroidJUnit4.class)

@LargeTest

public class MainActivityTest {

@Rule

public ActivityTestRule<MainActivity> activityTestRule

= new ActivityTestRule<>(MainActivity.class);

@Test

public void helloWorldTest() {

onView(withId(R.id.hello_world))

.check(matches(withText(R.string.hello_world)));

}

}

测试通过,结果显示在 Android Studio 中,类似于单元测试输出(见图 5-2 )。

A978-1-4842-9701-8_5_Fig2_HTML.jpg

图 5-2。

Hello World Espresso test results

添加按钮

接下来,让我们在 Hello World 代码中添加一个按钮。为此,我们将清单 5-3 中的代码添加到我们的activity_main.xml文件中。字符串button_label也需要添加到strings.xml文件中。请注意,该按钮在默认情况下是启用的。

Listing 5-3. Adding Hello World Button

< Button

android:id="@+id/button"

android:text="@string/button_label"

android:layout_width="wrap_content"

android:layout_height="wrap_content" />

图 5-3 显示了我们修改后的带有新按钮的应用。

A978-1-4842-9701-8_5_Fig3_HTML.jpg

图 5-3。

Hello World with button

我们希望确保按钮处于打开或启用状态。清单 5-4 现在显示了测试代码。这次我们使用.perform动作来点击按钮。

Listing 5-4. onView Button Test

@Test

public void helloWorldButtonTest(){

onView(withId(R.id.button))

.perform(click())

.check(matches(isEnabled()));

}

当一切都是绿色时,测试成功运行(参见图 5-4 )。

A978-1-4842-9701-8_5_Fig4_HTML.jpg

图 5-4。

Hello World test results

视图匹配器

表 5-1 显示了可用的视图匹配器选项。

表 5-1。

ViewMatcher

| 种类 | 制榫机 | | --- | --- | | 用户属性 | `withId, withText, withTagKey, withTagValue, hasContentDescription, withContentDescription, withHint, withSpinnerText, hasLinks, hasEllipsizedText, hasMultilineTest` | | 用户界面属性 | `isDisplayed, isCompletelyDisplayed, isEnabled, hasFocus, isClickable, isChecked, isNotChecked, withEffectiveVisibility, isSelected` | | 对象匹配器 | `allOf, anyOf, is, not, endsWith, startsWith, instanceOf` | | 等级制度 | `withParent, withChild, hasDescendant, isDescendantOfA, hasSibling, isRoot` | | 投入 | `supportsInputMethods, hasIMEAction` | | 班级 | `isAssignableFrom, withClassName` | | 根匹配器 | `isFocusable, isTouchable, isDialog, withDecorView, isPlatformPopup` |

视图操作

表 5-2 显示了可用的视图操作选项。

表 5-2。

ViewAction

| 种类 | 行动 | | --- | --- | | 点击/按下 | `click, doubleClick, longClick, pressBack, pressIMEActionButton, pressKey, pressMenuKey, closeSoftKeyboard, openLink` | | 手势 | `scrollTo, swipeLeft, swipeRight, swipeUp, swipeDown` | | 文本 | `clearText, typeText, typeTextIntoFocusedView, replaceText` |

视图断言

表 5-3 显示了可用的视图断言选项。

表 5-3。

ViewAssertion

| 包裹 | 断言 | | --- | --- | | 布局断言 | `noEllipsizedText, noMultilineButtons, noOverlaps` | | 位置断言 | `isLeftOf, isRightOf, isLeftAllginedWith, isRightAlignedWith, isAbove, isBelow, isBottomAlignedWith, isTopAlignedWith` | | 其他的 | `matches, doesNotExist, selectedDescendentsMatch` |

奏鸣曲

当我们使用任何 AdapterViews(如 ListView、GridView 或 Spinner)时,将无法找到数据。对于 AdapterViews,我们必须结合 onView 使用onData来定位和测试项目。

onData格式如下:

onData(ObjectMatcher)

.DataOptions

.perform(ViewAction)

.check(ViewAssertion)

可用的DataOptionsinAdapterViewatPositiononChildView

待办事项

为了了解这是如何工作的,让我们看看如何测试拥有 ListView 适配器的 ToDoList 应用(见图 5-5 )。

A978-1-4842-9701-8_5_Fig5_HTML.jpg

图 5-5。

ToDoList application

我们的应用使用 ListView 适配器。清单 5-5 显示了代码。

Listing 5-5. To Do List Code

public class MainActivity extends Activity {

private TextView mtxtSelectedItem;

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

mtxtSelectedItem = (TextView) findViewById(R.id.txt_selected_item);

String[] todolist = {"pick up the kids","pay bills","do laundry",

"buy groceries ","go the gym","clean room","call mum"};

List<String> list = Arrays.asList(todolist);

ArrayAdapter<String> adapter =

new ArrayAdapter(this, android.R.layout.simple_list_item_1, list);

ListView listView = (ListView) findViewById(R.id.list_of_todos);

listView.setAdapter(adapter);

listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {

@ Override

public void onItemClick(AdapterView<?> parent, View view, int position, long id) {

String text = ((TextView) view).getText().toString();

Toast.makeText(getApplicationContext(), text, Toast.LENGTH_LONG).show();

mtxtSelectedItem.setText(text);

}

});

}

}

确保一切正常的一个简单测试是在待办事项列表中选择一些事情,比如“去健身房”清单 5-6 显示了浓缩咖啡的代码。我们告诉测试查看onData代码中 AdapterView 中的位置[4],然后将它传递给 onView,这样它就可以检查文本是否确实说了“去健身房”

Listing 5-6. onData Test Code

@RunWith(AndroidJUnit4.class)

@LargeTest

public class MainActivityTest {

@Rule

public ActivityTestRule<MainActivity> activityTestRule

= new ActivityTestRule<>(MainActivity.class);

@Test

public void toDoListTest(){

onData(anything())

.inAdapterView(withId(R.id.list_of_todos)).atPosition(4)

.perform(click());

onView(withId(R.id.txt_selected_item))

.check(matches(withText("go to the gym")));

}

}

使用模拟器或在设备上再次运行测试。

詹金斯

要在 Jenkins 中运行 Espresso 测试,请单击添加构建步骤➤调用 Gradle 脚本并添加 connectedCheck 任务(参见图 5-6 )。

A978-1-4842-9701-8_5_Fig6_HTML.jpg

图 5-6。

Adding Espresso tests in Jenkins

Espresso 需要一个模拟器来执行它的测试,所以您还需要安装 Android 模拟器插件。你可以选择让 Jenkins 使用现有的仿真器或者创建一个新的仿真器(见图 5-7 )。

A978-1-4842-9701-8_5_Fig7_HTML.jpg

图 5-7。

Using an existing emulator

摘要

在这一章中,我们已经研究了使用onViewonData的一些浓缩咖啡测试。最后,如果你想知道我们的测试套件中应该有多少 Espresso 测试,那么回到我们在第一章(图 1-1 )中的敏捷测试金字塔,你会看到我们应该总是有比 Espresso 测试更多的单元测试,或者换句话说,比@LargeTests更多的@SmallTests

六、测试驱动开发

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-9701-8_​6) contains supplementary material, which is available to authorized users.

如果我们不努力展示测试驱动开发(TDD)的作用,那就不对了。因此,在本章中,我们将使用我们的 TDD 方法从头开始创建一个应用。使用 TDD,我们将为每日星象创建一个示例应用。无论如何,我不是一个占星学狂热者,但它是一个足够简单的应用,可以让我们展示我们的 TDD 技术。

理解测试驱动的开发

TDD 意味着我们使用下面的过程获取特性和代码列表中的第一个特性:

  • 首先编写一个测试,然后看到它失败(红色)
  • 实现尽可能简单的解决方案来通过我们的测试(绿色)
  • 重构以去除任何代码味道(重构)

实际上,您可能需要不止一个测试来满足某个特性。但是一旦你对你实现的特性感到满意,从列表中选择另一个特性,重复红色/绿色/重构过程,直到所有的特性都完成。

Note

在经典的 TDD 中,无论是 Java、C++ 还是 C#,你都不用担心任何基础设施。但是在 Android 中事情没有那么简单。当您创建一个要测试的 Java 类时,您通常需要创建一个显示该 Java 类或与之交互的活动。因此,当你说编写尽可能简单的解决方案来通过单元测试时,也必须包括一些 Android 活动代码。或者,如果你愿意,你可以把它留给重构阶段,但是它只需要在红/绿/重构过程中的某个地方完成。

单元测试和 TDD

到目前为止,我们一直专注于对我们的 Android 应用进行单元测试。但是单元测试不一定是 TDD。测试驱动开发意味着在编写代码之前先编写单元测试,而单元测试并不强制你在编写测试时进行。没有 TDD,单元测试通常是在编码周期结束时编写的,以提高代码覆盖率。所以,你可以在有或者没有 TDD 的情况下进行单元测试,但是没有单元测试就不能进行 TDD。一旦你开始 TDD,你将很快发现它比经典的单元测试带来的痛苦要少。

TDD 值

我们知道单元测试和一般测试有助于发现错误,但是我们为什么要使用 TDD 呢?有几个根本原因。TDD 推动开发人员只为实现某个特性所需的最低限度的代码编写代码,因此它帮助我们塑造我们的设计,以实现实际或真实使用所需的特性,而无需在我们的实现中镀金——节省资金并降低复杂性。我们称之为 YAGNI,或者“你不需要它”这导致了更简单的代码,因为实现关注的是需要什么,而不一定是您能够做什么。

在移动创业越来越快的今天,YAGNI 也鼓励尽可能快地推出最小可行产品(MVP)。企业主选择在 Google Play 或亚马逊应用商店推出应用所需的最少功能。这个最小的特性列表然后被分割成可管理的块,以满足开发人员的 TDD 过程。

没有实践 TDD 的单元测试也可以得到一个很好的回归测试套件,帮助你避免在编码时引入任何缺陷。因为我们在编写任何代码之前就在编写单元测试,所以 TDD 回归测试套件将比没有 TDD 的单元测试覆盖更广、更全面。

此外,由于正在进行的重构,代码变得更易于维护,也更精简,从而延长了代码库的寿命。在 Android 中编写可怕的、不可测试的代码是非常容易的。重构将鼓励你编写小的、集中的、可能是单行的方法,这些方法容易测试,而不是单一的 Android 视图。

最后,在这个连续的红/绿/重构循环中的编码过程有助于消除拖延,因为重点是小的、离散的步骤,随着一个又一个功能的实现,应用逐渐自下而上地出现。

使用 TDD 编写应用

在我们开始之前,我们需要了解星座应用的一些基本要求。

  • 显示每个星座
  • 显示每个星座的信息
  • 显示星座运势

我们还可以添加很多其他东西,但我们正在练习 YAGNI,所以我们将为我们的 MVP 占星应用添加最少的功能。

特征 1

TDD 意味着首先编写测试——这会失败——让测试通过,然后重构。我们的第一个功能是显示每个星座。使用 Android 向导创建一个名为占星的 Android 应用,其中包含一个空活动。我们的第一个测试使用我们在第三章中介绍的 Robolectric 来测试我们显示了 12 个标志(参见清单 6-1 )。

Listing 6-1. Robolectric Test

@RunWith(RobolectricGradleTestRunner.class)

@Config(constants = BuildConfig.class, sdk = 21, manifest = "src/main/AndroidManifest.xml")

public class ZodiacUnitTest {

private Activity mainActivity;

private ListView lstView;

@Before

public void setUp() {

// Robolectric sets up the MainActivity class

mainActivity= Robolectric.setupActivity(MainActivity.class);

assertNotNull("Main Activity not setup",mainActivity);

// add a listview to your layout file to get the test to compile

lstView=(ListView)mainActivity.findViewById(R.id.list_of_signs);

}

@Test

public void shouldDisplaySigns() throws Exception {

assertThat("should be a dozen star signs", 12, equalTo(lstView.getCount()));

}

}

测试代码设置了一个MainActivity,并查看我们的listView上是否有 12 个符号。运行测试,当然,它失败了(见图 6-1 )。

A978-1-4842-9701-8_6_Fig1_HTML.jpg

图 6-1。

Test fails (red)

MainActivity.java(见清单 6-2 )有一个ListView,它使用了我们的activity_main.xml布局文件中的ListViewlist_of_signs

Listing 6-2. MainActivity.java

public class MainActivity extends AppCompatActivity {

private Zodiac zodiac;

private TextView mtxtSelectedItem;

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

ListView listView = (ListView) findViewById(R.id.list_of_signs);

}

}

让代码编译的最简单方法是在strings.xml文件中添加一个zodiac_array(参见清单 6-3 )。

Listing 6-3. strings.xml

<resources>

<string name="app_name">Horoscope</string>

<string-array name="zodiac_array">

<item>Aries</item>

<item>Taurus</item>

<item>Gemini</item>

<item>Cancer</item>

<item>Leo</item>

<item>Virgo</item>

<item>Libra</item>

<item>Scorpio</item>

<item>Sagittarius</item>

<item>Capricorn</item>

<item>Aquarius</item>

<item>Pisces</item>

</string-array>

</resources>

现在在布局文件中引用这个数组(参见清单 6-4 )。

Listing 6-4. android_main.xml layout file

<ListView

android:id="@+id/list_of_signs"

android:entries="@array/zodiac_array"

android:layout_width="fill_parent"

android:layout_height="fill_parent" >

</ListView>

再次运行测试,测试通过(参见图 6-2 )。Robolectric 确实比普通的单元测试需要更长的时间,但它仍然是几秒钟而不是几分钟。我们不需要模拟器就能获得 Espresso 测试功能。

A978-1-4842-9701-8_6_Fig2_HTML.jpg

图 6-2。

Test passes (green)

对于这个特性,我们不需要做任何重构,可能是因为代码非常有限。相反,我们将添加更多的测试(参见清单 6-5 )。

Listing 6-5. Updated ZodiacUnitTests

/**

* If the Robolectric test will not run, edit the test configuration and add \app to the

* end of the Working Directory path (windows) or enter $MODULE_DIR$ (mac).

*/

@RunWith(RobolectricGradleTestRunner.class)

@Config(constants = BuildConfig.class, sdk = 21, manifest = "src/main/AndroidManifest.xml")

public class ZodiacUnitTest {

private ListView listView;

private String[] zodiacSigns;

@Before

public void setUp() {

MainActivity mainActivity = Robolectric.buildActivity(MainActivity.class).create().get();

assertNotNull("Main Activity not setup", mainActivity);

listView=(ListView) mainActivity.findViewById(R.id.list_of_signs);

zodiacSigns = RuntimeEnvironment.application.getResources().getStringArray(R.array.zodiac_array);

}

@Test

public void listLoaded() throws Exception {

assertThat("should be a dozen star signs", zodiacSigns.length, equalTo(listView.getCount()));

}

@Test

public void listContentCheck() {

ListAdapter listViewAdapter = listView.getAdapter();

assertEquals(zodiacSigns[0], listViewAdapter.getItem(0));

assertEquals(zodiacSigns[1], listViewAdapter.getItem(1));

assertEquals(zodiacSigns[2], listViewAdapter.getItem(2));

assertEquals(zodiacSigns[3], listViewAdapter.getItem(3));

assertEquals(zodiacSigns[4], listViewAdapter.getItem(4));

assertEquals(zodiacSigns[5], listViewAdapter.getItem(5));

assertEquals(zodiacSigns[6], listViewAdapter.getItem(6));

assertEquals(zodiacSigns[7], listViewAdapter.getItem(7));

assertEquals(zodiacSigns[8], listViewAdapter.getItem(8));

assertEquals(zodiacSigns[9], listViewAdapter.getItem(9));

assertEquals(zodiacSigns[10], listViewAdapter.getItem(10));

assertEquals(zodiacSigns[11], listViewAdapter.getItem(11));

}

}

图 6-3 显示了第一个功能完成后的 app。

A978-1-4842-9701-8_6_Fig3_HTML.jpg

图 6-3。

List of star signs

功能 2

在特性 2 中,我们希望“显示每个星座的信息”我们需要创建Zodiac类来存储我们所有的信息。因此,假设我们声明了以下变量(参见清单 6-6 )。

Listing 6-6. Zodiac Variables

private String name;

private String description;

private String symbol;

private String month;

我们可以将信息存储在 SQLite 数据库中,但这不是必需的,所以我们将采用最简单的方法,将黄道十二宫的信息存储在一个类中。我们的新单元测试现在显示在清单 6-7 中。

Listing 6-7. Unit Tests

@Test

public void zodiacSymbolTest() throws Exception {

TextView symbolTextView = (TextView) zodiacDetailActivity.findViewById(R.id.symbol);

assertEquals(Zodiac.signs[ARIES_SIGN_INDEX].getSymbol(), symbolTextView.getText().toString());

}

@Test

public void zodialMonthTest() throws Exception {

TextView monthTextView = (TextView) zodiacDetailActivity.findViewById(R.id.month);

assertEquals(Zodiac.signs[ARIES_SIGN_INDEX].getMonth(), monthTextView.getText().toString());

}

@Test

public void zodiacNameTest() {

TextView nameTextView = (TextView) zodiacDetailActivity.findViewById(R.id.name);

assertEquals(Zodiac.signs[ARIES_SIGN_INDEX].getName(), nameTextView.getText().toString());

}

正如预期的那样,看到我们处于红/绿/重构 TDD 循环的红色部分,单元测试全部失败(见图 6-4 )。

A978-1-4842-9701-8_6_Fig4_HTML.jpg

图 6-4。

New unit tests fail

完成Zodiac类(参见清单 6-8 和 6-9 )来存储星座信息。

Listing 6-8. Updated Zodiac Class

public class Zodiac {

private String name;

private String description;

private String symbol;

private String month;

public static final Zodiac[] signs = {

new Zodiac("Aries","Courageous and Energetic.", "Ram", "April"),

new Zodiac("Taurus","Known for being reliable, practical, ambitious and sensual.", "Bull", "May"),

new Zodiac("Gemini","Gemini-born are clever and intellectual.", "Twins", "June"),

new Zodiac("Cancer","Tenacious, loyal and sympathetic.", "Crab", "July"),

new Zodiac("Leo","Warm, action-oriented and driven by the desire to be loved and admired.", "Lion", "August"),

new Zodiac("Virgo","Methodical, meticulous, analytical and mentally astute.", "Virgin", "September"),

new Zodiac("Libra","Librans are famous for maintaining balance and harmony.", "Scales","October"),

new Zodiac("Scorpio","Strong willed and mysterious.", "Scorpion", "November"),

new Zodiac("Sagittarius","Born adventurers.", "Archer", "December"),

new Zodiac("Capricorn","The most determined sign in the Zodiac.", "Goat", "January"),

new Zodiac("Aquarius","Humanitarians to the core", "Water Bearer", "February"),

new Zodiac("Pisces","Proverbial dreamers of the Zodiac.", "Fish", "March"),

};

private Zodiac(String name, String description, String symbol, String month) {

this.name = name;

this.description = description;

this.symbol = symbol;

this.month = month;

}

public String getDescription() { return description;  }

public String getName() { return name; }

public String getSymbol() { return symbol; }

public String getMonth() { return month; }

public String toString() { return this.name; }

}

Listing 6-9. ZodiacDetailActivity class

public class ZodiacDetailActivity extends Activity {

public static final String EXTRA_SIGN = "ZodiacSign";

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_zodiac_detail);

int signNum = (Integer) getIntent().getExtras().get(EXTRA_SIGN);

Zodiac zodiac = Zodiac.signs[signNum];

TextView name = (TextView) findViewById(R.id.name);

name.setText(zodiac.getName());

TextView description = (TextView) findViewById(R.id.description);

description.setText(zodiac.getDescription());

TextView symbol = (TextView) findViewById(R.id.symbol);

symbol.setText(zodiac.getSymbol());

TextView month = (TextView) findViewById(R.id.month);

month.setText(zodiac.getMonth());

}

}

运行测试,它们现在通过了(参见图 6-5 )。

A978-1-4842-9701-8_6_Fig5_HTML.jpg

图 6-5。

Zodiac unit tests pass

这个功能发生了很多变化。明显的重构步骤是将存储在Zodiac.java中的信息放入 SQLite 数据库。这并没有给我们的讨论增加任何内容,所以你可以在 Apress 网站上找到重构后的代码和其余的源代码。

该特征现已完成(见图 6-6 )。

A978-1-4842-9701-8_6_Fig6_HTML.jpg

图 6-6。

Information on star sign

功能 3

特征 3 说我们应该显示星座的星象。所以,让我们再一次从测试开始。要求是它必须是免费的,并且可以用 XML 或 JSON (Java Script Object Notation)格式获得。我们可以创建自己的简单 API,或者使用来自 http://fabulously40.comhttp://findyourfate.com 的众多免费 API 中的一个。

我们将使用一个 API 从 http://a.knrz.co/horoscope-api 调用洋葱的星座。

我们从第四章中的 Mockito 示例中知道,我们不会测试任何网络通信,测试我们的AyncTask方法也不是我们想在单元测试中做的事情。但是我们应该测试我们自己的方法来操作返回的星座 JSON(见清单 6-10 )。

Listing 6-10. JSON Testing

@SmallTest

public class DailyZodiacTest {

private JsonParser mJsonParser;

private String validJson, invalidJson;

private BufferedReader bufferedReader;

@Before

public void setUp() throws IOException {

validJson = "{\n" +

"  \"year\": 2015,\n" +

"  \"week\": 45,\n" +

"  \"sign\": \"aries\",\n" +

"  \"prediction\": \"Test1\"\n" +

"}";

invalidJson = "bogus";

mJsonParser = new JsonParser();

bufferedReader = org.mockito.Mockito.mock(BufferedReader.class);

Mockito.when(bufferedReader.readLine()).thenReturn(validJson).thenReturn(null);

}

@Test

public void validJSON_true() {

assertTrue(mJsonParser.isValidJSON(validJson));

}

@Test

public void invalidJSON_false() {

assertFalse(mJsonParser.isValidJSON(invalidJson));

}

@Test

public void testCreateJsonObjectReturnsJsonObject() throws JSONException {

JSONObject jsonObject = mJsonParser.createJsonObject(bufferedReader);

String horoscope = jsonObject.getString("prediction");

assertEquals("Test1", horoscope);

}

}

测试失败了,我们通过在一个名为JsonParser的新类中创建createJsonObjectisValidJson方法来编写代码使它们通过(参见清单 6-11 )。

Listing 6-11. Valid JSON Code

protected JSONObject createJsonObject(BufferedReader reader) {

try {

StringBuilder sb = new StringBuilder();

JSONObject jsonObject;

String line;

String json;

while ((line = reader.readLine()) != null) {

sb.append(line).append("\n");

}

json = sb.toString();

jsonObject = new JSONObject(json);

return jsonObject;

} catch (Exception e) {

Log.e(TAG, "Error converting result " + e.toString());

}

return null;

}

public boolean isValidJSON(String horoscope){

try {

new JSONObject(horoscope);

return true;

} catch (JSONException e) {

e.printStackTrace();

return false;

}

}

再次运行测试,他们通过了。由于 API 调用依赖于 is AsyncTask code,我们无法使用单元测试来轻松测试它。推荐的方法是使用 Espresso 通过模拟器进行测试。

这一次,在重构阶段,我们的ZodiacDetailActivity类中有大量的其他基础设施代码来让占星术显示在页面上。

该应用现在显示ZodiacDetailActivity的星座(见图 6-7 )。

A978-1-4842-9701-8_6_Fig7_HTML.jpg

图 6-7。

Horoscope app

摘要

在这一章中,我们使用 TDD 创建了一个简单的三特征星座应用。在前两个特性中,我们在测试中使用了 Robolectric,在最后一个特性中使用了 Mockito。单元测试代码仅限于与 Android 框架不直接相关的代码。我们一直避免使用任何 Espresso 模拟器测试,以帮助保持测试尽可能快。

七、处理遗留代码

在您的开发生涯中,每次开始一个新项目时,都很少有机会能够从头开始。通常情况下,你不得不扩展别人写的代码。其他时候,你只是加入团队来帮助完成增加的工作量。不可避免地,诱惑就是不做任何单元测试。毕竟,为现有代码创建单元测试是一项巨大的任务,所以何必麻烦呢。但是有一些方法可以处理这种“没有单元测试”的情况,这样当应用进入质量保证(QA)时,您的代码就不会崩溃。“这不是我的准则”从来都不是一个好借口。

我们引入测试的过程如下:

  • 引入持续集成(CI)来构建代码
  • 为 TDD(测试驱动开发)配置 Android Studio
  • 基于现有测试添加最小单元测试,并让它们在 CI 服务器上运行
  • 向团队展示如何创建单元测试
  • 将测试代码覆盖率指标添加到 CI 中,预计为 5-10%
  • 添加浓缩咖啡测试
  • 模拟现有对象的同时,对任何新特性进行单元测试
  • 隔离现有代码,使任何人都无法直接访问它;
  • 移除未使用的代码
  • 重构孤立的代码,使代码覆盖率达到理想的 60–70%

无论您是单独的开发人员还是团队的一员,设置 CI 服务器总是值得的。我们在书的前面看了 Jenkins,但是你可以使用你自己个人最喜欢的,只要它与 Android 和 Gradle 集成。即使您自己完成了这一步,团队也会看到好处。

接下来,在 Android Studio 中将 JUnit、Mockito 和其他依赖项添加到您的项目中,并确保 Studio 是最新的稳定版本。添加一些简单的单元测试,并向团队展示如何创建单元测试,以便他们了解总体思路;向团队展示单元测试如何在 CI 服务器中工作。这一步的代码覆盖率将是最小的。

为现有应用的基本功能创建 Espresso 测试,即所谓的主要用例或快乐路径。你不能选择对应用进行内部测试,但是你可以在活动级别进行测试。如果应用开始失败,不这样做将导致指责,并侵蚀你在新的敏捷开发环境中建立的任何信心。现在您已经准备好了,为任何新代码创建单元测试。

添加新功能时不要编辑旧代码。隔离任何旧的代码,以便没有新的代码被添加到您现有的未经单元测试的/遗留的代码中。创建与旧代码交互的接口,这样它就有了一个逻辑围栏。

最后,一旦开发环境稳定下来,您就可以开始重构旧代码,这样代码覆盖率就会随着时间的推移而逐渐增加。在本章的剩余部分,我们将看看如何使用一个叫做 SonarQube 的工具来完成这个任务。

声纳员

我们的目标是重构代码,以便更容易测试和维护,但这可能会有问题。对我来说,敏捷就是消除责备,给人们更快实现质量特性的技能。告诉别人他们的代码有异味,无论你如何包装,都不是一件容易的事情,所以最好保持客观而不是主观。令人欣慰的是,除了代码覆盖率之外,还有许多工具和度量标准可以提供这种客观性。SonarQube 对于识别代码的实际问题特别有用。

按照以下步骤安装 SonarQube:

Download and install SonarQube Server; use the most up to date LTS (long-term support) version, from www.sonarqube.org/downloads/ .   Download and install the Sonar Runner.   Start the Sonar Server; run C:\sonarqube\bin\windows-x86-xx\StartSonar.bat on Windows or /etc/sonarqube/bin/[OS]/sonar.sh console on Unix.   Go to http://localhost:9000 in your browser to see if the Sonar Dashboard is running (see Figure 7-1).

A978-1-4842-9701-8_7_Fig1_HTML.jpg

图 7-1。

SonarQube Dashboard

我们需要检查服务器是否正在分析项目,以及是否安装了 Java 插件,因此下载 Sonar 示例。

Download the Sonar examples from https://github.com/SonarSource/sonar-examples/archive/master.zip and unzip   To get the project information into the Sonar Dashboard we need to use the runner. Navigate to the java example folder and start the runner, cd C:\sonar-examples\projects\languages\java\sonar-runner\java-sonar-runner-simple and then run C:\sonar-runner\bin\sonar-runner.bat or on Unix cd /etc/sonar-examples/projects/languages/java/sonar-runner/java-sonar-runner-simple and run the /etc/sonar-runner/bin/sonar-runner command.   Navigate to the Sonar Dashboard, click the Java project and you should see the image in Figure 7-2.

A978-1-4842-9701-8_7_Fig2_HTML.jpg

图 7-2。

Sonar analytics for our Java project

请注意,我们基于生命周期期望(SQLAE)的软件质量评估获得了“C”级。然而,我们对这个项目不感兴趣,因为它是 Java,而不是 Android。在分析任何 Android 项目之前,我们需要安装 Android 插件。

Log in as Administrator using admin/admin   Click Settings ➤ Update Center ➤ Available Plugins (see Figure 7-3).

A978-1-4842-9701-8_7_Fig3_HTML.jpg

图 7-3。

Sonar Update Center   Click the Android Lint plug-in to install the plug-in and restart SonarQube.

Android 插件会将任何 lint 错误导入 SonarQube,并允许您导航任何 Java 错误。要查看示例 Android 项目,请执行以下操作:

cd C:\sonar-examples\projects\languages\android\android-sonarqube-runner or on Unix /etc/sonar-examples/projects/languages/android/android-sonarqube-runner   Create the bin/classes folder as it fails to load without creating the directory   Run C:\sonar-runner\bin\sonar-runner.bat or on unix /etc/sonar-runner/bin/sonar-runner

图 7-4 显示了对该基础项目的顶层分析。

A978-1-4842-9701-8_7_Fig4_HTML.jpg

图 7-4。

Android app analysis

如上安装 Tab Metrics 插件并重启 SonarQube。即使是在一个非常小的项目中,当你现在点击问题链接(见图 7-5 )时,你应该对 SonarQube 发现的问题有所了解。

A978-1-4842-9701-8_7_Fig5_HTML.jpg

图 7-5。

Android app issues list

Android 插件非常适合用 Eclipse 编写的 Android 应用,这可能是您试图修复的大多数遗留应用。现在我们有了工作,我们应该安装 Gradle 插件,这样我们就可以分析 Android Studio 项目了。

Add the plug-in and sonarProperties to your build.gradle (app) file, see Listing 7-1. This won’t replace the existing file but will be in addition to what’s already in the file.   Click Sync Now to update the build.gradle file.   Run your Analyzer command from the project root directory with the command gradlew sonarRunner.   Open the dashboard at http://localhost:9000, to browse your project’s quality.   Listing 7-1. build.gradle Updates

apply plugin: ’sonar-runner’

sonarRunner{

sonarProperties{

property "sonar.host.url", "``http://localhost:9000

property "sonar.jdbc.url", "jdbc:mysql://localhost:3306/sonar?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true&useConfigs=maxPerformance"

property "sonar.jdbc.driverClassName","com.mysql.jdbc.Driver"

property "sonar.jdbc.username","root"

property "sonar.jdbc.password","root"

property "sonar.projectKey", "RIIS:CropCompare"

property "sonar.projectVersion", "2.0"

property "sonar.projectName","CropCompare"

property "sonar.java.coveragePlugin", "jacoco"

property "sonar.sources","src\\main"

property "sonar.tests", "src\\test"

property "sonar.jacoco.reportPath", "build\\jacoco\\jacocoTest.exec"

property "sonar.java.binaries", "build"

property "sonar.dynamicAnalysis", "resuseReports"

}

}

图 7-6 显示 CropCompare 应用有近 200 个问题——47 个关键问题和 87 个主要问题——需要修复。

A978-1-4842-9701-8_7_Fig6_HTML.jpg

图 7-6。

CropCompare app issues list

比较项目

当你的代码覆盖率达到你认为可以接受的程度时,你可以使用 Sonar compare projects 功能来比较每个项目(见图 7-7 )。我们可以很快地识别出哪些项目代码覆盖率低,复杂度高。这将快速确定需要经历相同过程的其他项目。

A978-1-4842-9701-8_7_Fig7_HTML.jpg

图 7-7。

Comparing projects

重构代码

一旦修复了 SonarQube 问题,就应该精简最大的类并消除关键的代码味道问题。记住在任何大手术后使用您的 Espresso 测试套件测试代码,以确保您没有破坏构建。

重构可能还包括为你的项目创建一个新的、更干净的架构。MVP(模型-视图-演示者)和 MVVM(模型-视图-视图模型)都正在成为流行的 Android 架构。数据绑定是清理代码的另一个好方法——尽管在撰写本文时它仍处于测试阶段——因为它从用户界面或 UI 中删除了数据引用,也是实现 MVVM 架构的良好开端。

经验教训

在我们结束这一章之前,如果不谈谈从写得很差的遗留 Android 代码到更易维护的代码的转变过程中所学到的一些经验,那将是一个错误。

保持对话的客观性。告诉别人他的代码不好是非常主观的对话。但是告诉团队,目标是在公司的 Git 服务器上的不同项目中具有相同级别的代码覆盖率和复杂性度量,这是一个更容易说服的方法。

不要在应用中附带任何测试或测试信息。在当前的单元测试环境中,即使您尝试了,也不太可能将单元测试包含在您的 APK (Android 应用包)中,但是我们在过去已经看到了许多测试数据存储在 resources 和 assets 文件夹中的例子,所以总是解压缩您的生产 APK,以确保它在有效负载中没有任何额外的内容。

当你继承一个现有的项目时,要循序渐进。不要被指标所驱使。尽量不要太担心代码覆盖率;毕竟,对你的评判是基于你如何写出好的、干净的、交付价值的代码,而不是你的代码覆盖率或任何其他指标是否超过了某个特定的值。

关注性能指标也很重要。就像 Espresso test harness 一样,一些简单的应用计时指标将让您保持正轨。没有什么比创建经过质量测试的代码发现它比原来的遗留代码慢两到三倍更糟糕的了。没有理由应该是这样,但是错误发生了,所以添加一个性能指标,这样您就可以在它成为问题之前意识到它(并且可以根除它)。

给你的估计增加一些配置时间。如果做得正确,人工 QA 时间应该会大大减少,但是这意味着开发和 devops 时间会消耗掉一些收益。不要假设开发人员在没有配置学习曲线的情况下会全力投入 TDD。

摘要

在这一章中,我们已经研究了一些将单元测试添加到现有代码库中的策略。在 Android Studio 中使用 Sonar 和重构,随着时间的推移,您可以逐渐分离现有的应用,增加它们的代码覆盖率,并降低它们的复杂性。

最后,值得一提的是,你不需要任何人的许可来进行单元测试,即使团队的其他成员不想参与。现在,您可以使用 Android Studio 开始单元测试,因为开始单元测试不再有任何障碍,Java 世界的其他部分已经这样做了大约十年。不管有没有 TDD,单元测试都需要成为开发过程的一部分。

posted @   绝不原创的飞龙  阅读(13)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
· 上周热点回顾(2.17-2.23)
点击右上角即可分享
微信分享提示