目录
1 引言
1.1 背景
1.2 术语和缩略语
2 闲谈单测
2.1 说说我理解的单测
2.1.1 对测试金字塔的理解
2.1.2 为什么要做单测?
2.1.3 需要写 UI 测试吗?
2.1.4 单测与重构
2.1.5 TDD
2.1.6 单测写在哪?
2.1.7 单测三段式
2.1.8 一个简单的测试例子
2.2 单测针对于哪些代码进行
2.3 测试用例该如何设计
2.3.1 确保原因与影响清晰
2.3.2 只验证有状态变化的函数调用
2.3.3 测试函数名应该有描述性
2.4 单测框架
3 如何开始单测?
3.1 项目中引入单测框架
3.2 参考单测模板代码和优秀文章
Mockito使用
Robolectric使用
单测介绍
3.3 编写单测代码
3.4 单元测试代码分析
4 如何统计覆盖率?
4.1 统计覆盖率
4.2 覆盖率统计 AS 中以及导出 HTML 文件的差异
5 总结
6 参考
随着 Android 应用越来越壮大,对应代码量显著增加,代码逻辑也日趋复杂,此时需要采取一定的行动去保证代码质量,减少逻辑漏洞等,于是严格地执行单元测试编写任务,将单元测试落实到平常开发和维护任务当中去,就是很重要的一环,不可忽视。
然而,很多应用开发者之前并未编写过单元测试代码,那么如果有一篇通俗易懂并带有操作步骤的文章,能帮助应用开发者完成从单元测试小白到入门的过渡,就再好不过了,于是本文就是在此情况写就的,如有不好之处,请多多包涵,谢谢。
缩略语/术语 |
全 称 |
说 明 |
---|---|---|
Module | 模块 | 本文指 Android Studio 项目中包涵的多个模块其中之一 |
TDD | Test-Driven Development | 测试驱动开发,在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。 |
单测 | Android Unit Test | 本文为了行文方便,使用单测表示 Android 单元测试。 |
测开 | Test Development Engineer | 测试开发工程师,本文主要指进行白盒测试的测开。 |
本文所指的单测,是金字塔最底层占70%的小型测试(单测) Unit tests,不包括最顶层占10%的大型测试(UI 测试) UI Tests,对于中间层20%的集成测试(集测),需看情况去做。
另外,集成测试更多是指验证一整个执行流程,而单测验证某个行为或逻辑,可以这么理解:集成测试验证执行流程时,会走单测验证过的某个行为或逻辑,即:单测可能是集成测试的一部分(不完全正确,但可以这么理解)。
比如,Android 中需要在 Activity 显示一张网络图片,单测需验证网络请求图片是否成功,集测需验证从打开 Activity 到显示图片这一过程是否都按预期在执行,UI 测试验证 Activity 显示的图片是否如预期一样。
除了引言中背景提到的单测必要性之外,还有如下2点理由。
(1)一种代码验证,提高对代码的自信度
当我们对类函数代码做了修改或者重构,只需要再跑一遍单元测试
如果通过,说明我们的修改不会对函数原有的行为造成影响
如果测试不通过,说明我们的修改与原有行为不一致,要么修改代码,要么修改测试。
(2)驱动代码重构设计,代码重构质量的验证
刚开始,已有的代码可能基本没有办法写单测,会形成重构驱动,重构的过程会让代码逐渐具备良好的可测性
如果可以针对代码方便快速地,在无需做任何配置工作的情况下,编写一系列互相独立且稳定运行的测试,那么该代码就具备了可测性
具备可测性的代码必然是经过了良好设计的,形成可测性代码的过程,也就是编码技能提升的过程。
Android 应用从广义来说,属于大前端,也就意味着 UI 变动会很频繁。且对于 UI 问题,应用开发者是很容易就能发现的,一般也比较容易解决。那么,针对 UI 进行测试其实是需要的,但没那么必要,可以不用写 UI 测试。
不用写 UI 测试,除了上述提到的 2 个理由,另外还有这 2 个理由:Espresso 单测比较简单;测开会做 UI 测试。将重心放在单测上才是王道。
在没有进行合理的代码解耦前,就马上的进行单测编写,最终导致的有可能是编写不下去、用例难以维护、阅读性差等。
根本的原因其实是代码的耦合度过高,功能类与协作类之间是强引用关系(这里是指架构设计上的说法,非内存引用),当需要模拟协作类的一些返回结果来进行测试时,无法替换或者模拟(Mock)。层级间、功能类间以接口的形式访问是一个较优的单测方式。
没写过单测的童鞋可能不知道,其实重构也是单测的一部分,切记不要在本就不优雅的代码上写单测,请先重构。单测为代码质量保驾护航,重构提升代码质量和自我编程能力。
项目如果走的敏捷开发,会涉及到测试驱动开发这一设计方法论,但说实话,在写代码前先写好测试代码,这对开发者能力要求很高,且目前国内开发环境其实对这块的理解并不如理想之见,暂无需考虑。
首先,我们暂不写 UI 测试,只做单测,那么只需要在 src/test/java/包名/ 下写即可,一般来说可以不用在 src/androidTest/java/包名/ 下写单测代码。
其次,针对于 Android 应用,你可以在每个 Module 下都为其编写单测代码,然后统计各个 Module 的覆盖率,最后求一个平均覆盖率,即为整个应用的覆盖率。但,我们知道这其实是比较麻烦的,每次统计时都需要去计算每个 Module 的覆盖率然后求平均,当然如果用 jacoco 可以优雅自动统计的话就另当别论。
所以,为了方便统计单测覆盖率,本文暂推荐在指定 Module 的 src/test/java/包名/ 下写所有 Module 的单测代码,这个指定 Module 可以是 app 模块,也可以是新建的专为写单测的 unit_test 模块。
经典的单测三段式:模拟前提、执行语句、断言结果。有时会把这三者的部分或全部合在一起,如:Assert.assertEquals(4, 2+2),就把三者融合在一起。
我们代码中经常会有日期工具类,下面是对有效日期判断的方法进行的测试例子。
(1)有效日期判断的源码
object DateUtils {
fun getValidDate(milliseconds: Long): Long {
var validDate = milliseconds
val timeInMillis = Calendar.getInstance(Locale.US).timeInMillis
if (milliseconds <= 0L || milliseconds > timeInMillis) {
validDate = timeInMillis
}
return validDate
}
}
(2)测试代码
@Test
fun testGetValidData() {
// 1、模拟前提
val currentTimeMillis1 = System.currentTimeMillis()
// 2、执行语句
val validDate1 = DateUtils.getValidDate(-1)
// 3、断言结果
Assert.assertTrue(validDate1 in (currentTimeMillis1 - 1000)..(currentTimeMillis1 + 1000))
val currentTimeMillis2 = System.currentTimeMillis()
val validDate2 = DateUtils.getValidDate(0)
Assert.assertTrue(validDate2 in (currentTimeMillis2 - 1000)..(currentTimeMillis2 + 1000))
val currentTimeMillis3 = System.currentTimeMillis()
val validDate3 = DateUtils.getValidDate(currentTimeMillis3 + 100000)
Assert.assertTrue(validDate3 in (currentTimeMillis3 - 1000)..(currentTimeMillis3 + 1000))
val milliseconds4 = System.currentTimeMillis() - 100000
val validDate4 = DateUtils.getValidDate(milliseconds4)
Assert.assertEquals(milliseconds4, validDate4)
}
单测是指对软件中的最小可测试单元进行检查和验证,要以类功能作为测试目标的单个或者一连串的函数测试,也就是说,单测可以是对某个类的具体函数的功能、内部逻辑进行验证。
而针对代码复杂性和依赖性,有如下图的原则描述可参考:
这里对代码简单依赖多的情况多提一嘴,对它们写单测意义并不大,不要为了提高单测覆盖率,而花费很多时间和精力去写单测,这样得不偿失。
模拟前提条件和测试断言之间相隔了200行,这很难知道这断言是否正确,可读性差
确保模拟前提与验证结果相近,查看问题时能快速定位
对函数执行过程中验证不带来状态发生改变的函数是无意义的,例如isUserActive、getPermission、isVAlidPermission等都是对程序没有发生状态变化的,
验证是否这类函数是否执行,只会增加后续代码修改带来的不稳定。
正确应该只验证addPermission是否执行
测试函数名应该具备可直接理解该测试用例的意图和验证结果,如下这种写法,很难让人知道测试目的是为了验证三次输入错误。
正确应该命名为:should_LockOutUser_when_ThreeInvalidLoginAttempts
should_期望结果_when_测试场景。确保测试用例名字中包括被测试的场景和期望的输出。
Junit4
+ Mockito(powerMock)+ robolectric
简单单测 + 模拟难以实例化的类 + 实现 Android 框架(更多内容请参考相应框架学习)
单测依赖介绍如下:
// JUnit4:本地单元测试
'junit:junit:4.13.2',
'androidx.test:core:1.4.0',
// Robolectric:本地单元测试依赖 Android 框架
'org.robolectric:robolectric:4.4',
// Mockito:本地单元测试模拟框架
"org.mockito:mockito-core:3.12.4",
// mock final类时出现错误:Mockito cannot mock/spy because : - final class,增加如下模拟框架
'org.mockito:mockito-inline:3.12.4',
// PowerMock:Mockito的一种扩展(以实现完成对private/static/final方法的Mock)
'org.powermock:powermock-module-junit4:2.0.9',
'org.powermock:powermock-api-mockito2:2.0.9'
比如在 app Module 的 build.gradle 的 dependencies 下,依赖 JUnit4 如下:testImplementation 'junit:junit:4.13.2'
UI 测试暂不做,但为了区分依赖,也罗列如下:
// AndroidJUnitRunner and JUnit Rules:插桩单元测试
'androidx.test:runner:1.4.0',
'androidx.test:rules:1.4.0',
// runner 和 rules 的扩展包:@RunWith(AndroidJUnit4.class) 在此扩展包的 runners 下
'androidx.test.ext:junit:1.1.3',
// Espresso:Android 界面测试
'androidx.test.espresso:espresso-core:3.4.0'
依赖 espresso 如下:androidTestImplementation ‘androidx.test.espresso:espresso-core:3.4.0'
UI 测试时需在defaultConfig中添加:testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
UI 测试覆盖率统计开关打开:testCoverageEnabled true
android {
buildTypes {
debug {
testCoverageEnabled true
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
单测可以访问编译版本的资源:includeAndroidResources = true
android {
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
Mockito是一套模拟框架,允许模拟公有、非静态类的公有函数,提供函数返回模拟、函数调用验证、参数匹配等功能。
Mockito的使用 - 简书
Robolectric使用教程 - HansChen - 博客园
Android单元测试之Robolectric - 简书
Android单元测试框架Robolectric的学习使用_川峰的博客-CSDN博客_robolectric
https://segmentfault.com/a/1190000006811141
Android单元测试只看这一篇就够了 - 简书
其实编写单测和写代码是一样的,只是使用不同工具完成功能或测试。具体操作的话,在 app/src/test/java/包名/ 下创建和代码一样的包结构,然后新建测试文件,编写单测代码即可。有如下注意事项:
为了给应用开发者一个直观的印象,这里还是决定贴出一份单测代码,如有不足之处,还请海涵:
@Config(shadows = [ShadowLog::class, MockCA::class, MockDoExerciseApi::class, MockPortalApi::class], sdk = [23], application = BaseTestApplication::class)
class ProfilePresenterTest : BaseTestRobolectricClass() {
@Spy
lateinit var v: ProfileContract.V
lateinit var p: ProfilePresenter
@Before
fun setUp() {
MockitoAnnotations.openMocks(this)
p = spy(ProfilePresenter::class.java)
p.attachToView(v)
}
/**
* 命名规则:期望输出_测试场景
*/
@Test
fun fiveMethodsShouldBeInvoked_WhenInitData() {
p.initData()
verify(v).updateWeight("")
verify(v, never()).finishActivity()
verify(v, atLeastOnce()).updateHeight("")
verify(v, atLeast(1)).updateExerciseGoal("")
verify(v, times(1)).updateExerciseFrequency("")
verify(v, atMost(1)).updateExerciseTime("")
// 检查是否所有的用例都涵盖了,如果没有将测试失败。放在所有的测试后面
verifyNoMoreInteractions(v)
}
@Test
fun finishActivityMethodShouldBeInvoked_WhenResetUserInfo() {
PrivateAccessor.invoke(p, "resetUserInfo")
verify(v).finishActivity()
}
companion object {
private val TAG = ProfilePresenterTest::class.java.simpleName
}
}
BaseTestRobolectricClass 源码参考:
@RunWith(RobolectricTestRunner::class)
@Config(shadows = [ShadowLog::class], sdk = [23], application = BaseTestApplication::class)
abstract class BaseTestRobolectricClass {
protected val mContext: Context = ApplicationProvider.getApplicationContext()
companion object {
@JvmStatic
protected val TAG: String = this::class.java.simpleName
@BeforeClass
@JvmStatic
fun setup() {
ShadowLog.stream = System.out
}
}
}
在 src/test/java 上右键选择如图 Run...,会跑整体单测代码。跑完后还会在写过单测代码的文件后显示单测覆盖率。也可导出覆盖率为 HTML 文件,但不比 AS 准确。
现象:AS中总代码行高于生成的HTML文件,所以显示的代码行覆盖率低于生成的HTML文件
原因:见截图。可知,HTML文件代码行中,并未包含activity、fragment和view相关的代码行(不知道是因为没写UI测试导致的,或是AS导HTML时导致的)
解决:目前开发时,以AS为准。
一般来说,单测初级阶段,在统计出覆盖率后,行覆盖率达到25%或更高指标时,就算差不多了。但写单测的路也不应就此停下,在维护代码过程中会涉及对单测的修改;在后面新增功能代码时也需新增单测代码。
好了,本文到此也差不多该收尾了,希望能给单测小白一些收获或感悟,文后还附上了官方文档供参考。
【1】Google官方测试文档:https://developer.android.com/training/testing/fundamentals
【2】Mockito官方文档:Mockito (Mockito 3.5.10 API)
【3】Robolectric官方文档:Robolectric