Android 单元测试详解

1,921 阅读13分钟

介绍

Google官方Demo地址,包含了Junit以及Espresso的例子

  • 什么是单元测试?

    单元测试不仅仅是针对java语言的概念,所有的语言都有单元测试。从字面意义上来看,指的是一个“单元”的测试,一个单元在Java中,或者说在我们Android的单元测试中,指的是一个方法。所以可以说单元测试就是针对每个方法所写的测试方法。单元测试又分为本地测试(test文件夹下)仪器测试(androidTest文件夹下)

    上图是自动化测试金字塔,也称为自动化分层测试,Unit 是整个金字塔的基石,最重要特点是运行速度非常快;第二个重要特点是 UT 应覆盖代码库的大部分,能够确定一旦 UT 通过后,应用程序就能正常工作。

    Unit:70%,大部分自动化实现,用于验证一个单独函数或独立功能模块的代码;

    UI:20%,主要是UI相关的测试,UI的功能测试;

    多模块集成测试:10%,覆盖三个或以上的功能模块,真实用户场景和数据的验收测试;

  • 为什么要写单元测试?

    • 节约时间:

      相对于使用编译并且运行后的App,通过debug或者log的形式查看是否正确,本地单元测试在运行速度上有明显的优势,而且可以指定很多不同的入参来模拟不同的情况下的结果,而不是修改一个参数运行一次项目。

    • 可以有更好的API设计:

      代码是为了什么,当然是为了重复运行。如何保持unit test代码的稳定?主要靠好的API设计。API切实正确切割了需求,那么在重构的时候API就基本不用变化,unit test也不用重写。以后你重构的时候,只要你的unit test覆盖的够好,基本跑一遍就知道有没有问题。可以节省大量的时间。

    • 测试驱动重构:

      在编写单元测试的过程中,也许会发现一些方法或者类对于写出测试代码不是很友好,那或许是因为在原始类的部分API中,做了很多耦合或者API做了它本身意义之外的事。

    • 有一定的学习成本:

      单元测试的写法并不同于日常使用的Android的语法,单元测试有自己的语法以及一些框架的使用,需要花一些时间去了解并且学习,才能够掌握。尤其在Android中,有很多坑。(但是当掌握了语法之后,会非常顺手)。

    • 相当于一份代码文档:

      其实,当在写单元测试时,为了测试某个功能或某个api,首先得调用相关的代码,因此留下来的便是一段如何调用的代码。这些代码的价值在于为以后接手维护/重构/优化功能的人,留下一份相对于文字更适合阅读文档。

  • 什么样的代码需要写单元测试?

    实际上也不是所有的代码都需要写单元测试,有些逻辑简单的代码实际上并不需要单元测试去验证正确性。有关哪些代码需要写单元测试,很早就有人提出了这个问题,单元测试以及TDD的发明者Kent Beck的是这样回答的:

    老板为我的代码付报酬,而不是测试,所以,我对此的价值观是——测试越少越好,少到你对你的代码质量达到了某种自信(我觉得这种的自信标准应该要高于业内的标准,当然,这种自信也可能是种自大)。如果我的编码生涯中不会犯这种典型的错误(如:在构造方法中设了个错误的值),那我就不会测试它。我倾向于去对那些有意义的错误做测试,所以,我对一些比较复杂的条件逻辑会异常地小心。当在一个团队中,我会非常小心的测试那些会让团队容易出错的代码。

  • 单元测试的目的,目标(Right-BICEP)

    • Right - 输出的结果正确
    • B - are all the boundary conditions correct 边界条件是否正确
    • I - can you check the inverse relationships 是否可以检查一下反向关联
    • C - can you cross-check results using other means? 能够使用其他手段交叉检查一下结果?
    • E - can you force error conditions to happen? 是否可以强制错误条件产生?
    • P – are performance characteristics within bounds? 是否满足性能要求?

如何使用

关于如何开始单元测试呢?Android中的单元测试始终离不开几个框架:Junit、Robolectric(本地测试提供Android环境)、Mockito、Espesso(测试UI)。

Junit4

junit4是一个最基础的单元测试框架,运行在JVM环境上,通过注解的方式标注哪些是测试方法,哪些是测试方法之前需要运行的方法,如下例子:

public class Calculator {
    public int add(int a, int b){
        return a+b;
    }

    public void performClick(){
				Log.d("---fb---", "click");
    }
}

这是一个很简单的类,现在生成一个测试类,在Android Studio中,使用 command+shift+t 就可以跳转到指定的测试类,或者创建一个新的测试类。直接看测试类的输出:

上图是测试类,在类旁边和使用了 @Test 标注的方法旁边都有一个绿色的可运行按钮,如果想要运行类中所有方法或者运行单个方法,就可以点击对应的按钮。

  • @Before / @After

    在自动创建测试类的时候,可以选择是否生成 @Before / @After 的方法,@Before 是运行每个@Test方法之前会运行的方法,可以在里面做一些 初始化的操作。相对应的 @After 是在所有测试方法运行完成后运行的方法,可以做一些资源释放的操作。

  • @Test

    被这个注解标注的方法,都是一个测试方法,并且可以独立运行的。一个目标方法对应一个测试方法。

上面的例子中,add() 这个测试方法中,指定了不同的入参相对应的函数返回是否正确,那么运行下程序,控制台就会输出:

左边是任务列表,右边是运行结果,可以看到所有的用例都测试通过了。下面改一下代码让它测试失败:

可以看到, 当把第三个断言改为101的时候,测试就报错了,并且提示了哪里出错了,预期的结果与实际执行的结果,都会提示出来。

Parameterized

Parameterized是Junit中一个比较常用的类,主要是用来指定输入参数的,Junit所制定的测试类,并没有指定main方法,而所有的Junit注解声明的类的起始类都是在JuniCore这个类中,指定了测试类的main方法入口:

public static void main(String... args) {
    Result result = new JUnitCore().runMain(new RealSystem(), args);
    System.exit(result.wasSuccessful() ? 0 : 1);
}

然后,上面的runmain方法里面会调用一个 Runner 的run方法:

public Result run(Runner runner) {
        Result result = new Result();
        RunListener listener = result.createListener();
        notifier.addFirstListener(listener);
        try {
            notifier.fireTestRunStarted(runner.getDescription());
            runner.run(notifier);
            notifier.fireTestRunFinished(result);
        } finally {
            removeListener(listener);
        }
        return result;
    }

所以,测试类的主要运行内容是通过Runner方法来运行的,而Parameterized就是一个Runner类,只需要在使用的时候,使用注解 Runwith() 声明即可。例子如下:

原始函数:

public class Fibonacci {
    public static int compute(int n) {
        int result = 0;
        if(n <= 1) {
            result = n;
        } else {
            result = compute(n -1) + compute(n - 2);
        }
        return result;
    }
}

测试类:

//指定Parameterized作为test runner
@RunWith(Parameterized.class)
public class TestParams {

    //这里是配置参数的数据源,该方法必须是public static修饰的,且必须返回一个可迭代的数组或者集合
    //JUnit会自动迭代该数据源,自动为参数赋值,所需参数以及参数赋值顺序由构造器决定。
    @Parameterized.Parameters
    public static List getParams() {
        return Arrays.asList(new Integer[][]{
                { 0, 0 }, { 1, 1 }, { 2, 1 }, { 3, 2 }, { 4, 3 }, { 5, 5 }, { 6, 8 }
        });
    }

    private int input;
    private int expected;

    //构造函数中指定的参数对应数据源中指定的参数
    //例如:当获取到数据源的第3条数据{2,1}时,input=2,expected=1
    public TestParams(int input, int expected) {
        this.input = input;
        this.expected = expected;
    }

    @Test
    public void testFibonacci() {
        System.out.println(input + "," + expected);
        Assert.assertEquals(expected, Fibonacci.compute(input));
    }

}

Junit Rule

Junit Rule 是一个比较重要的概念,之后的UI测试中也会用到一些相关的。Junit Rule就是一个实现了TestRule的类,类似在测试类中的@Before以及@After的注解,使用Junit Rule,可以减少重复代码。比如两个测试类的setUp一样,或者总是需要在setUp中写一些同样的初始化,这时候就可以使用Junit Rule替代这些样板代码。在Junit框架中有一些自带的Rule,比如Timeout等等。也可以自定义Rule:

class MyCustomRule : TestRule {
    override fun apply(base: Statement?, description: Description?): Statement {
        return object : Statement() {
            override fun evaluate() {
                //在测试类执行之前做一些事情,类似@Before
                base?.evaluate()
                //在测试类执行之后做一些事情,类似@After
            }
        }
    }
}

Mockito (PowerMock)

验证方法调用

在上面Junit4的例子中,我们所测试的都是包含返回值的函数,所以只需要测试函数的返回值与所期望的是不是一致就可以了,但是在开发中有很多的没有返回值的方法,这个时候我们就需要使用到Mockito这个库了。比如有这样一个方法:

class TestMockitoExp{
  fun testMockito(content: String) {
    if(validate(content)){
      doSomethingWhenTrue()
    } else{ 
      doSomethingWhenFalse()
    }
  }
}

这段代码比较简单,可以看到主要是包含了一个if判断,可以把这个条件判断看作是业务代码中比较复杂的处理或者验证。可以看到,这段代码并没有返回值,那么单元测试验证的就应该是在满足某一条件的情况下相应的最后一句代码(对应例子中的doSomethingWhenTrue())是否被执行了,至于这行代码执行的方法(对应例子中的doSomethingWhenTrue())是否执行正确,那不是这个单元测试需要关心的,在这个方法的单元测试过程中,只能默认这个方法内容是正确的。那问题是如何才能知道最后一句的方法被调用了?这里就需要用到Mockito这个库。 Mockito库可以mock一个对象,通过这个mock对象就可以验证方法是否被调用了。比如在上述例子中,如果要验证方法被调用了,就需要:

TestMockitoExp mockExp = Mockito.mock(TestMockitoExp.class);
//表示doSomethingWhenTrue被调用了一次,times(1)可选,默认一次。
mockExp.verify(mockExp, times(1)).doSomethingWhenTrue();

注意:只有mock的对象可以做为verify的参数,验证方法是否被调用,因此,一个比较友好的代码设计方式就是在尽可能少的在初始化的时候直接new一个对象,而是通过组合的方式,这样就可以随时替换依赖为我们的mock对象。

改变方法返回值

在上面的代码中包含一个if的判断语句,如果我们希望在测试testMockito方法时,能够覆盖到if判断条件为true和false的情况。那么只需要按照如下的方式:

//当调用validate(),返回false
when(mockExp.validate()).thenReturn(false);

改变方法的行为

在一些业务场景中,我们可能需要测试一些不是立即调用的方法,比如:

run(new Runnable(){
 @Override
 public void run(){
   doSomething();
 }
})

在上面的方法中,调用了一个run(Runnable runnable)方法,假设我们将这个run方法post到主线程,那么参数Runnable的run 方法可能不是立即执行的。但是在测试的时候,可能需要直接调用Runnable的run方法,所以需要改变这个run方法内部的行为:

doAnswer(new Answer() {
            @Override
            public Object answer(InvocationOnMock invocation) {
                ((Runnable)(invocation.getArgument(0))).run();
                return null;
            }
        }).when(object).run(any(Runnable.class));

使用doAnswer,在其实现中,InvocationOnMock是目标方法的反射方法,拿到该方法的第一个参数(也就是runnable),直接调用run方法就可以了。

Mockito更多用法:文章地址

spy

spy和mock一样,都是可以mock一个对象的方法,而区别就在于,spy是根据实例去mock 一个实例,通过spy去mock的对象,调用的方法都是真实的方法,如果不指定方法的行为,那么将会调用实例真正的方法。而mock如果不指定的话,则默认是什么都不做。

PowerMock

Mockito不能mock 静态方法,final class, private方法。而PowerMock可以做到,使用方法:

//FinalTest是一个final class
@RunWith(PowerMockRunner.class)  //声明使用PowerMockRunner去运行
@PrepareForTest(FinalTest.class)  //mock的类是final class / final 方法所在的类 / 静态方法所在的类
@PowerMockIgnore({"javax.net.ssl.*", "okhttp3.*","org.mockito.*", "org.robolectric.*", "android.*"})  //不使用MockClassLoader去加载相关的类, 否则这些类在目标测试方法中使用的时候会出错。
public class TestPowerMockTest {

    private FinalTest testFinal = PowerMockito.mock(FinalTest.class);
    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
    }
    public void test_download(){
        testFinal.test();
    }

在使用方式上,主要与Mockito的区别就在于需要使用一个注解声明是使用PowerMockRunner运行的。

Robolectric

GitHub

Robolectric 是一个提供Android运行环境的框架。目前Junit以及Mockito都是运行在JVM环境下的,而当我们需要测试一些类中包含有Android相关的代码的时候,就需要一个Android的运行环境,此时就需要使用Robolectric了,它会下载一个robolectric定制的Android虚拟运行环境的包,非常慢,不过指定sdk的版本之后,只需要下载一次就可以了。

异步调试

上面有提到一个runnable的例子,通过改变方法的行为可以立即调用。 还有就是当想要根据不同的参数测试一个请求的返回值的时候,可以构造一个同步的请求去测试返回值。如果要测试某一个请求的返回值是否正确,可以使用CountDownLatch:

原始类:

class TestAsync{

    var count = 1

    fun loadData(callback: CallBack){

        Thread {
            if (getResult()){
                count = 2
                callback.onData()
            }else{
                callback.onFailed()
            }
        }.start()
    }

    interface CallBack{
        fun onData()
        fun onFailed()
    }

    private fun getResult(): Boolean{
        Thread.sleep(1500) // 模拟网络请求
        return true
    }
}

测试类:

class TestAsyncTest {

    @Test
    fun loadData() {
        val testAsync = TestAsync()
        val countDownLatch = CountDownLatch(1)
        var count = 1
        testAsync.loadData(object: TestAsync.CallBack{
            override fun onData() {
                count = 2
                countDownLatch.countDown()
            }

            override fun onFailed() {

            }

        })
        countDownLatch.await()
        assertEquals(2, count)
    }
}

最后是关于单元测试的一些建议

  • AAA规则:Arrange, Act, Assert,即准备(mock一些类),行动,断言。

  • 依赖隔离:依赖隔离可以更好的mock方法的返回值、修改方法的行为等等

  • final class、static method、private的使用

  • (如果需要测试一个private方法的话)

You generally don't unit test private methods directly. Since they are private, consider them an implementation detail. Nobody is ever going to call one of them and expect it to work a particular way.
You should instead test your public interface. If the methods that call your private methods are working as you expect, you then assume by extension that your private methods are working correctly.