Android 单元测试接入指南
基本介绍
定义
单元测试是验证 指定输⼊ 的 实际结果 是否与 预期结果 匹配的测试。
接入单元测试的目的
单元测试的好处
提升代码的稳定性,保证代码的逻辑和边界均可覆盖
自动化测试,利于自测与重构测试
促进代码设计,让代码有明确的输入输出、各个层级间功能清晰
单元测试的问题点
部分情况会导致总开发时间更长
功能更新,单元测试代码必须同步更新
核心概念
主体:通常为公共类的 public 方法,也即业务流程中的一个流程节点;
目的:验证流程节点的处理结果是否与预期结果匹配;
关注点:指定输入、预期结果、实际结果,不应关注测试对象任何内部流程、任何内部细节
用例个数:需覆盖输入与输出产生绝大部分的组合,并非一个测试主体一个用例的简单对应关系
输入集、预期结果
输入集:测试主体所有可能的输入集合类型,囊括正常输入、异常输入。输入集中多数输入在第一次编写流程节点的单元测试代码时确立,少数(多为异常输入)在后续迭代中伴随故障的产生而补充入输入集;
预期结果:将预设的输入在 特定场景 下输入到流程节点后,期望得到的结果。预期结果可能是状态、单个行为、行为链,原则上预期结果不会是私有的状态、行为(private方法调用)。
原则:输入与预期结果一一对应,有输入必有结果反馈;(这也就反射要求代码设计让有明确的分层和输入输出)
测试分类
按照预期结果的种类,可将单元测试分为两大类:
状态测试:预期结果多为返回值、测试主体所在类暴露在外的成员变量。
行为测试:预期结果多为特定行为(链),具体来说是其他 public 方法的调用:
-
- 内部行为:为了防止内部嵌套测试的出现,对 public 做单元测试时方法内部其他的public方法,亦可作为预期结果;
-
- 异常行为:异常输入产生异常结果,异常结果可能是抛出 Exception
-
- 行为链:当指定输入可能产生多个需要关注的一系统行为时,测试的验证就应验证这一系列行为而非最后一次行为:
单元测试的接入流程
|
单元测试的维度
按Android目录分类
androidTest 目录应包含在真实或虚拟设备上运行的测试。此类测试包括集成测试、端到端测试,以及仅靠 JVM 无法完成应用功能验证的其他测试。
test 目录应包含在本地计算机上运行的测试,如单元测试。
按运行环境分类
虚拟环境测试
JUnit测试
直接运行在PC端Java虚拟机环境中,只能测试标准的java包内容,不能测试Android的上下文,测试速度更快。
模拟设备
Robolectric等测试,在PC端模拟大部分的真机环境
真机测试
基本AndroidJUnit4的测试代码直接运行在手机设备上运行,几乎具有Android代码运行的所有上下文。用例在测试时,需要先安装原始的apk,同时AndroidJUnit4的代码会打包成另外一个apk,不过代码的运行是运行在原始apk的进程,因此能完全模拟真机的状态。同时还有一点需要注意,在运行一个测试用例时,原始的apk跑一遍apk启动的流程,类似的用户的所有初始化行为都会自动产生。
按测试内容分类
测试金字塔(如图 所示)说明了应用应如何包含三类测试(即小型、中型和大型测试):
小型测试是指单元测试,用于验证应用的行为,一次验证一个类。
中型测试是指集成测试,用于验证模块内堆栈级别之间的互动或相关模块之间的互动。
大型测试是指端到端测试,用于验证跨越了应用的多个模块的用户操作流程。
沿着金字塔逐级向上,从小型测试到大型测试,各类测试的保真度逐级提高,但维护和调试工作所需的执行时间和工作量也逐级增加。因此,您编写的单元测试应多于集成测试,集成测试应多于端到端测试。虽然各类测试的比例可能会因应用的用例不同而异,但我们通常建议各类测试所占比例如下:小型测试占 70%,中型测试占 20%,大型测试占 10%。
单元测试基础实践
基本框架介绍
功能点 | 支撑框架 | 关键类、方法 |
---|---|---|
框架/容器 | JUnit | @Before、@After、@Test、@RunWith() |
状态验证 | JUnit | Assert#assertXxxx() |
行为验证 - 依赖Mock | Mockito | BDDMockito#given、BDDMockito#then |
行为验证 - 静态、私有Mock | PowerMock | PowerMockito |
四大组件测试 | Roboletric | |
完全真机模拟 | AndroidJUnit4 |
BDD编码规范
Given(环境搭建)
Given步骤需要搭建环境,为之后的操作提供测试基础。其操作可分为几大类:
测试主体所在类的创建;
Mock 依赖类的注入;
将测试主体的状态设置为预期状态(如测试唤醒方法是否正确时,需先将语音置为休眠)
准备测试需要的数据;
插桩:当测试主体受依赖类某些方法的返回值、回调影响时,应对这些方法进行插桩操作。因为Mock之后的对象,并不会执行真实流程,通常无法给出有效的结果以致测试主体无法正常执行。
When(执行测试)
When步骤在Given步骤搭建的环境中,直接执行测试主体的调用,以触发需要的行为或者获得需要的状态。
Then(结果验证)
Then步骤拿到When步骤的执行结果后,需验证结果是否符合预期
状态验证
*/***
** 说明:行为测试Demo*
** 典型场景:业务逻辑类的测试多数为⾏行行为测试,类中的依赖普遍呈现错综复杂的情况,通常都需要将其中的依赖都Mock 出对应的类以完成⾏行行为测试;*
** 验证原则:预期结果多为 验证返回值、测试主体所在类暴露在外的成员变量量*
** 推荐流程:Given(环境搭建) -> When(执⾏行行测试) -> Then(结果验证)*
*** ***@author\*** *wangshengxing* *08.20* *2020*
**/*
class StandardStateTest {
val TAG = **"StandardStateTest"**
init {
TesterLog.init()
}
*/***
** 测试对象*
** 示例验证除法的正确性*
**/*
fun divide(a:Int,b:Int):Int{
return a/b
}
*/***
** Demo:测试除方法是否正常*
**/*
**@Test**
fun canDivideWork(){
//given
val a=10
val b=5
val expect=2
//when
val result=divide(a,b)
L.i(TAG, **"canDivideWork:** ${result}**"**)
//then
Assert.assertEquals(expect,result)
}
}
真机状态验证
*/***
** 说明:加解密工具验证*
*** ***@author\*** *wangshengxing* *08.19* *2020*
**/*
**@RunWith**(AndroidJUnit4::class)
class CipherUnitTest {
**@Test**
fun encryptDecryptWork() {
//given
val str=**"123456"**
//when
val encryptData= CipherUtil.encryptData(str)
val decryptData= CipherUtil.decryptData(encryptData)
//then
Assert.assertEquals(str, decryptData)
}
}
单元测试进阶
插桩之spy与mock
插桩指对原有的代码行为进行定制修改,通常有spy和mock两种形式。mock方法和spy方法都可以对对象进行插桩。但是前者是接管了对象的全部方法,而后者只是将有桩实现(stubbing)的调用进行mock,其余方法仍然是实际调用。
spy的标准是:如果不打桩,默认执行真实的方法,如果打桩则返回桩实现。
*/***
** 对部分代码进行插桩*
**/*
**@Test**
fun canSpyList(){
//given
val list: MutableList = LinkedList()
val spy: MutableList = spy(list)
`when`(spy.size).thenReturn(100)
//when
spy.add(**"one"**)
spy.add(**"two"**)
L.i(TAG, **"canSpyList: list size** ${spy.size}**"**)
//then
Assert.assertEquals(spy[0], **"one"**)
Assert.assertEquals(100, spy.size)
}
行为验证
*/***
** 说明:状态测试Demo*
** 典型场景:⼯工具类的测试偏向于状态测试。⼯工具类不不处理理具体业务,只提供算法、业务⽆无关的操作等,验证其返回结果即可完成测试;*
** 验证原则:*
** 行为是否执行;*
** 行为执行次数是否符合预期;*
** 行为执行时的参数是否符合预期;*
** 行为如果指定了监听器,监听器中操作是否符合预期;*
*** ***@author\*** *wangshengxing* *08.20* *2020*
**/*
class StandardBehaviorTest {
val TAG = **"StandardBehaviorTest"**
object NetUtil{
fun hasConnected()=true
}
interface LoginListener{
fun onLogin(name:String,password:String)
fun onFailed()
}
class DemoModel{
fun login(name:String,password:String,listener:LoginListener){
if (NetUtil.hasConnected()) {
//...
listener.onLogin(name, password)
}else{
listener.onFailed()
}
}
}
init {
TesterLog.init()
}
**@Test**
fun canLoginWhenAllNormal(){
//given
val name=**"user"**
val password=**"123456"**
val listener = mock(LoginListener::class.*java*)
val model=DemoModel()
//when
model.login(name, password, listener)
//then 验证函数成功调用
then(listener).should().onLogin(Mockito.anyString(),Mockito.anyString())
//验证调用次数
then(listener).should(Mockito.times(1)).onLogin(name,password)
//Mockito.any<>() 返回值为null ,因此需要自己定义
then(listener).should(Mockito.timeout(2000)).onLogin(UT.any(),UT.any())
//验证onFailed没有调用过
then(listener).should(Mockito.times(0)).onFailed()
Mockito.verify(listener, Mockito.never()).onFailed()
}
}