Android单元测试方案
Android单元测试方案
单元测试是应用程序测试策略中的基本测试,通过对代码进行单元测试,可以轻松地验证单个单元的逻辑是否正确,在每次构建之后运行单元测试,可以帮助您快速捕获和修复因代码更改(重构、优化等)带来的回归问题。
目的
提高稳定性,能够明确地了解是否正确的完成开发;
快速反馈bug,跑一遍单元测试用例,定位bug;
在开发周期中尽早通过单元测试检查bug,最小化技术债,越往后可能修复bug的代价会越大,严重的情况下会影响项目进度;
为代码重构提供安全保障,在优化代码时不用担心回归问题,在重构后跑一遍测试用例,没通过说明重构可能是有问题的,更加易于维护。
分类
本地测试(test):只在本地机器JVM上运行,以最小化执行时间,这种单元测试不依赖于Android框架,或者即使有依赖,也很方便使用模拟框架来模拟依赖,以达到隔离Android依赖的目的.
- 仪器化测试(androidTest): 在真机或模拟器上运行的单元测试,由于需要跑到设备上,比较慢,这些测试可以访问仪器(Android系统)信息,比如被测应用程序的上下文,一般地,依赖不太方便通过模拟框架模拟时采用这种方式。
本地测试
引包
testImplementation 'junit:junit:4.13.2'
Junit
JUnit提供了一些辅助函数,他们用来帮助我们确定被测试的方法是否按照预期正常执行,这些辅助函数我们称之为断言(Assertion)。JUnit4所有的断言都在org.junit.Assert类中,Assert类包含了一组静态的测试方法,用于验证期望值expected与实际值actual之间的逻辑关系是否正确,如果不符合我们的预期则表示测试未通过。
Junit5
项目 gradle配置
dependencies {
classpath "de.mannodermaus.gradle.plugins:android-junit5:1.8.2.1"
}
app gradle配置
plugins {
id("de.mannodermaus.android-junit5")
}
// (Required) Writing and executing Unit Tests on the JUnit Platform
testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.2")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.2")
// (Optional) If you need "Parameterized Tests"
testImplementation("org.junit.jupiter:junit-jupiter-params:5.8.2")
// (Optional) If you also have JUnit 4-based tests
testImplementation("junit:junit:4.13.2")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.8.2")
参数化测试
支持外部的各类入参
/**
* csv文件内容:
* shawn,24
* uzi,50
*/
@ParameterizedTest
@CsvFileSource(resources = "/test.csv") //指定csv文件位置
@DisplayName("参数化测试-csv文件")
public void parameterizedTest2(String name, Integer age) {
System.out.println("name:" + name + ",age:" + age);
Assertions.assertNotNull(name);
Assertions.assertNotNull(age);
}
@ParameterizedTest
@MethodSource("method") //指定方法名
@DisplayName("方法来源参数")
public void testWithExplicitLocalMethodSource(String name) {
System.out.println(name);
Assertions.assertNotNull(name);
}
static Stream<String> method() {
return Stream.of("apple", "banana");
}
@ValueSource: 为参数化测试指定入参来源,支持八大基础类以及String类型,Class类型
@NullSource: 表示为参数化测试提供一个null的入参
@EnumSource: 表示为参数化测试提供一个枚举入参
@ParameterizedTest
@ValueSource(strings = {"one", "two", "three"})
@DisplayName("参数化测试1")
public void parameterizedTest1(String string) {
System.out.println(string);
Assertions.assertTrue(StringUtils.isNotBlank(string));
}
结构化测试
public class NestedTestDemo {
@Test
@DisplayName("Nested")
void isInstantiatedWithNew() {
System.out.println("最一层--内嵌单元测试");
}
@Nested
@DisplayName("Nested2")
class Nested2 {
@BeforeEach
void Nested2_init() {
System.out.println("Nested2_init");
}
@Test
void Nested2_test() {
System.out.println("第二层-内嵌单元测试");
}
@Nested
@DisplayName("Nested3")
class Nested3 {
@BeforeEach
void Nested3_init() {
System.out.println("Nested3_init");
}
@Test
void Nested3_test() {
System.out.println("第三层-内嵌单元测试");
}
}
}
}
mockito
Mockito是一个用于java单元测试中的mocking框架,mock就是模拟的意思,就是能够模拟一些类和方法的实现
为什么需要mock?
在写单元测试的时候,我们会遇到某个测试类有很多依赖,这些依赖类或对象又有别的依赖,这样会形成一棵巨大的依赖树。ClassA并不关心ClassB的依赖对象ClassC和ClassE是怎么构造,它只关心ClassB和ClassD的构造
引包
//mockito
testImplementation 'org.mockito:mockito-core:4.9.0'
验证行为
@Test
public void testMock() {
//创建一个mock对象
List list = mock(List.class);
//使用mock对象
list.add("one");
list.clear();
//验证mock对象的行为
verify(list).add("one"); //验证有add("one")行为发生
verify(list).clear(); //验证有clear()行为发生
}
Stubbing
@Test
public void testMock2() {
//不仅可以针对接口mock, 还可以针对具体类
LinkedList list = mock(LinkedList.class);
//设置返回值,当调用list.get(0)时会返回"first"
when(list.get(0)).thenReturn("first");
//当调用list.get(1)时会抛出异常
when(list.get(1)).thenThrow(new RuntimeException());
//会打印"print"
System.out.println(list.get(0));
//会抛出RuntimeException
System.out.println(list.get(1));
//会打印 null
System.out.println(list.get(99));
verify(list).get(0);
}
注意:
对于有返回值的方法,mock会默认返回null、空集合、默认值。比如为int/Integer返回0,为boolean/Boolean返回false、为Object返回null。
一旦stubbing,不管方法被调用多少次,都永远返回stubbing的值。
stubbing可以被覆盖, 如果对同一个方法进行多次stubbing,最后一次的stubbing会生效。
Argument matchers(参数匹配器)
@Test
public void testMock3() {
List list = mock(List.class);
//使用anyInt(), anyString(), anyLong()等进行参数匹配
when(list.get(anyInt())).thenReturn("item");
//将会打印出"item"
System.out.println(list.get(100));
verify(list).get(anyInt());
}
验证方法的调用次数
@Test
public void testMock4() {
List list = mock(List.class);
list.add("once");
list.add("twice");
list.add("twice");
list.add("triple");
list.add("triple");
list.add("triple");
//执行1次
verify(list, times(1)).add("once");
//执行2次
verify(list, times(2)).add("twice");
verify(list, times(3)).add("triple");
//从不执行, never()等同于times(0)
verify(list, never()).add("never happened");
//验证至少执行1次
verify(list, atLeastOnce()).add("twice");
//验证至少执行2次
verify(list, atLeast(2)).add("twice");
//验证最多执行4次
verify(list, atMost(4)).add("triple");
}
验证****回调
代码中调用的函数有可能是注册的回调,如registerCallback(callback),此时如果想对callback进行测试,可以使用thenAnswer
@Mock
private ExampleService exampleService;
@Test
public void test() {
MockitoAnnotations.initMocks(this);
when(exampleService.add(anyInt(),anyInt())).thenAnswer(new Answer<Integer>() {
@Override
public Integer answer(InvocationOnMock invocation) throws Throwable {
Object[] args = invocation.getArguments();
// 获取参数
Integer a = (Integer) args[0];
Integer b = (Integer) args[1];
// 根据第1个参数,返回不同的值
if (a == 1) {
return 9;
}
if (a == 2) {
return 99;
}
if (a == 3) {
throw new RuntimeException("异常");
}
return 999;
}
});
Assert.assertEquals(9, exampleService.add(1, 100));
Assert.assertEquals(99, exampleService.add(2, 100));
try {
exampleService.add(3, 100);
Assert.fail();
} catch (RuntimeException ex) {
Assert.assertEquals("异常", ex.getMessage());
}
}
验证抛异常
@Test
public void test() {
Random mockRandom = mock(Random.class);
when(mockRandom.nextInt()).thenThrow(new RuntimeException("异常"));
try {
mockRandom.nextInt();
Assert.fail(); // 上面会抛出异常,所以不会走到这里
} catch (Exception ex) {
Assert.assertTrue(ex instanceof RuntimeException);
Assert.assertEquals("异常", ex.getMessage());
}
}
使用spy()监视真正的对象
使用spy可以监视对象方法的真实调用。当我们mock某个类时,如果需要某些方法是真实调用,而某些方法是mock调用时,借助spy可以实现这些功能。
@Test
public void testMock10(){
List list = new ArrayList();
List spy = spy(list);
//subbing方法,size()并不会真实调用,这里返回10
when(spy.size()).thenReturn(10);
//使用spy对象会调用真实的方法
spy.add("one");
spy.add("two");
//会打印出"one"
System.out.println(spy.get(0));
//会打印出"10",与前面的stubbing方法对应
System.out.println(spy.size());
//对spy对象依旧可以来验证其行为
verify(spy).add("one");
verify(spy).add("two");
}
doReturn/doThrow/doAnswer/doNothing
由于spy对象默认会走真实的函数,主要给spy对象使用,效果同前面的when().then()
对void方法不能使用when/thenReturn语法。
对spy对象要慎用when/thenReturn
List spyList = spy(new ArrayList());
//下面代码会抛出IndexOutOfBoundsException
when(spyList.get(0)).thenReturn("foo");
//这里不会抛出异常
doReturn("foo").when(spyList).get(0);
System.out.println(spyList.get(0));
缺点
原理:使用Cglib(内部使用ASM)框架来生成测试类的子类,用于屏蔽或执行测试类的方法。所以,对于final,private,static 类和方法,mockito是无能为力的,kotlin的类默认都是final,mockito不太适用。
Mockito简单使用及原理分析
MockK
MockK 是一个用 Kotlin 写的 Mocking 框架。
android studio配置
//mockk
testImplementation "io.mockk:mockk:1.10.5"
mock类
@Test
fun testDoSomething1() {
val mockk = mockk<MockkClassSub>()
//mock指定方法
every { mockk.doSomething1(any()) } returns Unit
val mockkClass = MockkClass(mockk)
//调用被mock的方法
mockk.doSomething1("")
//该方法未通过every进行mock,会报错
mockk.doSomething2("")
}
注意:和mockito不同,mockk的对象方法默认会抛异常
mock对象
mock静态类
@Test
fun testObject() {
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
val classSub = MockkClassSub()
mockkObject(classSub)
//mock指定方法
every { classSub.doSomething1(any()) } returns Unit
val mockkClass = MockkClass(classSub)
//调用被mock的方法
mockkClass.testDoSomething1("")
//调用真实的方法
mockkClass.testDoSomething2("")
}
spyk() & spyk(T obj)
@Test
fun testSpy() {
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
val mockk = spyk<MockkClassSub>()
// val mockkClassSub = MockkClassSub()
// //返回mockkClassSub对象被spyk后的对象,原对象不会改变
// val spyk1 = spyk(mockkClassSub)
every { mockk.doSomething1(any()) } returns Unit
val mockkClass = MockkClass(mockk)
//调用被mock的方法
mockkClass.testDoSomething1("")
//调用真实的方法
mockkClass.testDoSomething2("")
}
mock私有方法
对于 Object 类,需要在mock的时候设置 recordPrivateCalls 为true
@Test
fun testPrivate() {
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
val mockClass = MockkClass(MockkClassSub())
val mockk = spyk(mockClass, recordPrivateCalls = true)
every { mockk["testPrivate"]("") } returns Unit
mockk.testPublic("")
verify {
println("verify testPrivate")
mockk["testPrivate"]("")
}
}
answers
@Test
fun testAnswer() {
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
val mockk = spyk<MockkClassSub>()
// val mockkClassSub = MockkClassSub()
// //返回mockkClassSub对象被spyk后的对象,原对象不会改变
// val spyk1 = spyk(mockkClassSub)
every { mockk.doSomething1(any()) } answers {
println("定制mock行为")
//拿到真实函数信息
val originalMethod = invocation.originalCall
//拿到第一个输入参数
val firstArg = firstArg<String>()
println("定制mock行为 firstArg $firstArg")
//调用真实行为并拿到响应结果
val originalResult = callOriginal()
//同上
val originalResult1 = originalMethod.invoke()
//返回一个固定结果
"mock result"
}
val mockkClass = MockkClass(
musicRepositoryImpl
musicRepositoryImpl)
//调用被mock的方法
mockkClass.testDoSomething1("testDoSomething1")
//调用真实的方法
mockkClass.testDoSomething2("testDoSomething2")
}
verify /verifySequence /verify(timeout)
验证多个方法被调用/验证顺序调用/延迟验证
@Test
fun testVerify() {
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
val mockk = spyk<MockkClassSub>()
// val mockkClassSub = MockkClassSub()
// //返回mockkClassSub对象被spyk后的对象,原对象不会改变
// val spyk1 = spyk(mockkClassSub)
every { mockk.doSomething1(any()) } returns Unit
val mockkClass = MockkClass(mockk)
//调用被mock的方法
mockkClass.testDoSomething1("")
//调用真实的方法
mockkClass.testDoSomething2("")
verify {
println("verify")
mockk.doSomething1("")
mockk.doSomething2("")
}
verifySequence {
println("verifySequence")
mockk.doSomething1("")
mockk.doSomething2("")
}
verify(timeout = 2000) {
println("verify timeout")
mockk.doSomething1(any())
}
}
andthen/andthenThrow/AndThenAnswer
定义后续执行行为
val spyk = spyk<ServiceImplA>()
//定义函数mock行为
val functionAnswer = FunctionAnswer {
println("functionAnswer")
""
}
//定义异常mock行为,返回一个运行时异常
val throwingAnswer = ThrowingAnswer(RuntimeException())
//定义多个行为处理集合,按添加顺序触发
val manyAnswersAnswer = ManyAnswersAnswer(listOf(functionAnswer, throwingAnswer))
//mock
every { spyk.doSomething2(any()) } returns "" andThenAnswer (functionAnswer
) andThenAnswer (throwingAnswer
) andThenAnswer (manyAnswersAnswer
//构造了两个ConstantAnswer组成一个ManyAnswersAnswer对象
//listOf里输入的每个元素会最终赋值到ConstantAnswer的answer方法调用上
//如果这里传入的是字符串,则代表这个answer就仅仅是返回这个字符串
//这里的泛型对应里spyk.doSomething2()的返回参数类型
) andThenMany (listOf("functionAnswer", "throwingAnswer"))
//第一次执行进入到returns ""
spyk.doSomething2("1")
//进入functionAnswer
spyk.doSomething2("2")
try {
//第三次进入throwingAnswer抛出运行时异常
spyk.doSomething2("3")
} catch (e: RuntimeException) {
println("第三次执行抛出运行时异常")
}
//进入manyAnswersAnswer中的functionAnswer
spyk.doSomething2("4")
try {
//进入manyAnswersAnswer中的throwingAnswer
spyk.doSomething2("5")
} catch (e: RuntimeException) {
println("第5次执行抛出运行时异常")
}
//将返回functionAnswer
println("第6此调用返回:${spyk.doSomething2("6")}")
spyk.doSomething2("7")
mock****局部变量
有时,我们需要检测或控制局部变量。
fun testPrivateParam() {
Log.d(TAG, "testPrivateParam")
val mockClassPrivate = MockkClassSub()
mockClassPrivate.doSomething1("")
}
@Test
fun testPrivateParam() {
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
mockkConstructor(MockkClassSub::class)
// every { anyConstructed<MockkClassSub>().doSomething1("") } returns Unit
val mockkClass = MockkClass(MockkClassSub())
mockkClass.testPrivateParam()
verify {
anyConstructed<MockkClassSub>().doSomething1("")
}
}
Robolectric
注意:robo暂不支持junit5
第一次运行会下载对应android包,比较慢
引包
android {
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
//robolectric
testImplementation 'org.robolectric:robolectric:4.8'
broadcastreceiver
广播的测试点可以包含两个方面
- 验证应用程序是否注册了该广播
- 验证广播接收器的处理逻辑是否正确,关于逻辑是否正确,可以直接人为的触发onReceive()方法,让然后进行验证
public class MyReceiver extends BroadcastReceiver {
private final static String TAG = "MyReceiver";
@Override
public void onReceive(Context context, Intent intent) {
Log.d(TAG, "onReceive");
SharedPreferences.Editor editor = context.getSharedPreferences(
"account", Context.MODE_PRIVATE).edit();
String name = intent.getStringExtra("EXTRA_USERNAME");
editor.putString("USERNAME", name);
editor.apply();
}
}
@RunWith(RobolectricTestRunner::class)
class MyReceiverTest {
@Test
fun onReceive() {
val action = "com.max.unitdemo.receiver"
val intent = Intent(action)
intent.putExtra("EXTRA_USERNAME", "hello")
val myReceiver = MyReceiver()
myReceiver.onReceive(RuntimeEnvironment.application, intent)
val application = RuntimeEnvironment.application
val preferences: SharedPreferences = application
.getSharedPreferences("account", Context.MODE_PRIVATE) as SharedPreferences
Assert.assertEquals("hello", preferences.getString("USERNAME", ""))
}
@Test
fun register() {
val shadowApplication = ShadowApplication.getInstance()
val action = "com.max.unitdemo.receiver"
val intent = Intent(action)
// 验证是否注册了相应的Receiver
Assertions.assertTrue(shadowApplication.hasReceiverForIntent(intent))
}
}
Activity
robo提供了ActivityController,通过ActivityController,我们可以很容易操作activity对应生命周期事件
详细使用见demo
Fragment
fragment的测试在robo中已经废弃,官方推荐使用FragmentScenario来测试
主要功能包括创建fragment,操作生命周期,拿到fragment的对象。可以与robo一起使用
android 引包
//fragment test
debugImplementation("androidx.fragment:fragment-testing:1.5.4")
Service
robo提供了ServiceController操作service对应生命周期
Toast
binding.buttonActivity.setOnClickListener {
Toast.makeText(this@MainActivity, "hello", Toast.LENGTH_LONG).show()
}
@Test
fun testToast() {
val activity = Robolectric.setupActivity(MainActivity::class.java)
val btn = activity.findViewById<View>(R.id.button_activity) as Button
btn.performClick()
// val shadowActivity = Shadows.shadowOf(activity)
// val shadowTextView = Shadows.shadowOf(btn)
Assert.assertNotNull(ShadowToast.getLatestToast())
Assert.assertEquals("hello", ShadowToast.getTextOfLatestToast())
}
Shadow
robolectric有很多shadow类来修改或拓展Android OS原本的类......每一次执行android类时,robolectric确保shadow类先执行。再详细一点作用,android.jar只是让我们app能编译通过,运行时如果使用android.jar则会报RuntimeException,对应的Shadow类就屏蔽掉了这个问题,同时Shadow类也提供了对应的记录功能,方便测试和拿到对应的状态。如ShadowActivity,ShadowService,ShadowToast。
android原生的类基本robo都有Shadow,同时,robo也提供了自定义Shadow类的功能。
class Company(var name: String?) {
fun welcome() {
println("method called in Company.")
}
fun sayHello() {
println("say hello in Company.")
}
}
@Implements(Company::class)
class ShadowCompany {
//Robolectric会自动设置真实的关联对象
//注意,如果调用使用被shadow的函数,还是会走到shadow类中
@RealObject
private val company: Company? = null
//必须实现该方法,参数与构造函数参数一样
fun __constructor__(name: String?) {
println("constructor in shadow class. ")
}
@Implementation
fun welcome() {
// company?.welcome()
println("method called in ShadowCompany. ${company?.name}")
}
}
//通过shadows配置自定义的shadow class
@Config(shadows = [ShadowCompany::class])
@Test
fun testShadow() {
val company = Company("hello")
company.welcome()
company.sayHello()
}
注意
- @Implements注解指定需要对哪个类进行shadow;
- @Implementation指定需要对哪个方法进行替换;
- 使用__constructor__来对构造器进行替换;
- @RealObject来引用真实的关联对象;
仪器化测试
在AndroidTest目录下,使用AndroidJunit4。运行测试用例时,会直接将app跑到真机上,在运行时就可以拿到app的各个对象。
常用的测试框架有 UiAutomator,Espressso
UiAutomator:通过匹配规则找到控件,然后调用相关事件。1.0版本是shell进程,2.0版本是app进程(可以使用targetapp数据)。两个版本都是通过 系统的 UiAutomator类来发送相关事件
Espressso: 通过匹配规则找到控件,调用相关事件。8.0以上版本才支持跨进程
Android Espresso是如何获取View? (一)—— Espresso源码篇 - 掘金
这两个框架常用于UI自动化测试,不适用于做函数的单元测试。
测试范围
框架只是便于我们测试代码,对于项目不同层,可以不使用或使用不同的测试框架
以上面结构举例
activity/receiver/service:robo + mockk
fragment:robo + mockk
viewmodel: mockk
repository: mockk
database:robo + mockk
network: mockk
覆盖率
测试结果
jacoco覆盖率
jacoco引包
plugins {
id 'jacoco'
}
Demo
暂时无法在文档外展示此内容
测试心得
考虑可读性:对于方法名使用表达能力强的方法名,对于测试范式可以考虑使用一种规范, 如 RSpec-style。方法名可以采用一种格式,如:测试的方法_条件__预期结果。
不要使用逻辑流关键字:比如(If/else、for、do/while、switch/case),在一个测试方法中,如果需要有这些,拆分到单独的每个测试方法里。
测试真正需要测试的内容:需要覆盖的情况,一般情况只考虑验证输出(如某操作后,显示什么,值是什么)。
不需要考虑测试private的方法:将private方法当做黑盒内部组件,测试对其引用的public方法即可;不考虑测试琐碎的代码,如getter或者setter。
每个单元测试方法,应没有先后顺序:尽可能的解耦对于不同的测试方法,不应该存在Test A与Test B存在时序性的情况。
引用
Android—junit5
junit5 官方文档
mockito
mockk
Robolectric使用教程