​Android下单元测试实践——测试框架简介

前言

测试代码的写法可以归纳为三部分

第一部分: 准备测试数据和定义mock行为

第二部分: 调用真实的函数

第三部分: 调用验证函数进行结果的验证

Junit4

在模块的test路径下编写测试案例。在类中使用@Test注解,就可以告诉Junit这个方法是测试方式。同时使用assert*方法,可以调用Junit进行结果的验证。

  1. @Test

  2. public void test() {

  3. assertEquals("abc", getActual());

  4. }

Junit常用注解

除了@Test注解,还有以下常见的注解可供使用

注解作用备注
@BeforeClass会在所有的方法执行前被执行,static 方法 (全局只会执行一次,而且是第一个运行)全局的含义是在一个测试类中
@AfterClass会在所有的方法执行之后进行执行,static 方法 (全局只会执行一次,而且是最后一个运行)
@Before会在每一个测试方法被运行前执行一次 可以用来清理执行环境,保证测试用例在执行前具有干净的上下文
@After会在每一个测试方法运行后被执行一次
可选使用Junit的Rule简化代码

和Junit的@Before和@After分别作用于每一个单元测试案例的开始和结束类似,@Rule注解提供了同样的能力,但有一个好处就是执行前,执行单元测试和执行后在同一个方法中,包含在同一个上下文中,这能让我们更加灵活的处理单元测试。

使用起来也比较简单:

第一步:实现TestRule接口

  1. public class MethodNameExample implements TestRule {

  2.     @Override

  3.     public Statement apply(Statement base, Description description) {

  4.         //想要在测试方法运行之前做一些事情,就在base.evaluate()之前做

  5.         String className = description.getClassName();

  6.         String methodName = description.getMethodName();

  7.         base.evaluate();  //这其实就是运行测试方法

  8.         //想要在测试方法运行之后做一些事情,就在base.evaluate()之后做

  9.         System.out.println("Class name: "+className +", method name: "+methodName);

  10.         return base;

  11.     }

  12. }

第二步:在Test类中使用。加上@Rule注解即可

  1. @Rule

  2. public MethodNameExample methodNameExample = new MethodNameExample();

使用Parameterized特性减少重复测试用例(Junit5自带,Junit4需额外引入依赖)

根据不同的输入,待测试函数会有不同的输出结果,那么我们就需要针对每一类的输入,编写一个测试用例,这样才能覆盖待测函数的所有逻辑分支。(写多少个测试用例能够覆盖全所有的逻辑分支可称之为待测函数的圈复杂度).

使用Junit4提供的Parameterized Tests特性,可以帮助我们减少用例编写的代码,使测试类更清晰简单,而且数据可以从CSV文件导入。以下提供一个例子 ( 官方例子见参考资料 [11] ) :

第一步:引入依赖

testImplementation(rootProject.ext.dependencies.jupiter)

第二步:测试类中添加注解

  1. @RunWith(Parameterized.class)

  2. public class BioTest {

  3. }

第三步:可以定义实例变量,明确输入和输出。比如这里,我们定义了2个变量,一个是预期的输出结果,一个是输入的参数。

  1. private boolean expectedResult;

  2. private String inputTime;

第四步:定义构造函数,在构造函数中对变量赋值

  1. public BioPayProviderTest2(boolean expectedResult, String time) {

  2. this.expectedResult = expectedResult;

  3. this.time = time;

  4. }

第五步:定义数据集,使用注解标注,返回一个数组,数组代表的就是Junit4需要提供给构造函数进行实例化的数据集。

  1. @Parameterized.Parameters

  2. public static Collection<Object[]> data() {

  3. return Arrays.asList(new Object[][]{

  4. {true, null},

  5. {false, System.currentTimeMillis() + ":"},

  6. {true, (System.currentTimeMillis() - 73 * 3600) + ":"}

  7. });

  8. }

第六步:编写测试用例。对于以下的测试用例,Junit4会使用第五步的数据进行填充,执行3次。

  1. @Test

  2. public void test() throws Exception {

  3. boolean ret = needShowDialog(time, mockWalletId);

  4. Assert.assertEquals(expectedResult, ret);

  5. }

Mockito

Mockito是目前使用比较广泛的Mock框架,他可以根据需要mock出虚假的对象,在测试环境中,可以用来替换掉真实的最像,达到两大目的:

  1. 验证这个对象的某些方法的调用情况,调用了多少次,参数是什么等等
  2. 指定这个对象的某些方法的行为,返回特定的值,或者是执行特定的动作

使用Mockito mock对象的某些方法的行为

  1. // 使用mock函数即可模拟一个对象,这个对象的实例变量均为默认值

  2. BioGuidePresenter presenter = Mockito.mock(BioGuidePresenter.class);

  3. // 设定这个mock对象被调用某一方法时,应该返回的结果

  4. Mockito.when(presenter.checkIsEnrolled(1)).thenReturn(true);

使用Mockito Spy对象

  1. // 使用spy函数即可模拟一个对象,这个对象的实例变量均为默认值

  2. BioGuidePresenter presenter = Mockito.spy(BioGuidePresenter.class);

  3. // 设定这个mock对象被调用某一方法时,应该返回的结果

  4. Mockito.when(presenter.checkIsEnrolled(1)).thenReturn(true);

spy()和mock()的区别在于,未指定mock方法的返回值时,默认返回null,而为指定spy方法的返回值时,默认执行目标方法的逻辑,并返回对应逻辑执行的结果。另外有一个很重要的区别在于,使用spy的情况下,虽然提供了函数的模拟实现,但Mockito框架仍然会调用真实的代码,所以如果真实代码无法在单测下运行,则使用spy模拟会导致测试失败。

使用Mockito验证结果

  1. // 验证mock的database对象,调用setUniqueId方法时的入参是否为12

  2. verify(database).setUniqueId(ArgumentMatchers.eq(12));

  3. // 验证mock的database对象的getUniqueId方法是否被调用2次

  4. verify(database, times(2)).getUniqueId();

  5. // 验证mock的database对象的getUniqueId方法是否被调用1次

  6. verify(database).getUniqueId();

  7. // 也可以使用传统的Junit判断方法判断结果是否符合预期

  8. assertEquals("foo", spy.get(0));

使用Mockito-inline模拟静态方法

Mockito版本升级之后,支持对Static Method做Hook。前提是在build.gradle中引入Mockito-inline。

org.mockito:mockito-inline:${version}

以下是实例被测代码,当我们在测试类中调用doSomethings(),很可能无法通过调整MemoryService的返回值,控制doSomthing2的入参,从而覆盖更多的逻辑分支。此时我们就需要Hook DataEngine甚至是MemoryService,获取我们想要的返回值。

  1. public void doSomethings() {

  2. DataEngine.getMemoryService().saveCacheObject("key", "abc");

  3. ...

  4. String a = DataEngine.getMemoryService().getCacheObject("key");

  5. doSomething2(a);

  6. }

下面给出使用mockito-inline对静态方法的处理步骤。

一.Hook静态方法。 使用Java7的try-with-resource语法,模拟触发静态方法 (DataEngine .getMemoryService  的行为。

需要注意的是:mockService可以是通过Mockito mock出来的,也可以是我们创建的一个真实的MemoryService子类,区别在于,使用Mockito mock的MemoryService我们不需要实现所有的方法,只需要mock我们测试类中可能调用到的方法。

  1. MemoryStoreService mockService = Mockito.mock(MemoryStoreService.class);

  2. try (MockedStatic<DataEngine> service = Mockito.mockStatic(DataEngine.class)) {

  3. service.when(DataEngine::getMemoryService).thenReturn(mockService);

  4. }

二. 使用更加智能的模拟返回方法。

我们使用较多的是thenReturn()方法,但是在本案例的场景下,我们需要功能更强大的返回方法。因为:处理模拟的入参,

1. MemoryService::saveCacheObject返回值是Void,所以无法使用thenReturn()

2. 我们需要处理入参,针对每一个saveCacheObject的模拟调用,我们都需要真实的将其保存到Map中

  1. final Map<String, Object> pools = new HashMap<>();

  2. //当触发了mockService的saveCacheObject方法,就会回调answer(),从而将入参的Key和Value保存到Map中

  3. Mockito.doAnswer(new Answer() {

  4. @Override

  5. public Object answer(InvocationOnMock invocation) throws Throwable {

  6. pools.put((String) invocation.getArgument(0), invocation.getArgument(1));

  7. return null;

  8. }

  9. }).when(mockService).saveCacheObject(Mockito.anyString(), Mockito.any());

当我们使用doAnswer模拟了saveCacheObject,那我们很有可能需要使用同样的策略模拟getCacheObject。就像这样:

  1. Mockito.when(mockService.containsCachedObject(Mockito.anyString()))

  2. .thenAnswer(invocation -> pools.containsKey(invocation.getArgument(0)));

使用Mockito测试异步代码段

假如需要测试一段异步代码,可以使用标准的异步代码测试步骤进行。举例如下:

  1. public void update(List<Demo> demos) {

  2. repo.refresh(demos, () -> {

  3. doSomething();

  4. });

  5. }

针对上述代码,测试的基本思路是:

步骤一: 模拟一个异步回调函数

  1. //1.判断需要的回调函数的类型,创建ArgumentCaptor

  2. ArgumentCaptor<Repo.OnRefreshListener> captor =

  3. ArgumentCaptor.forClass(Repo.OnRefreshListener.class);

  4. //2.主动调用相应函数,触发Mockito框架的执行流进行到回调函数,同时将captor.capture()作为入参传入。

  5. Mockito.verify(repo).refresh(Mockito.anyList(), captor.capture());

  6. //3.通过captor.getValue()模拟异步回调函数

  7. Repo.OnRefreshListener mockListener = captor.getValue();

步骤二: 主动调用异步回调接口,从而使执行流进入回调函数中

  1. //主动调用异步函数接口,使得测试执行流进入函数体

  2. mockListener.onResult();

步骤三: 判断是否执行了doSomething()方法,或者执行结果是否符合预期的其他判断方式。

使用Mockito-inline测试静态方法的异步代码段

假如需对以下代码进行单元测试,我们就需要用到mockito-inline.可以看到,RPC请求是通过一个静态方法发出,并且通过异步回调的形式返回结果。

  1. public void demo(String id) {

  2. RpcService.send(new DemoReq(id), new RpcCallback<DemoResp>() {

  3. @Override

  4. public void onFailure(BaseReq call, String msg, String procCd, String procSts, Exception e) {

  5. if(listener != null){

  6. listener.onFailure(msg, procCd, procSts);

  7. }

  8. }

  9. @Override

  10. public void onResponse(BaseReq call, WalletDetailRespMsg response) {

  11. if(listener != null){

  12. listener.onSuccess(response);

  13. }

  14. }

  15. });

  16. }

具体写法关键在定义拦截后的行为,invacation保留了调用信息,根据序号获取入参,可以对入参进行判断,之后就可以主动调用回调函数。

  1. try (MockedStatic<RpcService> rpcMock = Mockito.mockStatic(RpcService.class)) {

  2.     //告诉mockito,遇到RpcService.send(参数任意,参数任意)的时候,拦截

  3. rpcMock.when(() -> RpcManager.sendFromNative(Mockito.any(), Mockito.any()))

  4. .then(invocation -> {

  5. //拦截之后,会进入到这里。

  6. //invocation会保留调用的信息。通过getArgument可以获取入参

  7. RpcCallback callback1 = invocation.getArgument(1, RpcCallback.class);

  8. //主动调用callback,可以指定回调入参

  9. callback1.onResponse(Mockito.mock(BaseReq.class),

  10. Mockito.mock(WalletDetailRespMsg.class));

  11. return null;

  12. });

  13.         //主动调用被测方法

  14. presenter.refreshWalletDetail(testWalletId, callback);

  15. Mockito.verify(callback).onSuccess(Mockito.any());

  16. }

使用Kotlin封装Mockito-inline单测公用方法

如上,我们若使用java的try-catch-resources会显得代码臃肿,于是我们可以尝试Kotlin简化。 对于try-catch-resources,kotlin中的等价写法是使用use

  1. //mock了Apps.getApp()这个静态方法的返回结果。传入一个高阶函数,易于进行串联调用。

  2. fun getAppMock(action: () -> Any?) {

  3.     Mockito.mockStatic(Apps::class.java).use { appUtilsMock ->

  4.         appUtilsMock.`when`<Void> { Apps.getApp() }.thenReturn(null)

  5.         action()

  6.     }

  7. }

如果我们封装了大量的公用mock代码,那么一段测试代码就长这样:

  1. @Test

  2. fun reduceWithUserRejectTest() {

  3.     val change = HceDefaultChange(true)

  4.     getAppMock {

  5.         isNfcDefaultPaymentMockStatic(true) {

  6.             checkNetMockStatic(true) {

  7.                 val actual: PaymentPageState = change.reduce(PaymentPageState())

  8.                 Assert.assertTrue(actual.showWaving)

  9.             }

  10.         }

  11.     }

  12. }

是不是和写Flutter或者Compose的UI页面一样啦~

Roboletric

如果不测试Activity页面,则不建议使用Roboletric,一是因为mockito已经能够完成几乎全部的工作,并不需要用到Roboletric,二是用Roboletric影响测试执行速度。

编写可运行的Roboletric单元测试方法

  1. // 首先需要添加RobolectricTestRunner,作为运行Roboletric的启动器

  2. @RunWith(RobolectricTestRunner.class)

  3. // 其次需要使用Config配置本次单元测试的基础配置。

  4. // 1. 如果你的电脑上运行的JAVA版本不是11以上,则需要指定sdk版本为Android 9.0以下

  5. // 2. 可以指定shadows。shadows下文会详细解析,这里可配置可不配置,取决于具体场景

  6. // 3. qualifiers可以配置机器的尺寸,多语言环境等,可配置可不配置,取决于具体场景。例子中指定了中文环境

  7. @Config(sdk = {Build.VERSION_CODES.O_MR1},

  8.         shadows = {DemoShadow.class},

  9.         qualifiers = "zh"

  10. )

  11. public class DemoTest {

  12. }

使用Roboletric模拟Activity

Roboletric的一大特点就是可以模拟Android的context。 我们可以再@Before注解的方法中使用Roboletric创建一个Activity,

  1. @Before

  2. public void initActivity() {

  3. //Intent可选

  4. Intent faceIntent = new Intent();

  5.     faceIntent.putExtra(DEMO, uri.toString());

  6. activity = Robolectric.buildActivity(VerificationBioGuideActivity.class, faceIntent)

  7.             .create().resume().get();

  8. }

Roboletric调用buildActivity即可模拟一个Activity,调用create可以触发onCreate回调,调用resume可以触发onResume回调,最后调动get就可以拿到这个activity对象。拿到activity的对象之后,我们就可以通过activity进行一些操作了。例如,获取View的控件,获取字符串等。

  1. // 获取View控件

  2. TitleBar titleBar = (TitleBar) activity.findViewById(R.id.title_bar);

  3. // 获取字符串

  4. activity.getString(R.string.verification_bio_pay_title_finger_success_tips)

可以使用Roboletric模拟出来的activity作为context,如果只需要用到applicaitonContext,可以使用

RuntimeEnvironment.getApplication()

Roboletric的杀手锏——Shadows

Robolectric的本质是在Java运行环境下,采用Shadow的方式对Android中的组件进行模拟测试,从而实现Android单元测试。

Shadows的作用就是使用自定义的方法和类替换原先业务的方法和类,原理就是使用字节码修改技术进行动态的修改。例如,业务中A.class原先要调用B.class的C()函数,我们使用Shadows,并定义一个函数签名一样的函数D()将其作用于C()函数上。当触发A.class的调用后,程序执行流程会进入D()函数中。

自定义Shadows简介

第一步: 使用@Implements类定义需要被替换的目标类。Shadows类本身也是一个普通的Java类,只不过多了@Implements注解,所以他也可以实现接口,继承等功能。

  1. // 表明这个Shadow期望模拟ActualClass类的行为

  2. @Implements(ActualClass.class)

  3. @RequiresApi(api = Build.VERSION_CODES.N)

  4. public class ActualClassShadow {

  5. }

第二步: 与目标函数使用相同的函数签名,并且增加@Implementation注解。可以只替换目标类中的部分函数,而其他函数仍然遵循目标类的逻辑。

  1. //表明需要模拟ActualClass中的containsKey方法

  2. @Implementation

  3. public boolean containsKey(String key) {

  4. }

第三步(可选):__constructor__可以替换构造函数

  1. public void __constructor__(int x, int y) {

  2.     realPoint.x = x;

  3.     realPoint.y = y;

  4. }

第四步(可选):使用@RealObject可以获取目标对象

@RealObject private Point realPoint;

第五步:定义好Shadows之后,我们需要再测试类的Config注解中进行注册。

@Config(shadows = {ActualClassShadow.class})

使用Chat-GPT生成单元测试案例

紧跟时事!!

我们可以使用Chat-GPT快速生成测试案例,分为以下几步:

第一步:让Chat-GPT知晓该函数的意图

第二步:告诉Chat-GPT单元测试的目标

Optional:可以指定Chat-GPT使用Junit4的一些测试特性简化单测案例

序号Chat-GPTInput
1java (输入待测代码)what's the author's intentions
2The author's intention with this Java code is to provide a methodhexStringToByteArray that can convert a hexadecimal string to abyte array. The method takes a single argument data, which is thehexadecimal string to be converted. The code first checkswhether the input string is valid and has an even number ofcharacters, since each pair of characters in the string representsa single byte. If the input string is valid, the code creates a new byte arrayresult with a length equal to half the length of the input string(since each pair of characters represents one byte). It then loopsover the input string in pairs, converting each pair of charactersto a byte and storing it in the result array. If the input string is not valid, the code logs an error message andreturns null. Overall, the intention of this method is to provide a simple andreliable way to convert hexadecimal strings to byte arrays inJava.
3A good unit test suite should aim to:- Test the function's behavior for a wide range of possible inputs- Test edge cases that the author may not have foreseen- Take advantage of the features of Parameterized to make thetests easy to write and maintain- Be easy to read and understand, with clean code anddescriptive names- Be deterministic, so that the tests always pass or fail in the same wayJunit has many convenient features that make it easy to write and maintain unit tests. We'll use them to write unit tests for the function above.For this particular function, we'll want our unit tests to handle the following diverse scenarios
4Since the code provided is in Java, we'll write our unit tests inJava using the JUnit framework. Here are some example unittests we might write for the hexStringToByteArray function: @RunWith(Parameterized.class)public class DesUtilsHexStringToByteArrayTest {    @Parameterized.Parameters    public static Collection<Object[]> data() {        return Arrays.asList(new Object[][]{                // Test cases where input is valid                {"00", new byte[]{0x00}},                {"7f", new byte[]{0x7f}},                {"deadbeef", new byte[]{(byte) 0xde, (byte) 0xad, (byte) 0xbe, (byte) 0xef}},                {"0102030405060708090a0b0c0d0e0f", new byte[]{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}},                // Test cases where input is invalid                {"", null},                {"0", null},                {"1", null},                {"a", null},                {"g", null},                {"gg", null},                {"000", null},                {"001", null},                {"00g", null},                {"0x00", null},                {"deadbeef0", null}        });    }     private String input;    private byte[] expected;     public DesUtilsHexStringToByteArrayTest(String input, byte[] expected) {        this.input = input;        this.expected = expected;    }     @Test    public void testHexStringToByteArray() {        byte[] actual = hexStringToByteArray(input);        assertArrayEquals(expected, actual);    }} In this example, we're using JUnit's Parameterized runner togenerate multiple test cases from a single test method. The datamethod returns a collection of test cases, each represented as anarray of input parameters and expected output. We have included test cases for a wide range of possible inputs,including valid input strings with different lengths, as well asinvalid input strings that don't have an even number ofcharacters, contain non-hexadecimal characters, or exceed themaximum length that can be converted to a byte array. We've used descriptive names for our test class, test method,and test parameters to make the tests easy to read andunderstand. Finally, our tests are deterministic, since they will always pass or fail in the same way.

行动吧,在路上总比一直观望的要好,未来的你肯定会感 谢现在拼搏的自己!如果想学习提升找不到资料,没人答疑解惑时,请及时加入扣群: 320231853,里面有各种软件测试+开发资料和技术可以一起交流学习哦。

最后感谢每一个认真阅读我文章的人,礼尚往来总是要有的,虽然不是什么很值钱的东西,如果你用得到的话可以直接拿走:

这些资料,对于【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴上万个测试工程师们走过最艰难的路程,希望也能帮助到你!

  • 15
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值