单元测试学习笔记

示例代码太少,以后会逐渐补上。

目录:

综述

如果你查过一些关于单元测试的资料,你可能会和我一样发现一个问题。有一些文章在说到单元测试的时候,提到了要做边界测试,要考虑各种分支;也有一些文章则说的是修改原有代码,例如依赖隔离;还有一些文章说的是测试的框架的使用,例如 Junit 。那么它们之间有着什么样的联系呢?

最开始,我们可能更关注边界测试和分支测试。但遗憾的是,这方面的资料相对来说较少。更多的是依赖隔离这类的文章。为什么?

因为有很多代码是无法被测试的。

能够被测试的代码需要满足某些条件。你可能会觉得很麻烦,做单元测试还要为了满足这些条件去修改原来的代码。事实上,满足这些条件能使你的代码变得健壮。如果你写的代码是无法被测试的,那么你的首要任务就是将它们重构为可测试的单元。要想知道如何写出可测试的代码,就得了解 Mock 和 Stub 。这也就是你在看一些加减乘除单元测试例子之后,仍然不知道怎么测试自己的代码的原因。(每次看到这样的文章就好气啊_(:з」∠)_)

但是即使重构了,还有一个问题。你总得写代码来执行对其他代码进行测试吧?这部分的代码可能很复杂,也可能变得难以维护。于是测试框架就出现了,帮你减轻做测试的负担。

简单说,它们三者之间的关系是:先重构已有代码,使其成为可测试的单元,为接下去的测试做准备。接着写出对这些单元进行测试的代码,验证结果。为了使测试代码易于编写和维护,借助测试框架。

单元测试时所面临的问题

为了使代码可被测试,需要对其进行重构。在这个过程中会遇到一些问题:

  • 一个类的方法里包含了其他类的方法,怎么测试?
  • 如果代码依赖于 Web 服务,例如请求某个网站的数据,怎么测试?
  • 一个类的方法里包含了该类的其他方法,要怎么测试?
  • 一个类的方法有很多个对数据处理的步骤,是要测试最终结果,还是要对每个处理的步骤可能出现的问题进行测试?
  • 一个方法没有返回值(即 void )怎么办?——交互测试

对于前两个问题,可以用依赖隔离来解决。《单元测试的艺术》的3.1有个用来理解依赖隔离的例子:
航天飞机在送入太空之前,要先进行测试,否则飞到一半出了问题怎么办?
而有一部分测试是确保宇航员已经准备好进入太空。但是你又不能让宇航员真的坐实际的航天飞机去测试。有个办法就是建造一套仿真系统,模拟航天飞机控制台的环境。输入某种特定的外部环境,然后看宇航员是否执行了正确的操作。
在这个例子中,通过模拟外部环境来解除了对实际外部环境(航天飞机进入太空)的依赖。同样的思路可以用到测试中。

依赖隔离

先从写出能够测试的代码开始说起吧。

参考文章:Android单元测试 - 如何开始?

这里的依赖指的是:当前 类A 需要调用 类B 的方法,则说 A 依赖于 B 。

隔离方法:

  1. 将 B 改成一个 接口C 。
  2. 将 A 中的 B类 出现的位置替换为 接口C 。

A 和 B 隔离前后对比:
隔离前:A -> B
隔离后:A -> C -> B

在项目实际代码以及测试代码中使用不同的B:

  • 在项目执行代码中:传入 类A 的对象是 接口C 的一个 派生类D (实现了 接口C )。 类D 是 项目中实际运行的代码,提供了对接口的完整实现。A -> C -> D
  • 在单元测试的代码(独立于项目执行代码,发布软件时要把这部分删掉)中:传入 A 的对象也是实现了 接口C 的一个 派生类E 。但是这个类与D不同,它提供的实现可能只是一个return。从而模拟(Mock)了派生类D的特定行为,使得在测试的时候,不需要使用D类。A -> C -> E

这样做的好处是,一旦隔离完成,以后就不必大幅度修改A。在隔离的时候,要将所有依赖项改为从外部传入。这就需要给类A添加一个set方法,传入接口C的实现(implement),即上面的D和E。

依赖隔离的例子

类A:

public class Calculater {

    public double divide(int a, int b) {
        // 检测被除数是否为0
        if (MathUtils.checkZero(b)) {
            throw new RuntimeException("divisor must not be zero");
        }

        return (double) a / b;
    }
}

它调用了类B(MathUtils)的 checkZero 方法。于是我们说类A依赖于类B的 checkZero 方法。需要注意的是这个 MathUtils 不是从外部传入的

类B是一个具体实现的类:

public class MathUtils {
    public static boolean checkZero(int num) {
        return num == 0;
    }
}

在知道产生依赖之后,要将类B改成一个接口(方法名前缀I表示这是一个接口Interface):

public interface IMathUtils {
    public boolean checkZero(int num);
}

在类A的代码中,将B替换成该接口:

public class Calculater {

    private IMathUtils mMathUtils = new MathUtils();   // 这里的代码改动了
    
    // 这里添加了set方法。向该类传入了mathUtils
    public void setMathUtils(IMathUtils mathUtils){
        mMathUtils = mathUtils;
    }
    
    public double divide(int a, int b) {
        if (mMathUtils.checkZero(b)) { // 这里的代码改动了,将静态类改成对象
            throw new RuntimeException("divisor must not be zero");
        }
        return (double) a / b;
    }
}

之前的B是一个静态类,不需要声明,但改成接口后需要声明。

接口的实现:

  • 对于实际运行的代码,需要一个类去实现 IMathUtils 接口,然后传入 Calculater 。
    修改类B:

    public class MathUtils implements IMathUtils{
        public boolean checkZero(int num) {
            return num == 0;
        }
    }
    
  • 对于用于测试的代码,也需要一个类实现 IMathUtils 接口,然后传入 Calculater 。但不同的是,这个类的实现可能只需添加一个 return 语句,不用细致实现。
    总是正确的接口:

    public class FakeMathUtils implements IMathUtils{
        public boolean checkZero(int num) {
            return true;
        }
    }
    

    return的时候,可以设一个变量,方便配置不同取值,否则还得创建新的类。

    public class FakeMathUtils implements IMathUtils{
        public boolean isZero = true;
        
        public boolean checkZero(int num) {
            return isZero;
        }
    }
    

交互测试

如果一个特定的工作单元的最终结果是调用一个第三方对象(而不是返回某个值,即 void ),你就需要进行交互测试。

这个第三方对象不受你的控制。你无法查看它的源代码,或者这不是你负责测试的部分。因此你只需确保传给它的参数是正确的就可以了。

那么如何确保传过去的参数是正确的?

在这之前,要确保已经依赖隔离。

假设接口为:

public interface IPerson {
    ...
    public void doSomethingWithData(String data);
}

待测试类的某个方法:

public class A {
    private String data = "";
    ...

    public void methodA(IPerson person) {
        ...
        person.doSomethingWithData(data);
    }

    public void setData(String data) {
        this.data = data;
    }
}

真正使用的 Person 类是如何实现的呢?假设我们无从得知。我们的任务是保证传入的 data 是符合我们预期的。只要传入的内容符合预期,那么就说明我们要测试的方法是没问题的。

伪实现:

public class FakePerson implements IPerson {
    private String data = "";

    ...
    public void doSomethingWithData(String data) {
        this.data = data;
    }

    public String getData() {
        return data;
    }
}

在调用 methodA 的时候,传入 FakePerson 实例。

A test = new A();
test.setData("hahahaha");

IPerson fakePerson = new FakePerson();
test.methodA(fakePerson);

Assert.AssertEquals("hahahaha", fakePerson.getData());

伪对象 FakePerson 在被 测试类A 的 methodA 方法中调用,该方法会给伪对象传入某个信息。

伪对象 FakePerson 不对该信息进行进一步处理,只是赋值给类成员变量存储起来。

由于伪对象是从外部传入的 test.methodA(fakePerson); ,因此可以直接在外部获取存储的信息 fakePerson.getData() 。在assert的时候,获取该信息,查看是否和预期的一致。

参考:

《单元测试的艺术》第四章

Android单元测试在蘑菇街支付金融部门的实践

单元测试框架

在测试之前,要创建一个专门用于测试的类。这个类的类名以Test结尾。在类里面添加测试方法,测试方法名前面要加上 test ,接在 test 后面的是被测试的方法名。在该方法内做三件事:

  1. 测试之前需要准备的数据,例如 new 出要测试的类——Setup
  2. 执行要测试的类的方法——Action
  3. 最后添加 Assert 以验证结果——Verify

测试框架里的 AssertXxx 是什么玩意儿?

我们写的测试代码在运行的时候会产生一些结果,验证这些结果是否符合预期的一个低效方法就是将这些结果输出到控制台(Console)或者文件,然后你自己用眼睛一个个去对比。

如果你懒得去比呢?又或者说你对比的时候觉得没错,但是实际上是因为一个1l的错误导致你没有发现呢?

就让 Assert 来帮你解决这些烦人的问题吧! Assert (中文为:断言)就是让你将预期的结果和程序运行的结果传入它的方法里面,由它来替你做对比的事情。

例如一个测试结果是否相等的 Assert :
assertEquals(你自己算出的结果, 程序运行的结果);
如果两个结果不同,即程序运行的结果不符合你的预期,那么它就会提示你这里出现了错误。

从此,你就从几百甚至是几万条的测试代码输出的对比中解放出来,大大节约了时间。

有些文章标题看着像是介绍单元测试,实际上是介绍单元测试框架。测试框架(Junit,Nunit等)实际上是提供便于测试的方案的框架,学习这些内容是学习框架的结构,以及如何使用框架定义的各种 assert ,而不是学习单元测试的方法。这两者要区分开来!!!!!

快捷实现用于测试接口的框架(Mockito)

对于刚才那个接口 IMathUtils ,我们可以不用再新建一个类去实现它,而是交给 Mockito 。
IMathUtils mathUtils = mock(IMathUtils.class); // 生成mock对象

when(mathUtils.checkZero(1)).thenReturn(false); // 这里是快捷实现。它告诉 Mockito :如果在下面代码调用了mathUtils.checkZero()并传入参数1,那么就让调用这个方法的地方返回false这个值。

做好以上准备后

  1. 单元测试总体上需要做些什么?
    • 只考虑代码在最正确的操作和条件下能否得出正确的结果
    • 在数据的边界条件下能否得到正确的结果
    • 代码在所有可能的错误数据下能否给出错误提示或者不至于崩溃
    • 如何消除依赖隔离
  2. 单元测试的任务(摘自:Java单元测试(Junit+Mock+代码覆盖率)
    • 接口功能测试:用来保证接口功能的正确性。
    • 局部数据结构测试(不常用):用来保证接口中的数据结构是正确的
      • 比如变量有无初始值
      • 变量是否溢出
    • 边界条件测试
      • 变量没有赋值(即为NULL)
      • 变量是数值(或字符) 时
        • 主要边界:最小值,最大值,无穷大(对于 double 等)
        • 溢出边界(期望异常或拒绝服务):Min - 1,Max + 1
        • 临近边界:Min + 1,Max - 1
      • 变量是字符串时
        • 应用上面提到的字符变量的边界
        • 空字符串
        • 对字符串长度应用数值变量的边界
      • 变量是集合时
        • 空集合(Empty)
        • 对集合的大小应用数值变量的边界
        • 调整次序:升序、降序
      • 变量有规律时
        • 比如对于Math.sqrt,给出n^2-1,和n^2+1的边界
    • 所有独立执行通路测试:保证每一条代码,每个分支都经过测试
      • 代码覆盖率
        • 语句覆盖:保证每一个语句都执行到了
        • 判定覆盖(分支覆盖):保证每一个分支都执行到
        • 条件覆盖:保证每一个条件都覆盖到 true 和 false (即 if 、 while 中的条件语句)
        • 路径覆盖:保证每一个路径都覆盖到
      • 相关软件
        • Cobertura:语句覆盖
        • Emma: Eclipse插件Eclemma
    • 各条错误处理通路测试:保证每一个异常都经过测试
  3. Android 单元测试的任务(摘自:Android单元测试在蘑菇街支付金融部门的实践
    • 所有的Model、Presenter/ViewModel、Api、Utils等类的public方法
    • Data类除了getter、setter、toString、hashCode等一般自动生成的方法之外的逻辑部分
    • 自定义View的功能:比如set data以后,text有没有显示出来等等,简单的交互,比如click事件,负责的交互一般不测,比如touch、滑动事件等等。
    • Activity的主要功能:比如view是不是存在、显示数据、错误信息、简单的点击事件等。比较复杂的用户交互比如onTouch,以及view的样式、位置等等可以不测。因为不好测。

重构与单元测试

在单元测试前要重构,在重构前要编写集成测试。
集成测试 ——> 重构 ——> 单元测试
重构的过程中,每次只做少量的改动。尽可能多的运行集成测试,以此了解重构是否使得系统原有的功能被破坏。
要点:关注系统中你需要修复或者添加功能的部分,不要在其他部分浪费精力。其他部分等到需要处理的时候再考虑。

修复 BUG 或添加新功能的单元测试

先编写一个单元测试,这个测试针对于这个 BUG 。由于它是一个 BUG ,所以显然这个单元测试一开始给出的结果会是失败的。此时你修复 BUG ,并运行测试。如果测试成功,则表示你成功修复了这个 BUG ;如果测试失败,则表示 BUG 仍然存在。

换句话说,这个单元测试暴露了这个 BUG 。 BUG 本来没看出来,而这个单元测试的失败表明了 BUG 的存在。

添加新功能也是同样。写出一个会失败的测试,表示缺少这个功能。然后通过修改代码使得测试通过,就表明你成功添加了新功能。

获得接口的几种方法(基于值和状态的测试)

在本篇的 MathUtils 例子中,通过setMathUtils()传入 IMathUtils 的实现。这是通过 getter 和 setter 对类的成员变量操作的方法。这种方法称为依赖/属性注入。除此之外,还有其他方法。

  • 在方法调用点注入伪对象(《单元测试的艺术》3.4.6)
    这种方法与属性注入需要先获取实例再传入不同,它通过在构造函数里使用工厂方法获取实例。
    • 方案一:工厂类
      在被测试类的构造方法里执行了静态的工厂方法。不过工厂方法执行之前,通过 setter 传入用于测试的接口实现。这种方法与属性注入的不同之处在于,将 set 方法移入另外创建的工厂类。在测试的时候你完全不需要管被测试类,只需要对工厂类进行操作就可以。
      需要注意什么问题?你需要了解什么代码会在什么时候调用这个工厂,根据时机 set 进所需的工厂实现。
    • 方案二:本地工厂方法
      不使用工厂类,而是在被测试类里新建一个工厂方法。将被测试类设置为抽象类,完整地实现了除工厂方法外的所有方法,让子类继承并重写工厂方法。测试时有测试的实现,实际运行时有运行的实现。
      什么时候应该使用?模拟给被测试代码的输入。
      什么时候不应该使用?测试代码对服务的调用是否正确时。
      当被测试代码已经是依赖隔离或者应用了属性注入的时候,不考虑。若没有,则优先考虑。
  • 构造函数注入,赋值给类的成员变量
    创建新的构造函数,或者在原有构造函数上添加参数。如果类需要注入多个依赖,则会降低代码的可读性和可维护性(构造函数的参数个数可能变化的通病)。
    • 优化方案一:参数对象重构。将参数整合为一个对象,传入该对象。
    • 优化方案二:控制反转。控制反转的一个例子是 JAVA 的反射机制,根据类名生成对象。控制反转可以看做是将工厂方法中的生成对象的代码改到 XML 文件中。
    • 什么时候使用?第一:使用控制反转容器的框架。第二:想告诉 API 使用者这些参数是必须的(如果是可选的,则使用 getter 和 setter )。
    • 需要注意什么问题?大多数人不知道什么是控制反转原则。这意味着你一旦写出方案二这样的代码,就需要在别人不懂的时候教他。
  • 把参数放到需要被测试的方法的参数列表里

一些补充

  1. 应该对哪些代码编写单元测试?哪些代码不太需要编写单元测试?
    不经常改动的代码,特别是底层核心的代码需要编写单元测试。经常改动的代码就不太需要编写单元测试。毕竟你刚写完单元测试不久,整个代码就被修改了,你得再重新编写单元测试。

  2. Mock/Stub
    Mock 和 Stub 是两种测试代码功能的方法。 Mock 测重于对功能的模拟。 Stub 测重于对功能的测试重现。比如对于 List 接口, Mock 会直接对 List 进行模拟( assert 写在调用 List 的 test 方法里面);而 Stub 会新建一个实现了 List 的 TestList ,在其中编写测试的代码( assert 写在这个 TestList 里面)。《单元测试的艺术》4.2

    Stub 不会使测试失败,仅仅是用来模拟各种场景。 Mock 类似 Stub ,但它还能使用 assert 。

    优先选择 Mock 方式,因为 Mock 方式下,模拟代码与测试代码放在一起,易读性好,而且扩展性、灵活性都比 Stub 好。但需要注意,一个测试有多个 Stub 是可行的,但有多个 Mock 就会产生麻烦,因为多个 Mock 对象说明你同时测试了多个事情。编写测试代码时,不对 Stub 进行 assert ,而是统一到最后由 Mock 进行 assert 。如果你对明显是用做 Stub 的伪对象进行了断言,这属于过度指定。《单元测试的艺术》4.5

    如果在一个单元测试中,验证了多个点,你可能无法知道到底是哪个点出了错。应该尽可能分离。

  3. 想做单元测试结果做成集成测试
    如果既要请求网络,又要保存数据,还要显示界面,那就是集成测试了。

  4. 在使用断言确认字符串的时候,应该把整个预期字符串都写上么?
    在《重构:改善既有代码的设计》里面,有个测试读取文件的例子。

  5. 单元测试框架中的setup()

    • setup()方法应该初始化所有测试方法都需要的对象。至于只有某个测试方法用到的对象,交给这个测试方法来初始化。
    • 防止过度重构setup()方法。在重构时征求同伴的意见。
    • 不要在setup中准备伪对象。
  6. 还需要注意什么?

    • 编写测试时,要时刻考虑到阅读测试的人。想象一下他们第一次读到代码时的情形,确保他们不会生气。
    • 一个单元测试方法不能调用另外一个单元测试方法。如果想删除重复代码,那就抽取共同的代码到另一个方法中。
    • 隔离测试的方法:把你当前正在写的测试当做系统中唯一的一个测试。但是要注意,你必须把单元测试可能修改的状态恢复到初始值
    • 最安全的做法:每个测试使用一个单独的对象实例。
    • 想要进行流测试,最好使用某种集成测试框架。
    • 断言和操作需要分离。在断言的参数里面,只传入最终结果,其方法调用过程需要分离开。

参考:


原文地址:https://www.cnblogs.com/schaepher/p/6375076.html