0%

Android单元测试实践

基础概念

按照Google官方建议,Android测试体系应该参照测试金字塔架构(如下图所示),App应该包含三类测试(即小型、中型和大型测试):

  • 小型测试是指单元测试,用于验证应用的行为,一次验证一个类。
  • 中型测试是指集成测试,用于验证模块内堆栈级别之间的交互或相关模块间的交互。
  • 大型测试是指端到端测试,用于验证跨越了应用的多个模块的用户UI点击操作流程。

沿着金字塔逐级向上,从小型测试到大型测试,各类测试的保真度(对于用户的真实感受)逐级提高,但维护和调试工作所需的执行时间和工作量也逐级增加。因此,我们编写的单元测试应多于集成测试,集成测试应多于端到端测试。虽然各类测试的比例可能会因应用的用例不同而异,但我们通常建议各类测试所占比例如下:小型测试占 70%,中型测试占 20%,大型测试占 10%。

今天我们主要讨论的是占比70%的小型测试,也叫单元测试。

测试框架介绍

基础框架:JUnit

https://junit.org/junit4/
JUnit是一个Java语言的单元测试框架。Junit测试是程序员测试,即所谓白盒测试,因为我们知道被测试的软件如何(How)完成功能和完成什么样(What)的功能。Junit是一套框架,继承TestCase类,就可以用Junit进行自动测试了。多数Java的开发环境都已经集成了JUnit作为单元测试的工具。

断言框架:Truth

https://github.com/google/truth
断言框架主要是为了在单元测试代码中比较测试方法的实际输出与期望输出是否一致。
JUnit框架本身支持简单的断言,如下代码所示,assertEquals方法即为断言方法。

1
2
3
4
5
6
7
8
9
10
import org.junit.Test;

import static org.junit.Assert.*;

public class ExampleUnitTest {
@Test
public void addition_isCorrect() {
assertEquals(4, 2 + 2);
}
}

JUnit本身自带的断言方法,过于简单,并且测试语义也不是很丰富,这里引入Google的Truth断言框架,大致使用如下所示

1
2
3
4
5
6
7
import com.google.common.truth.Truth.assertThat

@Test
public void addition_isCorrect() {
int result = 2+2;
assertThat(result).isEqualTo(4);
}

当我们可以使用内置在测试框架如JUnit中的方法(类似于assertEquals)时,为什么还要依赖新的断言库呢?我们通过如下断言代码演示原因,下面代码是JUnit自带的assertEquals方法:

1
2
3
assertEquals(
ImmutableMultiset.of("guava", "dagger", "truth", "auto", "caliper"),
HashMultiset.create(projectsByTeam().get("corelibs")));

替换成Truth如下代码所示

1
2
3
assertThat(projectsByTeam())
.valuesForKey("corelibs")
.containsExactly("guava", "dagger", "truth", "auto", "caliper");

可以看到,使用Truth有如下几个优点:

  • 使用Truth编写代码会更快,因为它是链式调用,IDE可以智能提醒和自动补全
  • 使用Truth的代码更容易理解和阅读:
    • 它的样板代码更少。 例如,在上面的示例代码中,projectByTeam() 返回一个 ListMultimap,因此 projectsByTeam().get(…) 将仅等于另一个元素顺序相同的 List。 我们不想在这里测试排序,所以我们必须将其转换为 Multiset后再进行测试,否则会直接失败,因为直接拿到的list顺序不一定相等。
    • 将最终的结果放在首位会为接下来各种操作提供比较好理解的上下文信息:如果断言开始的部分就是“guava, dagger, …,”时,读者要读到后面才能确定要测试什么。
  • Truth有更加友好和易于阅读的错误提示信息:
1
2
3
java.lang.AssertionError: expected:<[guava, dagger, truth, auto, caliper]> but was:<[dagger, auto, caliper, guava]>
at org.junit.Assert.failNotEquals(Assert.java:835) <2 internal calls>
at com.google.common.truth.example.DemoTest.testBuiltin(DemoTest.java:64) <19 internal calls>

上面的错误消息对于一个简单的断言很好,但是考虑到如下情况:

  • 如果集合中有很多值,那么找出哪些值缺失(或多余的值)可能会很困难。
  • 如果测试被参数化除了“corelibs”以外的键,JUnit 消息将不会显示断言失败的键。
  • 如果结果项目列表是空的,JUnit 消息将不会显示整个多映射是空的还是只是“corelibs”集合为空。
    下面的信息是由 Truth 生成的错误消息:
1
2
3
4
5
6
7
value of    : projectsByTeam().valuesForKey(corelibs)
missing (1) : truth
───
expected : [guava, dagger, truth, auto, caliper]
but was : [guava, auto, dagger, caliper]
multimap was: {corelibs=[guava, auto, dagger, caliper]}
at com.google.common.truth.example.DemoTest.testTruth(DemoTest.java:71)

mock框架

Mock通常是指,在测试一个对象A时,我们构造一些假的对象来模拟与A之间的交互,而这些Mock对象的行为是我们事先设定且符合预期。通过这些Mock对象来测试A在正常逻辑,异常逻辑或压力情况下工作是否正常。

Mockito

https://site.mockito.org/
Mockito是最流行的Java mock框架之一。基本使用如下代码所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class HelloWorldTest { 
@Test
public void helloWorldTest() {
// mock DemoDao instance
DemoDao mockDemoDao = Mockito.mock(DemoDao.class);
// 使用 mockito 对 getDemoStatus 方法打桩
Mockito.when(mockDemoDao.getDemoStatus()).thenReturn(1);
// 调用 mock 对象的 getDemoStatus 方法,结果永远是 1
Assert.assertEquals(1, mockDemoDao.getDemoStatus());
// mock DemoService
DemoService mockDemoService = new DemoService(mockDemoDao);
Assert.assertEquals(1, mockDemoService.getDemoStatus() );
}
}

mockK

https://github.com/mockk/mockk
当我们使用Mockito去mock Java类或者方法是没有问题的,但是去mock kotlin的代码,可能会出现如下问题:

  • Mockito cannot mock/spy because : — final class
  • java.lang.IllegalStateException: anyObject() must not be null
  • when 要加上反引号才能使用,因为when在kotlin中是一个关键字
  • 测试静态方法(Static Method)

基于以上问题,开源社区专门为kotlin设计了一套mock框架:MockK。如下示例代码为mockK使用的样例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Kid(private val mother: Mother) {
var money = 0
private set

fun wantMoney() {
money += mother.giveMoney()
}
}

class Mother {
fun giveMoney(): Int {
return 100
}
}

上面代码主要用来测试Kid类中的wantMonkey方法调用后,kid的money结果是否准确

1
2
3
4
5
6
7
8
9
10
11
12
@Test
fun wantMoney() {
// Given
val mother = mockk<Mother>()
val kid = Kid(mother)
every { mother.giveMoney() } returns 30 // when().thenReturn() in Mockito

// When
kid.wantMoney()
// Then
assertEquals(30, kid.money)
}

Robolectric框架

Robolectric通过实现一套JVM能运行的Android代码,然后在Junit test运行的时候去截取android相关的代码调用,然后转到Robolectric内部实现的代码去执行这个调用的过程。不用依赖真实的 Android 环境中运行(模拟器或者真机)
Robolectric主要适用于UI的测试,比如Activity,Fragment,一些页面操作的测试场景,采用Shadow的方式对Android中的组件进行模拟测试,从而实现Android单元测试Robolectric正好弥补了Mockito的不足,两者结合使用是最完美的,即如果想要测试依赖了Android FrameWork相关的代码,就需要使用Robolectric的能力。

编写测试代码

详细的代码示例可参考AndroidTestingSamples项目

Step1:配置测试环境

根据执行环境组织整理测试目录,Android Studio 中的典型项目包含两个用于放置测试的目录。请按以下方式组织整理您的测试:

  • androidTest 目录应包含在真实或虚拟设备上运行的测试。此类测试包括集成测试、端到端测试,以及仅靠 JVM 无法完成应用功能验证的其他测试。
  • test 目录应包含在本地计算机上运行的测试,如单元测试。

考虑在不同类型的设备上运行测试的利弊,在设备上运行测试时,您可以从以下类型中进行选择:

  • 真实设备
  • 虚拟设备(如 Android Studio 中的模拟器)
  • 模拟设备(如 Robolectric)

真实设备可提供最高的保真度,但运行测试所花费的时间也最多。另一方面,模拟设备可提供较高的测试速度,但代价是保真度较低。不过,平台在二进制资源和逼真的循环程序上的改进使得模拟设备能够产生更逼真的结果。
虚拟设备则平衡了保真度和速度。当我们使用虚拟设备进行测试时,可以使用快照来最大限度地缩短测试之间的设置时间。

添加依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
dependencies {
// Core library
androidTestImplementation 'androidx.test:core:1.0.0'


// AndroidJUnitRunner and JUnit Rules
androidTestImplementation 'androidx.test:runner:1.1.0'
androidTestImplementation 'androidx.test:rules:1.1.0'


// Assertions
androidTestImplementation 'androidx.test.ext:junit:1.0.0'
androidTestImplementation 'androidx.test.ext:truth:1.0.0'
androidTestImplementation 'com.google.truth:truth:0.42'


// Espresso dependencies
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-accessibility:3.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-web:3.1.0'
androidTestImplementation 'androidx.test.espresso.idling:idling-concurrent:3.1.0'


// The following Espresso dependency can be either "implementation"
// or "androidTestImplementation", depending on whether you want the
// dependency to appear on your APK's compile classpath or the test APK
// classpath.
androidTestImplementation 'androidx.test.espresso:espresso-idling-resource:3.1.0'
}

整合testing模块

为了整合上面所有的测试库依赖,免除每个模块都要单独添加测试的依赖库这类繁琐重复的操作,我们在项目中可以新建一个testing模块,我们只需要添加对testing模块的依赖即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
testImplementation project(":testing")
````

#### Gradle配置支持模拟AndroidFramework资源

本地测试依赖,需要在模块的build.gradle文件中添加如下配置

```groovy
android {
// ...
testOptions {
unitTests.includeAndroidResources = true
}
}

Step2:创建测试

如我们在项目 src/main/java 目录的 me.yamlee.testing.samples 包名下创建了一个 MathUtils 的工具类,那么需要在src/test/java目录的同样包名下创建一个名为 MathUtilsTest 的对应测试类(可通过快捷键ctrl+shfit+t(windows)/cmd+shift+t(mac) 快速创建)。

Android平台无关测试

Android平台无关测试即不依赖Android Framework相关Api的功能代码,需要继承testing模块中的BaseUnitTest抽象类,例如下面代码所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
object MathUtils {

fun add(a: Int, b: Int): Int {
return a + b
}
}

//测试代码
class MathUtilsTest : BaseUnitTest() {
@Test
fun add() {
val result = MathUtils.add(1, 1)
Truth.assertThat(result).isEqualTo(2)
}
}

Android平台相关测试

对于依赖Android Framework相关Api的功能代码,如果不能使用Mockito进行手动mock,则可以使用Robolectric来进行模拟,如下代码所示,需要继承testing模块中的AndroidUnitTest抽象类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
object StringUtils {
fun getApplicationName(context: Context): String {
return context.getString(R.string.app_name)
}
}

//测试代码
class StringUtilsTest : AndroidUnitTest() {
@Test
fun getApplicationName() {
val result = StringUtils.getApplicationName(applicationContext)
Truth.assertThat(result).isEqualTo("AndroidTestingSamples")
}
}

需要注意的是 AndroidUnitTest配置的单元测试runner为 @RunWith(AndroidJUnit4.class) ,表示JUnit的TestRunner使用AndroidJUnit的Runner,通过使用这个TestRunner,在运行测试用例时便会自动使用Android Framework Mock

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 需依赖Android Framework资源的测试,例如context等一些api,
* 需要继承此类
*/
@RunWith(AndroidJUnit4::class)
abstract class AndroidUnitTest {
protected lateinit var applicationContext: Context

@Before
open fun setup() {
applicationContext = ApplicationProvider.getApplicationContext()
}
}

Step3:运行测试

AndroidStudio运行

如上图所示最左边类似▶按钮点击,即可触发测试方法运行

命令行运行

通过在命令行运行如下所示命令,执行app模块下的所有单元测试用例

1
./gradlew :app:test

Step4:查看单元测试覆盖率

测试覆盖率统计采用的是Jacoco,Android Gradle Plugin默认支持Jacoco测试覆盖率工具,通过在build.gradle配置文件中设置即可开启此功能。
项目根目录中有一个 jacoco.gradle 的配置文件,哪个模块需要生成测试覆盖率,可以通过如下代码引入

1
apply from: '../jacoco.gradle'

按如上代码配置Jacoco工具后即可,通过运行对应的gradle任务,即可生成相应的测试覆盖率报告

1
./gradlew clean :app:jacocoTestReport

测试覆盖率生成完成后,可以再项目模块目录下的build目录查找生成的测试覆盖率报告。如上面代码中我们生成的是aivse模块的测试覆盖率,我们可以在 app/build/reports/jacoco/jacocoTestReport/html/ 中通过浏览器打开 index.html 查看覆盖率情况

覆盖率报告大致样式如下图所示

Sonar测试覆盖率关联

有的公司会通过sonarqube进行代码的静态扫描监控,我们可以把单元测试覆盖率同步到sonarqube平台上,通过一下命令可以进行测试覆盖率关联(soanrqube的扫描配置比较复杂,不在本篇展开,后续单独介绍)

1
2
3
4
5
6
7
8
# 运行单元测试,并进行各个模块的单元测试覆盖率检查
./gradlew clean jacocoTestReport

# 将各个模块的测试覆盖率汇总
./gradlew allDebugCoverage

#上传到sonarqube上
./gradlew sonarqube

单元测试编写经验总结

TDD(Test Driven Development):测试驱动开发

TDD 是敏捷开发中的一项核心实践和技术,也是一种设计方法论。TDD的原理是在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。TDD 是 XP(Extreme Programming)的核心实践。它的主要推动者是 Kent Beck。

AndroidStudio测试代码分屏

如下图所示,我们在开发是,业务代码和测试代码可以进行分屏,我们可以快速的对业务代码和测试代码进行编辑和修改。

多使用依赖注入,减少硬编码对象创建

首先我们了解下依赖注入是什么,如下代码我们有一个CoffeMaker的类,用来制作咖啡,它依赖了Heater和Pump类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class CoffeeMaker {
private Heater heater;
private Pump pump;

public CoffeMaker(){
this.heater = new Heater();
this.pump = new Pump();
}

public void makeCoffee(){
heater.heat();
pump.pump();
}
}

在上面的代码中我们new CoffeMaker不需要传任何参数,但是使用依赖注入需要改为如下实现方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class CoffeeMaker {
private Heater heater;
private Pump pump;

public CoffeMaker(Heater heater,Pump pump){
this.heater = heater;
this.pump = pump;
}

public void makeCoffee(){
heater.heat();
pump.pump();
}
}

可以看到通过构造函数传递进来对象而不是在CoffeMaker构造函数中直接硬编码的方式就是依赖注入,那通过此种方式有何好处呢?最直接的好处就是方便我们进行单元测试

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void testMakeCoffee1(){
CoffeMaker coffeMaker = new CoffeMaker();
coffeMaker.makeCoffe();
}

@Test
public void testMakeCoffee2(){
Heater heater = mock(Heater.class)
Pump pump = mock(Pump.class)
CoffeMaker coffeMaker = new CoffeMaker(heater,pump);
coffeMaker.makeCoffe();;
}

在testMakeCoffe1方法中,我们是通过硬编码实现的对象创建,对于coffeMaker内部的heater和pump对象,我们无法干预,单元测试也覆盖不到。而对于testMakecoffee2方法我们可以mock不同的内部对象传给CoffeMaker这样就可以测试不同内部对象的状态,覆盖到更多场景。

总结

本篇内容总体介绍了当下Android单元测试的主要方案与流程,包括断言框架Truth、mock框架,以及Android编写单元测试代码的详细步骤和一些经验总结。