效能笔记 Android单元测试与JUnit源码解析 - 简书 - yjy239
https://www.jianshu.com/p/2bf4ecd1752b
优势和必要性
- 单元测试是软件工程中降低开发成本,提高软件质量的方法之一
- 单元测试可以降低开发成本,通过边界检测提高代码质量,提高代码的设计的解耦度
- 当考虑到代码需要单元测试时候,如果单元测试是比较好写,少了很多mock说明代码设计的不错,解耦和隔离都做的不错
- 在项目的迭代中,有一种模式名为测试驱动开发(
TDD)的方式。其含义是把迭代中每一个应用视为一系列的模块, 在开发设计每一个功能时候,就先编写一个测试,然后不断的添加断言在其中,在编写的设计的过程中同时考虑到隔离性正确性
测试金字塔
70%的小型测试:单元测试。对应到Android开发中是指本地单元测试(执行本地的JVM 如 Mockito)或者依赖测试模拟的Android环境进行单元测试(如Robolectric)。
20%的中型测试:集成测试. 对应到Android 开发中 就是使用Espresso 链接真机模拟真实操作
10%的大型测试:端对端测试。对应到Android开发中,就是使用如Google提供的 Firebase 测试实验室 在云端进行大规模测试你的应用,会通过验证不同的机型环境下你的应用是否能够正常运行。如在腾讯中,还会有录屏等功能分析视频中的帧数,校验元素的间距是否正常。往往是通过插桩等手段进行监控。
测试替代(Test Doubles)
有依赖外部输入请保证外部输出的正确性和稳定性。 因此,对于网络请求和数据库相关的单元测试一般都会想办法转化成内存级别的输入输出。对于网络和数据库相关的单测请再自己模块进行测试,保证业务和组件库的隔离。
测试替代本质就是合理的隔离外部依赖,提高测试的正确性和速度
| 方式 | 含义 |
|---|---|
| Fake (假对象) | 一般是单测依赖对象抽象出需要对外业务的接口。创建一个全新的对象实现该接口。而实现的方法将会重新实现,代替原来所有复杂的实现(如网络请求和数据库请求)。一般是用于ViewModel 中所控制的数据仓库对象,把其中相关磁盘存储,网络请求替换成内存级别的实现 |
| Mock (模拟对象) | 可以将类替换成一个全新对象,可以跟踪方法的运行情况。甚至允许类中的实现转化成空实现。但是mock出来的 |
| Stub (存根) | 将依赖类转化成一个无逻辑的,只返回结果的类 |
| Dummy (虚拟对象) | 提供一个没有任何操作的测试代替对象给单测对象 |
| Spy (间谍) | 将可以跟踪被Spy持有的类的运行结果 |
常用的库
| library | desc |
|---|---|
| JUnit4 | 这是最基础的单元测试库。一般是在Android中的test的目录下,仅仅用于测试无关Android环境的Java 类,只提供了最基础的单元测试断言以及运行环境 |
| Mockito | 这是用于解决测试类对其他外部的依赖,用于验证方法的调用。这个库中包含了mock和spy两种解决外部依赖方案 |
| PowerMock | 这个库可以看成Mockito的升级版。Mock存在着无法获取static静态对象和方法,private私有对象和方法的缺点。实际上单测需要获取这些私有对象来确定是否执行正确。PowerMock则很好的解决了这个缺点。 |
| Robolectric | 本地模拟Android 环境运行Android相关的测试代码 |
| Espresso | 这是生成一个单测的apk包在真机或者模拟机上运行单元测试代码 |
| mockk | 用于给kotlin使用的mock 测试库 |
| JMock | 一个专门用于验证方法执行的Mock库 |
| 其他 | androidx.test.ext:junit,androidx.fragment:fragment-testing 等提供一些Androidx的测试便捷库 |
dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation "org.hamcrest:hamcrest-all:1.3"
testImplementation "org.mockito:mockito-core:5.3.0"
testImplementation "org.robolectric:robolectric:4.10"
}JUnit
| 注解 | 使用 |
|---|---|
| @test | 代表当前方法为一个测试方法 |
| @Before | 在执行每一个测试方法之前的调用,一般做依赖类的准备操作 |
| @After | 执行完所有方法后的调用,一般进行资源回收 |
| @ignore | 被忽略的测试方法 |
| @BeforeClass | 在类中所有方法运行前运行。必须是static void修饰的方法 |
| @AfterClass | 类最后运行的方法 |
| @RunWith | 指定该测试类使用某种运行器 |
| @parameters | 指定测试类的测试数据集合 |
| @Rule | 是指测试的规则。每一个测试的通用处理方式。我们可以自定义@Rule,让一个类的每一个测试方法增加前后日志,或者多执行几次测试方法,有点像做横切面 AOP |
| @FixMethodOrder | 指定测试类中方法的顺序 |
| 常用断言 | 描述 |
|---|---|
| assertNotEquals | 断言预期传入值和实际值不相等 |
| assertArrayEquals | 断言预期传入数组和实际数组值相等 |
| assertNull | 断言传入对象是空 |
| assertNotNull | 断言传入对象不是空 |
| assertTrue | 断言为真 |
| assertFalse | 断言条件为假 |
| assertSame | 断言两个对象是同一个对象,相当于"==" |
| assertNotSame | 断言两个对象不是同一个对象,相当于"!=" |
| assertThat | 断言实际值是否满足指定条件 |
| assertThrows | 断言会抛出指定类型的异常 |
用 hamcrest 的匹配器(Matcher)拓展断言
assertThat(52, allOf(lessThan(60), greaterThan(45)));
// pass
// 断言 52 小于 60 且大于 45| 匹配器 | 说明 |
|---|---|
| is | 断言参数等于后面给出的匹配表达式 |
| not | 断言参数不等于后面给出的匹配表达式 |
| equalTo | 断言参数相等 |
| equalToIgnoreCase | 断言字符串忽略大小写是否相等 |
| containString | 断言字符包含字符串 |
| startsWith | 断言字符串以某字符串开始 |
| endWith | 断言字符串以某字符串结束 |
| nullValue | 断言参数的值为null |
| notNullValue | 断言参数的值不为null |
| greaterThan | 断言参数大于 |
| lessThan | 断言参数小于 |
| greaterThanOrEqualTo | 断言参数大于等于 |
| lessThanOrEqualTo | 断言参数小于等于 |
| closeTo | 断言浮点型数在某一范围内 |
| allOf | 断言符合所有条件,相当于&& |
| anyOf | 断言符合某一个条件,相当于或 |
| hasKey | 断言Map集合包含有此键 |
| hasValue | 断言Map集合包含有此值 |
| hasItem | 断言迭代对象含有此元素 |
Mockito
构造 mock 对象的两种方式
SharedPreferences.Editor mockedEditor = mock(SharedPreferences.Editor.class);
@RunWith(MockitoJUnitRunner.class)
public class ExampleUnitTest {
@Mock
private SharedPreferences.Editor mockedEditor;
}实现 mock 对象
SharedPreferences.Editor mockedEditor = mock(SharedPreferences.Editor.class);
// 定义方法的行为
when(mockedEditor.putBoolean(anyString(), anyBoolean())).thenThrow(new IllegalArgumentException());
when(mockedEditor.remove(anyString())).thenReturn(mockedEditor);
when(mockedEditor.commit()).thenReturn(true, false, true, false);
// 测试上面的方法定义是否正确
assertThrows(IllegalArgumentException.class, () -> mockedEditor.putBoolean("key", true));
assertEquals(mockedEditor.remove("key"), mockedEditor);
assertTrue(mockedEditor.commit());
assertFalse(mockedEditor.commit());
// mock 出来的对象,如果方法没有被定义,则是空方法,返回默认值:null、0、false 等
assertNull(mockedEditor.putInt("key", 6));
// when-then 和 given-will 作用一样
given(mockedEditor.putInt(anyString(), anyInt())).willThrow(new NullPointerException());
assertThrows(NullPointerException.class, () -> mockedEditor.putInt("key", 34));通过 spy 修改类/对象的行为
List<Integer> adds = new ArrayList<>();
adds.add(8);
adds.add(5);
adds.add(2);
List<Integer> spy = Mockito.spy(new ArrayList<>());
doThrow(new IllegalArgumentException()).when(spy).add(anyInt());
// 其他方法不受影响,mock 对象是空方法
assertEquals(spy.size(), 0);
spy.addAll(adds);
assertEquals(spy.size(), adds.size());
// 当调用 add(int) 是抛出异常
assertThrows(IllegalArgumentException.class, () -> spy.add(7));verify 验证方法调用次数和参数
SharedPreferences.Editor mock = Mockito.mock(SharedPreferences.Editor.class);
String key = "key", value = "value";
mock.putString(key, value);
verify(mock, times(1)).putString(key, value);
verify(mock, atLeast(1)).putString(key, value);robolectric
对于一个单元测试来说,链接真机的场景进行测试一般是大型测试需要模拟真实环境才需要的。或者说进行ui元素相关的校验才需要的测试。而绝大部分的测试都没有要求到ui元素校验,大多只是为了校验业务数据的是否正确。而这部分业务的校验依赖了android 系统的环境导致不能不链接真机/虚拟机。
而这种中小型的测试占了50%以上的情况都需要链接真机/虚拟机就太过浪费时间了,那么有没有办法在本地进行android测试呢?
如果使用Local测试,需要保证测试过程中不会调用Android系统API,否则会抛出RuntimeException异常,因为Local测试是直接跑在本机JVM的,而之所以我们能使用Android系统API,是因为编译的时候,我们依赖了一个名为“android.jar”的jar包,但是jar包里所有方法都是直接抛出了一个RuntimeException,是没有任何任何实现的,这只是Android为了我们能通过编译提供的一个Stub!当APP运行在真实的Android系统的时候,由于类加载机制,会加载位于framework的具有真正实现的类。由于我们的Local是直接在PC上运行的,所以调用这些系统API便会出错。
那么问题来了,我们既要使用Local测试,但测试过程又难免遇到调用系统API那怎么办?其中一个方法就是mock objects,比如借助Mockito,另外一种方式就是使用Robolectric, Robolectric就是为解决这个问题而生的。它实现一套JVM能运行的Android代码,然后在unit test运行的时候去截取android相关的代码调用,然后转到他们的他们实现的Shadow代码去执行这个调用的过程


