cyrushine / bookmark

4 stars 1 forks source link

效能笔记 Android单元测试与JUnit源码解析 - 简书 - yjy239 #62

Open cyrushine opened 1 year ago

cyrushine commented 1 year ago

https://www.jianshu.com/p/2bf4ecd1752b

优势和必要性

测试金字塔

70%的小型测试:单元测试。对应到Android开发中是指本地单元测试(执行本地的JVM 如 Mockito)或者依赖测试模拟的Android环境进行单元测试(如Robolectric)。

20%的中型测试:集成测试. 对应到Android 开发中 就是使用Espresso 链接真机模拟真实操作

10%的大型测试:端对端测试。对应到Android开发中,就是使用如Google提供的 Firebase 测试实验室 在云端进行大规模测试你的应用,会通过验证不同的机型环境下你的应用是否能够正常运行。如在腾讯中,还会有录屏等功能分析视频中的帧数,校验元素的间距是否正常。往往是通过插桩等手段进行监控。

image

测试替代(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代码去执行这个调用的过程

AwesomeScreenshot-www-jianshu-p-2bf4ecd1752b-2023-04-19_3_37_part1

AwesomeScreenshot-www-jianshu-p-2bf4ecd1752b-2023-04-19_3_37_part2