记得收藏【国际动画教程网】,赶快注册吧!
注册

合作站点账号登陆

QQ登录

只需一步,快速开始

扫一扫,访问微社区

快捷导航

单元测试之旅--预见优秀[含7P]

[复制链接]
发表于 2017-4-25 22:06:34 | 显示全部楼层 |阅读模式

单元测试之旅:预见优秀

大纲

  • 单元测试入门——优秀基因:从单元测试的定义开始,主要讨论单元测试如何开展的话题。
  • 单元测试进阶——寻求优秀:在熟悉单元测试的基础上,主要讨论如何进行优秀的单元测试。
  • 单元测试实践——构建优秀:对优秀的单元测试进行具体实践,以及探讨单元测试更多的场景。

1. 单元测试入门——优秀基因

单元测试最初兴起于敏捷社区。1997年,设计模式四巨头之一Erich Gamma和极限编程发明人Kent Beck共同开发了JUnit,而JUnit框架在此之后又引领了xUnit家族的发展,深刻的影响着单元测试在各种编程语言中的普及。当前,单元测试也成了敏捷开发流行以来的现代软件开发中必不可少的工具之一。同时,越来越多的互联网行业推崇自动化测试的概念,作为自动化测试的重要组成部分,单元测试是一种经济合理的回归测试手段,在当前敏捷开发的迭代(Sprint)中非常流行和需要。

然而有些时候,这些单元测试并没有有效的改善生产力,甚至单元测试有时候变成一种负担。人们盲目的追求测试覆盖率,往往却忽视了测试代码本身的质量,各种无效的单元测试反而带来了沉重的维护负担。

本篇讲义将会集中的从单元测试的入门、优秀单元测试的编写以及单元测试的实践等三个方面展开探讨。

文中的相关约定:

文中的示例代码块均使用Java语言。 文中的粗体部分表示重点内容和重点提示。 文中的引用框部分,一般是定义或者来源于其它地方。 文中标题的【探讨】,表示此部分讲师与学员共同探讨并由讲师引导,得到方案。 文中的 代码变量和说明 用方框圈起来的,是相关代码的变量、方法、异常等。

1.1 单元测试的价值

  • 什么是单元测试

    在维基百科中,单元测试被定义为一段代码调用另一段代码,随后检验一些假设的正确性。

    以上是对单元测试的传统定义,尽管从技术上说是正确的,但是它很难使我们成为更加优秀的程序员。这些定义在诸多讨论单元测试的书籍和网站上,我们总能看到,可能你已经厌倦,觉得是老生常谈。不过不必担心,正是从这个我们熟悉的,共同的出发点,我们引申出单元测试的概念。

    或许很多人将软件测试行为与单元测试的概念混淆为一谈。在正式开始考虑单元测试的定义之前,请先思考下面的问题,回顾以前遇到的或者所写的测试:

    • 两周或者两个月、甚至半年、一年、两年前写的单元测试,现在还可以运行并得到结果么?
    • 两个月前写的单元测试,任何一个团队成员都可以运行并且得到结果么?
    • 是否可以在数分钟以内跑完所有的单元测试呢?
    • 可以通过单击一个按钮就能运行所写的单元测试么?
    • 能否在数分钟内写一个基本的单元测试呢?

    当我们能够对上述的问题,全部回答“是”的时候,我们便可以定义单元测试的概念了。优秀的测试应该以其本来的、非手工的形式轻松执行。同时,这样的测试应该是任何人都可以使用,任何人都可以运行的。在这个前提下,测试的运行应该能够足够快,运行起来不费力、不费事、不费时,并且即便写新的测试,也应该能够顺利、不耗时的完成。如上便是我们需要的单元测试。

    涵盖上面描述的要求的情况下,我们可以提出比较彻底的单元测试的定义:

    单元测试(Unit Test),是一段自动化的代码,用来调动被测试的 方法 ,而后验证基于该方法或类的 逻辑行为 的一些假设。单元测试几乎总是用 单元测试框架 来写的。它写起来很顺手,运行起来不费时。它是全自动的、可信赖的、可读性强的和可维护的。

    接下来我们首先讨论单元测试框架的概念:

    框架是一个应用程序的半成品。框架提供了一个可复用的公共结构,程序员可以在多个应用程序之间进行共享该结构,并且可以加以扩展以便满足它们的特定的要求。

    单元测试检查一个独立工作单元的行为,在Java程序中,一个独立工作单元经常是一个独立的方法,同时就是一项单一的任务,不直接依赖于其它任何任务的完成。

    所有的代码都需要测试。于是在代码中的满足上述定义,并且对独立的工作单元进行测试的行为,就是我们讨论的单元测试。

  • 优秀单元测试的特性

    单元测试是非常有威力的魔法,但是如果使用不当也会浪费你大量的时间,从而对项目造成巨大的不利影响。另一方面,如果没有恰当的编写和实现单元测试,在维护和调用这些测试上面,也会很容易的浪费很多时间,从而影响产品代码和整个项目。

    我们不能让这种情况出现。请切记,做单元测试的首要原因是为了工作更加轻松。现在我们一起探讨下如何编写优秀的单元测试,只有如此,方可正确的开展单元测试,提升项目的生产力。

    根据上一小节的内容,首先我们列出一些优秀的单元测试大多具备的特点:

    1. 自动的、可重复的执行的测试
    2. 开发人员比较容易实现编写的测试
    3. 一旦写好,将来任何时间都依旧可以用
    4. 团队的任何人都可运行的测试
    5. 一般情况下单击一个按钮就可以运行
    6. 测试可以可以快速的运行
    7. ……

    或许还有更多的情形,我们可以再接再厉的思考出更多的场景。总结这些,我们可以得到一些基本的应该遵循的简单原则,它们能够让不好的单元测试远离你的项目。这个原则定义了一个优秀的测试应该具备的品质,合称为 A-TRIP

    • 自动化(Automatic)
    • 彻底的(Thorough)
    • 可重复(Repeatable)
    • 独立的(Independent)
    • 专业的(Professional)

    接下来,我们分别就每一个标准进行分析和解释,从而我们可以正确的理解这些。

    • A -TRIP 自动化(Automatic)

      单元测试需要能够自动的运行。这里包含了两个层面:调用测试的自动化以及结果检查的自动化。

      1. 调用测试的自动化:代码首先需要能够正确的被调用,并且所有的测试可以有选择的依次执行。在一些时候,我们选择IDE(Integration Development Environment,集成开发环境)可以帮助我们自动的运行我们指定的测试,当然也可以考虑CI(Continuous Integration,持续集成)的方式进行自动化执行测试。
      2. 结果检查的自动化:测试结果必须在测试的执行以后,“自己”告诉“自己”并展示出来。如果一个项目需要通过雇佣一个人来读取测试的输出,然后验证代码是否能够正常的工作,那么这是一种可能导致项目失败的做法。而且一致性回归的一个重要特征就是能够让测试自己检查自身是否通过了验证,人类对这些重复性的手工行为也是非常不擅长。
    • A- T RIP 彻底的(Thorough)

      好的单元测试应该是彻底的,它们测试了所有可能会出现问题的情况。一个极端是每行代码、代码可能每一个分支、每一个可能抛出的异常等等,都作为测试对象。另一个极端是仅仅测试最可能的情形——边界条件、残缺和畸形的数据等等。事实上这是一个项目层面的决策问题。

      另外请注意:Bug往往集中的出现在代码的某块区域中,而不是均匀的分布在代码的每块区域中的。对于这种现象,业内引出了一个著名的战斗口号“不要修修补补,完全重写!”。一般情况下,完全抛弃一块Bug很多的代码块,并进行重写会令开销更小,痛苦更少。

      总之,单元测试越多,代码问题越少。

    • A-T R IP 可重复(Repeatable)

      每一个测试必须可以重复的,多次执行,并且结果只能有一个。这样说明,测试的目标只有一个,就是测试应该能够以任意的的顺序一次又一次的执行,并且产生相同的结果。意味着,测试不能依赖不受控制的任何外部因素。这个话题引出了“测试替身”的概念,必要的时候,需要用测试替身来隔离所有的外界因素。

      如果每次测试执行不能产生相同的结果,那么真相只有一个:代码中有真正的Bug。

    • A-TR I P 独立的(Independent)

      测试应该是简洁而且精炼的,这意味着每个测试都应该有强的针对性,并且独立于其它测试和环境。请记住,这些测试,可能在同一时间点,被多个开发人员运行。那么在编写测试的时候,确保一次只测试了一样东西。

      独立的,意味着你可以在任何时间以任何顺序运行任何测试。每一个测试都应该是一个孤岛。

    • A-TRI P 专业的(Professional)

      测试代码需要是专业的。意味着,在多次编写测试的时候,需要注意抽取相同的代码逻辑,进行封装设计。这样的做法是可行的,而且需要得到鼓励。

      测试代码,是真实的代码。在必要的时候,需要创建一个框架进行测试。测试的代码应该和产品的代码量大体相当。所以测试代码需要保持专业,有良好的设计。

  • 生产力的因素

    这里我们讨论生产力的问题。

    当单元测试越来越多的时候,团队的测试覆盖率会快速的提高,不用再花费时间修复过去的错误,待修复缺陷的总数在下降。测试开始清晰可见的影响团队工作的质量。但是当测试覆盖率不断提高的时候,我们是否要追求100%的测试覆盖率呢?

    事实上,那些确实的测试,不会给团队带来更多价值,花费更多精力来编写测试不会带来额外的收益。很多测试未覆盖到的代码,在项目中事实上也没有用到。何必测试那些空的方法呢?同时,100%的覆盖率并不能确保没有缺陷——它只能保证你所有的代码都执行了,不论程序的行为是否满足要求,与其追求代码覆盖率,不如将重点关注在确保写出有意义的测试。

    当团队已经达到稳定水平——曲线的平坦部分显示出额外投资的收益递减。测试越多,额外测试的价值越少。第一个测试最有可能是针对代码最重要的区域,因此带来高价值与高风险。当我们为几乎所有事情编写测试后,那些仍然没有测试覆盖的地方,很可能是最不重要和最不可能破坏的。

    接下来分析一个测试因素影响的图:

    单元测试之旅--预见优秀 软件开发,编程语言,互联网,发明人,覆盖率 【Java】 220702xbxx9jj7vqgj3bjx 1

    事实上,大多数代码将测试作为质量工具,沿着曲线停滞了。从这里看,我们需要找出影响程序员生产力的因素。本质上,测试代码的重复和多余的复杂性会降低生产力,抵消测试带来的正面影响。最直接的两个影响生产力的因素: 反馈环长度 和 调试 。这两者是在键盘上消耗程序员时间的罪魁祸首。如果在错误发生后迅速学习,那么花在调试上的时间是可以大幅避免的返工——同时,反馈环越长,花在调试上的时间越多。

    等待对变更进行确认和验证,在很大程度上牵扯到测试执行的速度,这个是上述强调的反馈环长度和调试时间的根本原因之一。另外三个根本原因会影响程序员的调试量。

    1. 测试的可读性:缺乏可读性自然降低分析的熟读,并且鼓励程序员打开调试器,因为阅读代码不会让你明白。同时因为很难看出错误的所在,还会引入更多的缺陷。
    2. 测试结果的准确度:准确度是一个基本要求。
    3. 可依赖性和可靠性:可靠并且重复的方式运行测试,提供结果是另一个基本要求。

  • 设计潜力的曲线

    假设先写了最重要的测试——针对最常见和基本的场景,以及软件架构中的关键部位。那么测试质量很高,我们可以讲重复的代码都重构掉,并且保持测试精益和可维护。那么我们想象一下,积累了如此高的测试覆盖率以后,唯一没测试到的地方,只能是那些最不重要和最不可能破坏的,项目没有运行到的地方了。平心而论,那么地方也是没有什么价值的地方,那么,之前的做法倾向于收益递减——已经不能再从编写测试这样的事情中获取价值了。

    这是由于不做的事情而造成的质量稳态。之所以这么说,是因为想要到达更高的生产力,我们需要换个思路去考虑测试。为了找回丢掉的潜力,我们需要从编写测试中找到完全不同的价值——价值来自于创新及设计导向,而并非防止回归缺陷的保护及验证导向。

    总而言之,为了充分和完全的发挥测试的潜力,我们需要:

    1. 像生产代码一样对待你测试代码——大胆重构、创建和维护高质量测试
    2. 开始将测试作为一种设计工具,指导代码针对实际用途进行设计。

    第一种方法,是我们在这篇讲义中讨论的重点。多数程序员在编写测试的时候会不知所措,无法顾及高质量,或者降低编写、维护、运行测试的成本。

    第二种方法,是讨论利用测试作为设计的方面,我们的目的是对这种动态和工作方式有个全面的了解,在接下来的[探讨]中我们继续分析这个话题。

1.2 [探讨]正确地认识单元测试

  • 练习:一个简单的单元测试示例

    我们从一个简单的例子开始设计测试,它是一个独立的方法,用来查找list中的最大值。

    int getLargestElement(int[] list){
      // TODO: find largest element from list and return it.
    }

    比如,给定一个数组 { 1, 50, 81, 100 },这个方法应该返回100,这样就构成了一个很合理测试。那么,我们还能想出一些别的测试么?就这样的方法,在继续阅读之前,请认真的思考一分钟,记下来所有能想到的测试。

    在继续阅读之前,请静静的思考一会儿……

    想到了多少测试呢?请将想到的测试都在纸上写出来。格式如下:

    • 50, 60, 7, 58, 98 --> 98
    • 100, 90, 25 --> 100
    • ……

    然后我们编写一个基本的符合要求的函数,来继续进行测试。

    public int getLargestElement(int[] list) {
      int temp = Integer.MIN_VALUE;
      for (int i = 0; i < list.length; i++) {
        if (temp < list) {
          temp = list;
        }
      }
      return temp;
    }

    然后请考虑上述代码是否有问题,可以用什么样的例子来进行测试。

  • 分析:为什么不写单元测试

    请思考当前在组织或者项目中,如何写单元测试,是否有不写单元测试的习惯和借口,这些分别是什么?

  • 分析:单元测试的结构与内容

    当我们确定要写单元测试的时候,请认真分析,一个单元测试包含什么样的内容,为什么?

  • 分析:单元测试的必要性

    请分析单元测试必要性,尝试得出单元测试所带来的好处。

    单元测试的主要目的,就是验证应用程序是否可以按照预期的方式正常运行,以及尽早的发现错误。尽管功能测试也可以做到这一点,但是单元测试更加强大,并且用户更加丰富,它能做的不仅仅是验证应用程序的正常运行,单元测试还可以做到更多。

    • 带来更高的测试覆盖率

      功能测试大约可以覆盖到70%的应用程序代码,如果希望进行的更加深入一点,提供更高的测试覆盖率,那么我们需要编写单元测试了。单元测试可以很容易的模拟错误条件,这一点在功能测试中却很难办到,有些情况下甚至是不可能办到的。单元测试不仅提供了测试,还提供了更多的其它用途,在最后一部分我们将会继续介绍。

    • 提高团队效率

      在一个项目中,经过单元测试通过的代码,可以称为高质量的代码。这些代码无需等待到其它所有的组件都完成以后再提交,而是可以随时提交,提高的团队的效率。如果不进行单元测试,那么测试行为大多数要等到所有的组件都完成以后,整个应用程序可以运行以后,才能进行,严重影响了团队效率。

    • 自信的重构和改进实现

      在没有进行单元测试的代码中,重构是有着巨大风险的行为。因为你总是可能会损坏一些东西。而单元测试提供了一个安全网,可以为重构的行为提供信心。同时在良好的单元测试基础上,对代码进行改进实现,对一些修改代码,增加新的特性或者功能的行为,有单元测试作为保障,可以防止在改进的基础上,引入新的Bug。

    • 将预期的行为文档化

      在一些代码的文档中,示例的威力是众所周知的。当完成一个生产代码的时候,往往要生成或者编写对应的API文档。而如果在这些代码中进行了完整的单元测试,则这些单元测试就是最好的实例。它们展示了如何使用这些API,也正是因为如此,它们就是完美的开发者文档,同时因为单元测试必须与工作代码保持同步,所以比起其它形式的文档,单元测试必须始终是最新的,最有效的。

1.3 用 JUnit 进行单元测试

JUnit诞生于1997年,Erich Gamma 和 Kent Beck 针对 Java 创建了一个简单但是有效的单元测试框架,随后迅速的成为 Java 中开发单元测试的事实上的标准框架,被称为 xUnit 的相关测试框架,正在逐渐成为任何语言的标准框架。

以我们的角度,JUnit用来“确保方法接受预期范围内的输入,并且为每一次测试输入返回预期的值”。在这一节里,我们从零开始介绍如何为一个简单的类创建单元测试。我们首先编写一个测试,以及运行该测试的最小框架,以便能够理解单元测试是如何处理的。然后我们在通过 JUnit 展示正确的工具可以如何使生活变得更加简单。

本文中使用 JUnit 4 最新版进行单元测试的示例与讲解。

JUnit 4 用到了许多 Java 5 中的特性,如注解。JUnit 4 需要使用 Java 5 或者更高的版本。

  • 用 JUnit 构建单元测试

    这里我们开始构建单元测试。

    首先我们使用之前一节的【探讨】中使用过的类,作为被测试的对象。创建一个类,叫做 HelloWorld ,该类中有一个方法,可以从输入的一个整型数组中,找到最大的值,并且返回该值。

    代码如下:

    public class HelloWorld {
    
       public int getLargestElement(int[] list) {
           int temp = Integer.MIN_VALUE;
           for (int i = 0; i < list.length; i++) {
               if (temp < list) {
                   temp = list;
               }
           }
           return temp;
       }
    }

    虽然我们针对该类,没有列出文档,但是 HelloWorld 中的 int getLargestElement(int[])方法的意图显然是接受一个整型的数组,并且以 int 的类型,返回该数组中最大的值。编译器能够告诉我们,它通过了编译,但是我们也应该确保它在运行期间可以正常的工作。

    单元测试的核心原则是“任何没有经过自动测试的程序功能都可以当做它不存在”。getLargestElement 方法代表了 HelloWorld 类的一个核心功能,我们拥有了一些实现该功能的代码,现在缺少的只是一个证明实现能够正常工作的自动测试。

    这个时候,进行任何测试看起来都会有些困难,毕竟我们甚至没有可以输入一个数组的值的用户界面。除非我们使用在【探讨】中使用的类进行测试。

    示例代码:

    public class HelloWorldTest {
        public static void main(String[] args) {
            HelloWorld hello = new HelloWorld();
            int[] listToTest = {-10, -20, -100, -90};
            int result = hello.getLargestElement(listToTest);
            if (result != -10) {
                System.out.println("获取最大值错误,期望的结果是 100;实际错误的结果: " + result);
            } else {
                System.out.println("获取最大值正确,通过测试。");
            }
        }
    }

    输出结果如下:

    获取最大值正确,通过测试。
    
    Process finished with exit code 0

    第一个 HelloWorldTest 类非常简单。它创建了 HelloWorld 的一个实例,传递给它一个数组,并且检查运行的结果。如果运行结果与我们预期的不一致,那么我们就在标准输出设备上输出一条消息。

    现在我们编译并且运行这个程序,那么测试将会正常通过,同时一切看上去都非常顺利。可是事实上并非都是如此圆满,如果我们修改部分测试,再次运行,可能会遇到不通过测试的情况,甚至代码异常。

    接下来我们修改代码如下:

    public class HelloWorldTest {
        public static void main(String[] args) {
            HelloWorld hello = new HelloWorld();
            int[] listToTest = null;
            int result = hello.getLargestElement(listToTest);
            if (result != -10) {
                System.out.println("获取最大值错误,期望的结果是 100;实际错误的结果: " + result);
            } else {
                System.out.println("获取最大值正确,通过测试。");
            }
        }
    }

    当我们再次执行代码的时候,代码运行就会报错。运行结果如下:

    Exception in thread "main" java.lang.NullPointerException
    at HelloWorld.getLargestElement(HelloWorld.java:11)
    at HelloWorldTest.main(HelloWorldTest.java:13)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:144)
    
    Process finished with exit code 1

    按照第一节中的描述的优秀的单元测试,上述代码毫无疑问,称不上优秀的单元测试,因为测试连运行都无法运行。令人高兴的是,JUnit 团队解决了上述麻烦。JUnit 框架支持自我检测,并逐个报告每个测试的所有错误和结果。接下来我们来进一步了解 JUnit 。

    JUnit 是一个单元测试框架,在设计之初,JUnit 团队已经为框架定义了3个不相关的目标:

    • 框架必须帮助我们编写有用的测试
    • 框架必须帮助我们创建具有长久价值的测试
    • 框架必须帮助我们通过复用代码来降低编写测试的成本

    首先安装 JUnit 。这里我们使用原始的方式添加 JAR 文件到 ClassPath 中。

    下载地址: https://github.com/junit-team/junit4/wiki/Download-and-Install,下载如下两个 JAR 包,放到项目的依赖的路径中。

    • junit.jar
    • hamcrest-core.jar

    在 IDEA 的项目中,添加一个文件夹 lib,将上述两个文件添加到 lib 中。

    然后 File | Project Structure | Modules,打开 Modules 对话框,选择右边的 Dependencies 的选项卡,点击右边的 + 号,选择 “1 JARs or directories”并找到刚刚添加的两个 JRA 文件,并确定。

    然后新建 Java Class,代码如下:

    public class HelloWorldTests {
    
        @Test
        public void test01GetLargestElement(){
            HelloWorld hello = new HelloWorld();
            int[] listToTest = {10, 20, 100, 90};
            int result = hello.getLargestElement(listToTest);
            Assert.assertEquals("获取最大值错误! ", 100, result);
        }
    
        @Test
        public void test02GetLargestElement(){
            HelloWorld hello = new HelloWorld();
            int[] listToTest = {-10, 20, -100, 90};
            int result = hello.getLargestElement(listToTest);
            Assert.assertEquals("获取最大值错误! ", 90, result);
        }
    }

    如上的操作,我们便定义了一个单元测试,使用 JUnit 编写了测试。主要的要点如下:

    1. 针对每个测试的对象类,单独编写测试类,测试方法,避免副作用
    2. 定义一个测试类
    3. 使用 JUnit 的注解方式提供的方法: @Test
    4. 使用 JUnit 提供的方法进行断言:Assert.assertEquals(String msg, long expected, long actual)
    5. 创建一个测试方法的要求:该方法必须是公共的,不带任何参数,返回值类型为void,同时必须使用@Test注解
  • JUnit 的各种断言

    为了进行验测试验证,我们使用了由 JUnit 的 Assert 类提供的 assert 方法。正如我们在上面的例子中使用的那样,我们在测试类中静态的导入这些方法,同时还有更多的方法以供我们使用,如下我们列出一些流行的 assert 方法。

    | 方法 Method | 检查条件 | | ---------------------------------- | ------------------------ | | assertEquals(msg, a, b) | a == b,msg可选,用来解释失败的原因 | | assertNotEquals(msg, a, b) | a != b,msg可选,用来解释失败的原因 | | assertTrue(msg, x ) | x 是真,msg可选,用来解释失败的原因 | | assertFalse(msg, x) | x 是假,msg可选,用来解释失败的原因 | | assertSame(msg, a, b) | a 不是 b,msg可选,用来解释失败的原因 | | assertNull(msg, x) | x 是null,msg可选,用来解释失败的原因 | | assertNotNull(msg, x) | x 不是null,msg可选,用来解释失败的原因 | | assertThat(msg, actual, matcher) | 用匹配器进行断言,高级应用*,不再此文档讨论 |

    一般来说,一个测试方法包括了多个断言。当其中一个断言失败的时候,整个测试方法将会被终止——从而导致该方法中剩下的断言将会无法执行了。此时,不能有别的想法,只能先修复当前失败的断言,以此类推,不断地修复当前失败的断言,通过一个个测试,慢慢前行。

  • JUnit 的框架

    到目前为止,我们只是介绍了断言本身,很显然我们不能只是简单的把断言方法写完,就希望测试可以运行起来。我们需要一个框架来辅助完成这些,那么我们就要做多一些工作了。很幸运的是,我们不用多做太多。

    在 JUnit 4 提供了 @Before 和 @After ,在每个测试函数调用之前/后都会调用。

    • @Before : Method annotated with @Before executes before every test. 每个测试方法开始前执行的方法
    • @After : Method annotated with @After executes after every test. 每个测试方法执行后再执行的方法

    如果在测试之前有些工作我们只想做一次,用不着每个函数之前都做一次。比如读一个很大的文件。那就用下面两个来标注: @BeforeClass : 测试类初始化的时候,执行的方法 @AfterClass : 测试类销毁的时候,执行的方法

    注意:

    1. @Before / @After 可以执行多次; @BeforeClass / @AfterClass 只能执行一次
    2. 如果我们预计有Exception,那就给@Test加参数: @Test(expected = XXXException.class)
    3. 如果出现死循环怎么办?这时timeout参数就有用了: @Test(timeout = 1000)
    4. 如果我们暂时不用测试一个用例,我们不需要删除或都注释掉。只要改成: @Ignore ,你也可以说明一下原因 @Ignore("something happens")

    示例代码:下面的代码代表了单元测试用例的基本框架

    public class JUnitDemoTest {
        @Before
        public void setUp(){
            //TODO: 测试预置条件,测试安装
        }
        @After
        public void tearDown(){
            //TODO: 测试清理,测试卸载
        }
        @Test
        public void test01(){
            //TODO: test01 脚本
        }
        @Test
        public void test02(){
            //TODO: test02 脚本
        }
        @Test
        public void test03(){
            //TODO: test03 脚本
        }
    }

    单元测试框架的过程如下:

    单元测试之旅--预见优秀 软件开发,编程语言,互联网,发明人,覆盖率 【Java】 220702ojxx5sdrbvddv9ug 2

    JUnit 需要注意的事项:

    1. 每个 @Test 都是一个测试用例,一个类可以写多个 @Test
    2. 每个 @Test 执行之前 都会执行 @Before,执行之后都会运行 @After
    3. 每个 @Test , @After , @Before 都必须是 public void , 参数为空
    4. @After / @Before 也可以是多个,并且有执行顺序。在每个 @Test 前后执行多次。
      • @Before 多个名字长度一致, z -> a , 长度不一致,会先执行名字短的。
      • @After / @Test 多个名字长度一致, a -> z , 长度不一致,会后执行名字短的。
    5. @AfterClass / @BeforeClass 也可以是多个,并且有执行顺序。只会在测试类的实例化前后各执行一次。
      • @BeforeClass 多个名字长度一致, z -> a , 长度不一致,会先执行名字短的。
      • @AfterClass 多个名字长度一致, a -> z , 长度不一致,会后执行名字短的。
    6. @AfterClass / @BeforeClass 都必须是 public static void, 参数为空
    7. 测试结果有 通过、不通过和错误 三种。
  • JUnit 的测试运行

    这一小节,我们来介绍一下 JUnit 4 中的新的测试运行器(Test Runner)。如果我们刚开始编写测试,那么我们需要尽可能快捷的运行这些测试,这样我们才能够将测试融合到开发循环中去。

    编码 → 运行 → 测试 → 编码……

    其中,JUnit 就可以让我们构建和运行测试。我们可以按照 组合测试Suite 以及 参数化测试 分别来运行测试。

    • 组合测试Suite

      测试集 (Suite 或者 test suite)一组测试。测试集是一种把多个相关测试归入一组的便捷测试方式。可以在一个测试集中,定义需要打包测试的类,并一次性运行所有包含的测试;也可以分别定义多个测试集,然后在一个主测试集中运行多个相关的测试集,打包相关的测试的类,并一次性运行所有包含的测试。

      示例代码如下:

      @RunWith(value = Suite.class)
      @Suite.SuiteClasses(value = HelloWorldTests.class)
      public class HelloWorldTestRunner {
      }

    • 参数化测试

      参数化测试(Parameterized)是测试运行器允许使用不同的参数多次运行同一个测试。参数化测试的代码如下:

      @RunWith(value = Parameterized.class)
      public class ParameterizedHelloWorldTests {
      
          @Parameterized.Parameters
          public static Collection getTestParameters() {
              int[] listToTest1 = {10, 80, 100, -96};
              int[] listToTest2 = {-10, -80, -100, -6};
              int[] listToTest3 = {10, -80, -100, -96};
              int[] listToTest4 = {10, -80, 100, -96};
              int[] listToTest5 = {10, 80, -100, -96};
      
              return Arrays.asList(new Object[][]{
                      {100, listToTest1},
                      {-6, listToTest2},
                      {10, listToTest3},
                      {100, listToTest4},
                      {80, listToTest5}});
          }
      
          @Parameterized.Parameter
          public int expected;
      
          @Parameterized.Parameter(value = 1)
          public int[] listToTest;
      
          @Test
          public void testGetLargestElementByParameters() {
              Assert.assertEquals("获取最大元素错误!", expected, new HelloWorld().getLargestElement(listToTest));
          }
      }

      对于参数化测试的运行器来运行测试类,那么必须满足以下要求:

      1. 测试类必须使用 @RunWith(value = Parameterized.class) 注解
      2. 必须声明测试中所使用的实例变量
      3. 提供一个用 @Parameterized.Parameters 的注解方法,这里用的是 getTestParameters() ,同时此方法的签名必须是 public static Collection
      4. 为测试指定构造方法,或者一个个全局变量的成员进行赋值
      5. 所有的测试方法以 @Test 注解,实例化被测试的程序,同时在断言中使用我们提供的全局变量作为参数

1.4 [探讨]按业务价值导向进行单元测试设计

  • 练习:测试的结果是否正确

    如果测试代码能够运行正确,我们要怎么才能知道它是正确的呢?

    如何应对测试数据量比较大的时候,我们的测试代码如何编写?

  • 练习:测试的边界条件

    寻找边界条件是单元测试中最有价值的工作之一,一般来说Bug出现在边界上的概率比较大。那么我们都需要考虑什么样的边界条件呢?

  • 练习:强制产生错误条件

    关于产生错误的条件,请列出一个详细的清单来。

  • 分析:测试作为设计工具

    第一节【专题】中,我们有讨论设计潜力的曲线,其中第二条方案强调了测试作为设计的工具。那么我们想就两个方面来讨论这个测试设计的问题。

    1. TDD,测试驱动开发
    2. BDD,行为驱动开发

2. 单元测试进阶——寻求优秀

2.1 使用测试替身

在现代开发者测试的上下文中,除了允许在某些依赖缺失的情况下编译执行代码以外,崇尚测试的程序员还创建了一套“仅供测试”的工具,用于隔离被测试的代码、加速执行测试、使得随机行为变得确定、模拟特殊情况以及能够使测试访问隐藏信息等。满足这些目的的各种对象具有相似之处,但又有所区别,我们统称为测试替身(test double)。

这一节我们先探讨开发者采用测试替身的理由,理解了测试替身潜在的好处以后,我们再解析来看看各种可供选择的测试替身的类型。

  • 测试替身的威力

    引入测试替身的最根本的原因是——将被测试代码与周围隔离开。为了时不时的验证一段代码的行为是否符合期望值,我们最好的选择就是替换其周围的代码,使得获取对环境的完整控制,从而在其中测试目标代码。

    通过以下的几个部分,我们来讨论测试替身的好处。

    • 隔离被测试的代码

      代码的世界,一般包括了两种:被测试代码和与被测试代码进行交互的代码。

      接下来我们用一个简单的例子,展示如何隔离代码。示例代码如下:

      public class Car {
          private Engine engine;
      
          public Car(Engine engine) {
              this.engine = engine;
          }
      
          public void start() {
              this.engine.startUp();
          }
      
          public void stop() {
              this.engine.shutDown();
          }
      
          public void drive(Route route) {
              for (Directions directions : route.directions()) {
                  directions.follow();
              }
          }
      }

      这个例子中,包括了两个协作类: Engine 和 Route ,还有一个间接使用者: Directions

      我们站在 Car 的视角,用测试替身替换 Engine 和 Route , 用伪实现替换Route,那么我们就完全控制了向 Car 提供的各种 Directions 。

      类之间的关系如下:

      单元测试之旅--预见优秀 软件开发,编程语言,互联网,发明人,覆盖率 【Java】 220702uvxzzo7ososorjdy 3

    • 加速执行测试

      由于 Car 需要调用 Directions ,而后者的产生依赖于 Route ,假设在 Route 层面需要的时间比较多,测试来不及等这么久的情况下,可以通过使用对 Route 放置测试替身,实现快速的不用等待的测试执行。

      放置一个测试替身,令它总是返回预先计算好的路径,这样会避免不必要的等待,而且测试运行的更快了,

    • 使执行变得确定

      任何的测试代码,都可能包含了不确定的随机性。为了验证代码和测试具有确定的结果,我们需要能够针对同样的代码进行重复的运行测试,并总能够得到相同的结果。

      事实上,这个情况非常理想状态。很多时候,生产的代码有随机因素。或许不确定的行为,最典型的情形就是依赖于时间的行为。回到我们的 Car 的这个例子,不同的时间,得到的路线( Route 的 Directions )可能是不同的。在高峰时间和非高峰时间,得到的路径导航,可能是不相同的。我们通过对 Route 进行测试替身,使得之前不确定的测试变得确定起来。

    • 暴露隐藏的信息

      在 Car 这个例子里面,可以用测试替身完成最后一个需要它的理由。我们能看到,当 Car 进行启动的时候,需要调用了engine的 start() 的方法。engine目前是私有型,我们在测试中无法获得的engine的项目类型。那么我们需要用一个测试替身,来通过给它增加状态的方式,验证单元测试对乱码的讨厌。

      被测试的代码:

      public class TestEngine extends Engine {
          public boolean isRunning() {
              return isRunning;
          }
      
          private boolean isRunning;
      
          public void start() {
              this.isRunning = true;
          }
      }

  • 测试替身的类型

    主要的测试替身有 桩 ( Stub )、伪造对象( Fake )、测试间谍( Spy )以及模拟对象( Mock )四种。

    1. Stub (桩):一般什么都不做,实现空的方法调用或者简单的硬编码返回即可。
    2. Fake (伪造对象):真实事物的简答版本,优化的伪造真实事物的行为,但是没有副作用或者使用真实事物的其它后果。比如替换数据库的对象,而得到虚假的伪造对象。
    3. Spy (测试间谍):需要得到对象内部的状态的时候,而该对象对外又是封闭的,那么需要做一个测试间谍,事先学会反馈消息,然后潜入对象内部去获取对象的状态。测试间谍是一种测试替身,它用于记录过去发生的情况,这样测试在事后就能知道所发生的一切。
    4. Mock (模拟对象):模拟对象是一个特殊的测试间谍。是一个在特定的情况下可以配置行为的对象,规定了在什么情况下,返回什么样的值的一种测试替身。Mock已经有了非常成熟的对象库,包括JMock、Mockito和EasyMock等。

2.2 [探讨]优秀单元测试的支柱

  • 分析:独立的测试易于单独运行

    什么样的单元测试是独立的测试?

  • 分析:可维护的测试才是有意义的

    什么样的措施可以使得单元测试是可维护的?

  • 可读的代码才是可维护的

    如何从测试用例的要素中匹配单元测试代码的可读性?

  • 可靠的测试才是可靠的

    从哪些角度的思考与设计可以让单元测试代码变得可信赖和可靠?

2.3 识别单元测试中的坏味道

  • 过度断言

    过度断言是如此谨慎的敲定每个待检查行为的细节,以致它变得脆弱,并且掩盖了整体广度很深度之下的意图。当遇到过度断言,很难说清楚它要检查什么,并且当你退后一步观察,会看到测试打断的频率可能远超平均水平。它如此挑剔,以致无论任何变化都会造成输出与期望不同。

    我们看下面的例子来具体讨论。被测试的类叫做 LogFileTransformer ,是一个用来转换日志格式的类。

    public class LogFileTransformerTest {
        private String expectedOutput;
        private String logFile;
        @Before
        public void setUpBuildLogFile(){
            StringBuilder lines = new StringBuilder();
            lines.append("[2015-05-23 21:20:33] LAUNCHED");
            lines.append("[2015-05-23 21:20:33] session-di###SID");
            lines.append("[2015-05-23 21:20:33] user-id###UID");
            lines.append("[2015-05-23 21:20:33] presentation-id###PID");
            lines.append("[2015-05-23 21:20:33] screen1");
            lines.append("[2015-05-23 21:20:33] screen2");
            //TODO: lines.append(...)
            logFile = lines.toString();
        }
        @Before
        public void setUpBuildTransformedFile(){
            StringBuilder lines = new StringBuilder();
            lines.append("LAUNCHED");
            lines.append("session-di###SID");
            lines.append("user-id###UID");
            lines.append("presentation-id###PID");
            lines.append("screen1");
            lines.append("screen2");
            //TODO: lines.append(...)
            expectedOutput = lines.toString();
        }
        @Test
        public void testTransformationGeneratesRgiht(){
            TransfermationGenerator generator = new TransfermationGenerator();
            File outputFile = generator.transformLog(logFile);
            Assert.assertTrue("目标文件转换后不存在!", outputFile.exists());
            Assert.assertEquals("目标文件转换后不匹配!", expectedOutput, getFileContent(outputFile));
        }
    }

    看到过度断言了么?这里有两个断言,但是哪个是罪魁祸首,什么造成断言被滥用了呢?

    第一个断言检查目标文件是否创建,第二个断言检查目标文件的内容是否符合期望。现在,第一个断言的价值值得商榷,而且很可能需要被删除。但是我们主要关注第二个断言——过度断言:

    Assert.assertEquals("目标文件转换后不匹配!", expectedOutput, getFileContent(outputFile));

    看上去,它精确的验证了测试名称所暗示的内容,这是个重要的断言。问题是这个测试太宽泛了,导致断言对整个日志文件进行大规模的比较。这是一张厚厚的安全网,毫无疑问,即使是输出中最微小的变化,也会是断言失败。这也正是存在的问题。

    上述例子太容易失败而变得脆弱,断言并无本质的错误,但是问题在于测试违反了构成优秀测试的基本指导原则。

    一个测试应该只有一个失败原因

    那么我们如何改进这个测试?

    我们需要避免全文测试,就算需要要求,也需要分部分内容去测试。

    @Test
    public void testTransformationGeneratesRgiht2(){
        TransfermationGenerator generator = new TransfermationGenerator();
        File outputFile = generator.transformLog(logFile);
        Assert.assertTrue("目标文件转换后不匹配!", getFileContent(outputFile).contains("screen1###0"));
        Assert.assertTrue("目标文件转换后不匹配!", getFileContent(outputFile).contains("screen1###51"));
    }
    @Test
    public void testTransformationGeneratesRgiht3(){
        TransfermationGenerator generator = new TransfermationGenerator();
        File outputFile = generator.transformLog(logFile);
        Assert.assertTrue("目标文件转换后不匹配!", getFileContent(outputFile).contains("session-di###SID#0"));
    }

    修改后,分部对指定的部分进行测试。

  • 人格分裂

    改进测试的一个最简单的方法,就是找出人格分裂的情况。当测试出现了人格分裂的时候,我们认为它本身体现了多个测试,那是不对的。一个测试应当仅检查一件事并妥善执行。

    我们看下面的例子。测试类针对一些命令行接口,用不同的命令行参数来测试 Configuration 类对象的行为。

    public class ConfigurationTest {
        @Test
        public void testParingCommandLineArguments() {
            String[] args = {"-f", "hello.txt", "-v", "--version"};
            Configuration c = new Configuration();
            c.processArguments(args);
            Assert.assertEquals("hello.txt", c.getFileName());
            Assert.assertFalse(c.isDebuggingEnabled());
            Assert.assertFalse(c.isWarningsEnabled());
            Assert.assertTrue(c.isVerbose());
            Assert.assertTrue(c.shouldShowVersion());
    
            c = new Configuration();
            try{
                c.processArguments(new String[] {"-f"});
                Assert.fail("should 测试失败" );
            }catch (InvalidArgumentException expected){
                // 没有问题
            }
        }
    }

    这个测试的多重人格体现在它涉及了文件名、调试、警告、信息开关、版本号显示,还处理了空的命令行参数列表。这里没有遵循 准备 --> 执行 --> 断言 的结构。很明显这里断言了许多东西,虽然它们全部与解析命令行参数有关,但是还是可以彼此隔离的。

    这个测试的主要问题是胃口太大,同时还存在一些重复,我们先排除这些干扰,这样就可以看清主要问题了。

    首先,在测试里用了多次对 Configuration 类的构造器实例化的操作,我们可以将此类的操作抽取出来,并用 @Before 方法中实例化。这样也去掉了测试中的一部分重复。

    代码如下:

    protected Configuration c;
    @Before
    public void instantiateDefaultConfiguration() {
        c = new Configuration();
    }

    去掉重复的实例化以后,我们剩下来对 processArguments() 的两次不同调用和6个不同的断言(包括了 try-catch-fail 模式)。这样意味着我们至少要用两个不同的场景——也就是两个不同的测试。

    结合上面的 @Before ,代码如下:

    @Test
    public void validArgumentsProvided(){
          String[] args = {"-f", "hello.txt", "-v", "--version"};
        c.processArguments(args);
        Assert.assertEquals("hello.txt", c.getFileName());
        Assert.assertFalse(c.isDebuggingEnabled());
        Assert.assertFalse(c.isWarningsEnabled());
        Assert.assertTrue(c.isVerbose());
        Assert.assertTrue(c.shouldShowVersion());
    }
    @Test
    public void missingArgument(){
        try{
              c.processArguments(new String[] {"-f"});
              Assert.fail("should 测试失败" );
        }catch (InvalidArgumentException expected){
              // 没有问题
        }
    }

    但是其实我们还在半路上,一些检查条件是命令行参数的显然结果,另一些是隐含的默认值。从这个角度改进,我们将测试分解成多个测试类。如下图所示:

    单元测试之旅--预见优秀 软件开发,编程语言,互联网,发明人,覆盖率 【Java】 220702gbzxrosxb5udds5s 4

    这次重构意味着有一个测试关注于验证正确的默认值,另一个测试类验证显示设置的命令行值能正确工作,第三个指出应当如何处理错误的配置项。代码如下:

    • AbstractConfigTestCase

      public abstract class AbstractConfigTestCase {
          protected Configuration c;
      
          @Before
          public void instantiateDefaultConfiguration() {
              c = new Configuration();
              c.processArguments(args());
          }
      
          protected String[] args() {
              return new String[] {};
          }
      }

    • TestDefaultConfigValues

      public class TestDefaultConfigValues extends AbstractConfigTestCase {
          @Test
          public void defaultOptionsAreSetCorrectly() {
              assertFalse(c.isDebuggingEnabled());
              assertFalse(c.isWarningsEnabled());
              assertFalse(c.isVerbose());
              assertFalse(c.shouldShowVersion());
          }
      }

    • TestExplicitlySetConfigValues

      public class TestExplicitlySetConfigValues extends AbstractConfigTestCase {
          @Override
          protected String[] args() {
              return new String[] { "-f", "hello.txt", "-v", "-d", "-w", "--version" };
          }
      
          @Test
          public void explicitOptionsAreSetCorrectly() {
              assertEquals("hello.txt", c.getFileName());
              assertTrue(c.isDebuggingEnabled());
              assertTrue(c.isWarningsEnabled());
              assertTrue(c.isVerbose());
              assertTrue(c.shouldShowVersion());
          }
      }

    • TestConfigurationErrors

      public class TestConfigurationErrors extends AbstractConfigTestCase {
          @Override
          protected String[] args() {
              return new String[] { "-f" };
          }
      
          @Test(expected = InvalidArgumentException.class)
          public void missingArgumentRaisesAnError() {
          }
      }

  • 过分保护

    运行 Java 代码的时候,常见的Bug之一就是突然出现 NullPointerException 或 InndexOutOfBoundsException ,这是由于方法意外的收到空指针或者空串参数造成的。当然这些可以由程序员对其进行单元测试,从而增强守卫,保护好自己。

    但是,程序员往往不是保护测试免于以 NullPointerException 而失败,而是让测试优雅的以华丽措辞的断言而失败。这是一种典型的坏味道。

    代码示例:用了两个断言来验证正确的计算:一个验证返回的Data对象不为空,另一个验证实际的计数是正确的。

    public class TestCount {
        @Test
        public void count(){
            Data data = project.getData();
            Assert.assertNotNull(data);
            Assert.assertEquals(8, data.count());
        }
    }

    这是过度保护的测试,以为 assertNotNull(data) 是多余的。在调用方法之前,第一个断言检查 data 不为空,如果为空,测试就失败,这样的测试受到了过度的保护。这是因为当 data 为空的时候,就算没有第一个断言,测试仍然会时报。第二个断言试图调用 data 上的count()时,测试会不幸的以 NullPointerException 而失败。

    需要做的事情,是删除冗余的断言,它基本上是不能提供附加价值的断言和测试语句。

    删除第5行。 Assert.assertNotNull(data);

  • 重复测试

    程序员在写代码的时候,往往关注和追求整洁的代码(clean code)。而重复就是导致代码失去整洁的罪魁祸首之一。那么什么是重复呢?简单来说,重复是存在多份拷贝或对单一概念的多次表达——这都是不必要的重复。

    重复是不好的,它增加了代码的不透明性,使得散落在各处的概念和逻辑很难理解。此外,对于修改代码的程序员来说,每一处重复都是额外的开销。如果忘记或者遗漏了某处的改动,那么又增加了出现Bug的机会。

    代码示例:这个代码展示了几种形式的重复。

    public class TestTemplate {
        @Test
        public void emptyTemplate() throws Exception {
            assertEquals("", new Template("").evaluate());
        }
    
        @Test
        public void plainTextTemplate() throws Exception {
            assertEquals("plaintext", new Template("plaintext").evaluate());
        }
    }

    代码中出现了最常见的文本字符串重复,在两个断言中,空字符串和 plaintext 字符都出现了两次。我们叫这种重复为文字重复。我们可以通过定义局部变量来移除它们。同时在上述测试类中,还存在另一种重复,也许比显而易见的字符串重复有趣的多。当我们提取那些局部变量的时候,这种重复会变得更加清晰。

    首先,我们抽取重复的字符串,清理这些坏的味道。

    public class TestTemplate {
        @Test
        public void emptyTemplate() throws Exception {
            String template = "";
            assertEquals(template, new Template(template).evaluate());
        }
    
        @Test
        public void plainTextTemplate() throws Exception {
            String template = "plaintext";
            assertEquals(template, new Template(template).evaluate());
        }
    }

    其次,确实还有一些比较严重的重复,我们看这两个测试,只有字符串是不同的。当我们抽取的字符串之后,剩下的断言是一模一样的,这种操作不同数据的重复逻辑,我们叫做结构重复。以上的两个代码块用一致的结构操作了不同的数据。

    我们去掉这种重复,提炼重复后,产生一个自定义的断言方式。

    public class TestTemplate {
        @Test
        public void emptyTemplate() throws Exception {
            assertTemplateRendersAsItself("");
        }
    
        @Test
        public void plainTextTemplate() throws Exception {
            assertTemplateRendersAsItself("plaintext");
        }
    
        private void assertTemplateRendersAsItself(String template) {
            assertEquals(template, new Template(template).evaluate());
        }
    }

  • 条件逻辑

    在测试中,一旦存在条件逻辑的时候,一般都不是一件好事儿。这里的条件逻辑,是一种坏味道。假设我们正在重构代码,并运行之前的单元测试来保证代码一切正常。可是此时发现某个测试失败了。看上去很出乎意料,没想到这点小的变更却会影响测试,但是它的确发生了。我们查看代码,却突然发现自己无法知道,测试失败的时候,代码当时在干什么。

    代码示例:测试创建了 DictionaryDemo (字典)对象,用数据填充它,并验证请求到的 Iterator (迭代器)的内容是正确的。

    public class DictionaryTest {
        @Test
        public void returnsAnIteratorForContents(){
            DictionaryDemo dictionary = new DictionaryDemo() ;
            dictionary.add("key1", new Long(3));
            dictionary.add("key2", "45678");
            for (Iterable e = dictionary.iterator(); e.hasNext();){
                Map.Entry entry = (Map.Entry) e.next();
                if( "key1".equals(entry.getKey())){
                    Assert.assertEquals(3L, entry.getValue());
                }
                if( "key2".equals(entry.getKey())){
                    Assert.assertEquals("45678", entry.getValue());
                }
            }
        }
    }

    我们可以看到,这个测试针对的只是 DictionaryDemo 的内部行为,但是仍然非常难理解和解释。通过遍历条目,我们得到返回的 Iterator ,并根据键值对的关系,通过 Key,找到该条目的 Value。但是实际上,如果这两个 Key 没有被添加进去的时候,这个测试不会报错。这里存在了坏的味道。通过使用自定义断言,得到修改。

    代码如下:

    public class DictionaryTest {
    
        @Test
        public void returnsAnIteratorForContents2(){
            DictionaryDemo dictionary = new DictionaryDemo() ;
            dictionary.add("key1", new Long(3));
            dictionary.add("key2", "45678");
            assertContains(dictionary.iterator(), "key1", 3L);
            assertContains(dictionary.iterator(), "key2", "45678");
    
        }
    
        private void assertContains(Iterator i, Object key, Object value){
            while (i.hasNext()){
                Map.Entry entry =  (Map.Entry) i.next();
                if( key.equals(entry.getKey())){
                    Assert.assertEquals(value, entry.getValue());
                    return;
                }
            }
            Assert.fail();
        }
    }

    最后强调一下, Assert.fail() 很容易被遗漏掉。接下来我们就要再一次修改这样的坏味道了。

  • 永不失败的测试

    永不失败的测试,如果是真的能够做到百战百胜,那么是再好不过了。但是往往事与愿违,永不失败的测试往往比没有测试还糟糕。因为它给了虚假的安全感,这样的测试没有价值,出了事情它绝不警告你。

    检查代码是否抛出期望的异常,或许这是一个最常见的在永不失败的测试的场景。

    示例代码:

    public class HelloWorldTests {
        @Test
        public void includeForMissingResourceFails(){
            try {
                new Environment().include("somethingthatdoesnotexist");
            }catch (IOException e){
                Assert.assertThat(e.getMessage(),
                        contains("somethingthatdoesnotexist"));
            }
        }
    }

    这个代码清单中测试的结果是这样的:

    1. 如果代码如期工作并抛出异常,那么这个异常就被catch代码块捕获,于是测试通过。
    2. 如果代码没有如期工作,也就是没有抛出异常,那么方法返回,测试通过,我们并未意识到代码有任何问题。

    但是,这是一个抛异常的测试,在没有抛出异常的时候,测试其实是失败的,需要调用fail()来表示失败。

    public class HelloWorldTests {
        @Test
        public void includeForMissingResourceFails(){
            try {
                new Environment().include("somethingthatdoesnotexist");
                  Assert.fail();
            }catch (IOException e){
                Assert.assertThat(e.getMessage(),
                        contains("somethingthatdoesnotexist"));
            }
        }
    }

    简单的增加对 JUnit 中 fail() 方法的调用,是得测试起作用。现在除非抛出期望的异常,否则测试失败。

    另外 JUnit 4 引入的一个新特性是 @Test 注解的 expected 属性。

    public class HelloWorldTests {
        @Test(expected = IOException.class)
        public void includeForMissingResourceFails(){
            new Environment().include("somethingthatdoesnotexist");
        }
    }

    这样的特性,更短、更容易解析、更不易出错和遗漏。当然这种方法的缺点也很明显:我们不能访问所抛出的实际异常对象,无法进一步对异常进行断言。总之,要防止偶然的写一个用不失败的的测试,最好的方法是养成运行测试的习惯,或许是临时修改被测试的代码来故意触发一次失败,从而看到所犯的错误以及坏味道。

2.4 [探讨]在项目中进行单元测试

  • 分析:项目中单元测试策略

    在一个项目中,单元测试的策略的制定与执行需要考虑哪些因素?

  • 分析:如何组织单元测试的数据

    在一个项目中,单元测试的数据是否应该以硬编码的形式写入代码中?如果不是的话,需要如何组织这些测试用的数据呢?

  • 分析:谁该为项目的质量负责

    请思考一个问题,一个典型的项目组(包含项目经理、测试、开发和需求分析师)中谁应该为项目的质量负责?

3. 单元测试实践——构建优秀

3.1 在组织中引入单元测试

在一个组织中成功的引入测试驱动开发和单元测试并集成到该组织的文化中,对该组织的发展和团队的效率将会有极大的提升。然后有时候这个引入会失败,成功的组织则存在一些共性的东西,我们在这一节将探讨一下如何增加引入单元测试的成功率。

在任何类型的组织中,改变人们的习惯多半与心理学有关,而并非是技术问题。人们不喜欢变化,而且变化常常伴随着很多的FUD(fear, uncertainty, and doubt——害怕、不确定性和怀疑)。于是如何说服组织成员或者让组织接受新的变化,并不是一件容易和轻松的事情。

  • 怎样成为变革推动者

    开始实施变革之前,人们会开始对它们关心的事情提出棘手的问题,例如这样做会“浪费”多少时间?这对于开发人员来说意味着什么?我们怎么知道它有效呢?这些问题可以尝试用下面的成功的方式进行解决。你会发现,当你能够回答这些问题,并说服组织中的其它人,会对组织的变革提供非常大的帮助。

    这里有一些帮助的小提示:

    • 选择较小的团队
    • 新建子团队
    • 考虑项目的可行性

    此外,在变革中需要找到阻碍者,并寻找到它们不愿意进行单元测试的尝试的原因所在,加以解决。此外可以考虑赋予它们全新的职责,会让它们觉得被依赖而且对组织有意义。

  • 成功之路

    组织或者团队开始改变流程主要有两个方式:自下而上或者自上而下。

    1. 自下而上:先说服程序员,使得程序员采纳并且提倡,然后产生组织的变革,最终说服管理层接受。
    2. 自上而下:经理通过给团队做一个演示来开始实施,或者使用自己的权力进行推动变革。

    代码的完整性,Code Integrity

    这个术语,通常意味着代码做它该做的事,而团队知道代码不能做哪些事。

    代码的完整性包括如下实践:

    • 自动化构建
    • 持续集成
    • 单元测试与测试驱动开发
    • 代码一致性和商定的质量标准
    • 尽量快速的修复缺陷

    为了“我们的代码完整性很好”这个目标,也可以开始如上的实践。

  • 锁定目标

    没有目标,将会很难衡量改变,并且与他人交流。可以考虑下面的目标

    1. 提高代码测试覆盖率
    2. 提高相对代码改动量的测试覆盖率
    3. 减少重复出现的缺陷
    4. 减少修复缺陷的平均时间

3.2 使用 Maven 运行 JUnit 单元测试

  • Maven的功能与安装

    Maven 是一个用于项目构建的工具,通过它便捷的管理项目的生命周期。同时 Maven 不只是一个简单的项目构建工具,还是一个依赖管理工具和项目信息管理工具。它提供了中央仓库,能帮我们自动下载构建。

    在之前的课程中,我们使用 IDEA 工具,通过直接导入 JUnit 的 *.jar 包文件,进行单元测试的构建的。在这里我们继续使用 Maven 作为构建工具,来构建 JUnit 单元测试项目。

    首先,不要相信任何 IDE(Integration Development Environment,集成开发工具)中自带的 Maven 插件,包括 IDEA自带的。那么我们需要安装 Maven。

    具体的安装步骤如下:

    • 检查 JDK 的安装

      在安装 Maven 之前,首先要确认已经正确的安装了 JDK。Maven 可以运行在 JDK 1.4 以及以上的版本。目前的 JDK 1.8 的版本是可以的。需要下载 JDK 并进行安装。安装好 JDK 以后,需要检查 %JAVA_HOME% 的环境变量是否设定。

      输入 cmd | 打开 Windows 命令行, 输入 echo %JAVA_HOME%

      Microsoft Windows [Version 10.0.14393]
      (c) 2016 Microsoft Corporation. All rights reserved.
      
      C:\Users\xxx>echo %JAVA_HOME%
      C:\Program Files\Java\jdk1.8.0_66

    • 下载并安装 Maven

      Maven 可以免费在官网下载并安装。打开 Manve 的下载页面,下载针对所用平台的对应的版本,然后在 C 盘解压即可。

      Maven 的下载地址: https://maven.apache.org/download.cgi

      解压以后,需要设定 Windows 的环境变量。

      1. %M2_HOME%:在系统变量添加,路径为安装的 Maven 的根目录,例如 C:\Apache\apache-maven-3.3.9
      2. path:在系统变量中,找到path,添加上去 ;%M2_HOME%\bin;
      3. 重新打开 Windows 命令行,输入 mvn -version
      Microsoft Windows [Version 10.0.14393]
      (c) 2016 Microsoft Corporation. All rights reserved.
      
      C:\Users\xxx>mvn -version
      Apache Maven 3.3.9 (bb52d8502b132ec0a5a3f4c09453c07478323dc5; 2015-11-11T00:41:47+08:00)
      Maven home: C:\Apache\apache-maven-3.3.9
      Java version: 1.8.0_66, vendor: Oracle Corporation
      Java home: C:\Program Files\Java\jdk1.8.0_66\jre
      Default locale: en_US, platform encoding: GBK
      OS name: "windows 10", version: "10.0", arch: "amd64", family: "dos"

  • 建立一个Maven项目

    使用 IDEA 新建 Maven Project,并添加依赖如下:

    <dependency>
       <groupId>junit</groupId>
       <artifactId>junit</artifactId>
       <version>4.12</version>
       <scope>test</scope>
     </dependency>

    在弹出的浮层中点击“Enable Auto-import”即可。

    然后在 src/main/test/java 文件夹下面可以新建 Java Class 进行测试类的编写。

    将被测试的类 放在 src/main/java 的文件夹下。

  • 使用Maven生成JUnit报告

    Maven 本身并不是一个单元测试框架,能做的只是在构建执行到特定生命周期阶段的时间,通过插件来执行 JUnit 的测试用例。这个插件就是 maven-surefire-plugin,可以称之为测试运行器。

    默认情况下,maven-surefire-plugin 的 test 目标会自动执行测试用例源码路径(默认为 src/main/test/java/)下所有符合一组命名模式的测试类。这组模式为:

    | 模式 | 描述 | | ------------------- | ------------------------------- | | **/Test*.java | 任何子目录下所有命名以 Test 开头的 Java 类 | | **/*Test.java | 任何子目录下所有命名以 Test 结尾的 Java 类 | | **/*TestCase.java | 任何子目录下所有命名以 TestCase 结尾的 Java 类 |

    按照上述描述的模式,添加以下依赖:

    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>2.19.1</version>
      <configuration>
        <skipTests>false</skipTests>
        <source>1.8</source>
        <target>1.8</target>
        <includes>
          <include>**/*Tests.java</include>
          <include>**/*TestCase.java</include>
        </includes>
      </configuration>
    </plugin>

    然后在需要运行的目录中,执行 mvn test ,便可完成测试,并生成报告。默认情况下,maven-surefire-plugin 会在项目的 target/surefire-reports 目录下生成两种格式的错误报告:

    • 简单文本格式
    • 与 JUnit 兼容的 XML 格式

    这样的报告对于获得信息足够了,XML 格式的测试报告主要是为了支持工具的解析,是 Java 单元测试报告的事实标准。

3.3 单元测试框架在自动化测试中的应用

  • 自动化测试的介绍

    当前,软件测试贯穿到整个软件开发生命周期的全过程中,不再停留在编程之后的某个阶段,尤其是敏捷开发开始广泛的应用于互联网行业以后,敏捷测试就把软件测试解释为 对软件产品质量的持续评估 。在敏捷方法中,持续测试被提倡。当前的持续测试的实施,主要依托于持续集成。

    自动化测试:以人为驱动的测试行为转化为机器执行的一种过程

    这里我们使用 Selenium 工具进行自动化测试的应用。

    Selenium is a suite oftools to automate web browsers across many platforms.

    selenium硒, /s'linim/

    Selenium是开源的自动化测试工具,它主要是用于Web 应用程序的自动化测试,不只局限于此,同时支持所有基于web 的管理任务自动化。

    Selenium 是用于测试 Web 应用程序用户界面 (UI) 的常用框架。它是一款用于运行端到端功能测试的超强工具。您可以使用多个编程语言编写测试,并且 Selenium 能够在一个或多个浏览器中执行这些测试。

  • 使用 JUnit + Selenium 进行自动化测试

    接下来我们使用 Junit + Selenium 构建自动化测试

    步骤如下:

    • 安装 Java 和 IDEA

    • 使用 IDEA 创建 Maven Project,并使用如下 pom.xml 文件

      <?xml version="1.0" encoding="UTF-8"?>
      <project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
               xmlns="http://maven.apache.org/POM/4.0.0"
               xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
          <modelVersion>4.0.0</modelVersion>
          <parent>
              <groupId>org.seleniumhq.selenium</groupId>
              <artifactId>selenium-parent</artifactId>
              <version>2.53.1</version>
          </parent>
          <artifactId>selenium-server</artifactId>
          <name>selenium-server</name>
          <dependencies>
              <dependency>
                  <groupId>org.seleniumhq.selenium</groupId>
                  <artifactId>selenium-java</artifactId>
                  <version>${project.version}</version>
              </dependency>
              <dependency>
                  <groupId>org.seleniumhq.selenium</groupId>
                  <artifactId>selenium-remote-driver</artifactId>
                  <version>${project.version}</version>
              </dependency>
              <dependency>
                  <groupId>commons-io</groupId>
                  <artifactId>commons-io</artifactId>
              </dependency>
              <dependency>
                  <groupId>org.apache.commons</groupId>
                  <artifactId>commons-exec</artifactId>
              </dependency>
              <dependency>
                  <groupId>junit</groupId>
                  <artifactId>junit</artifactId>
              </dependency>
              <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-csv -->
              <dependency>
                  <groupId>org.apache.commons</groupId>
                  <artifactId>commons-csv</artifactId>
                  <version>1.4</version>
              </dependency>
              <!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
              <dependency>
                  <groupId>mysql</groupId>
                  <artifactId>mysql-connector-java</artifactId>
                  <version>6.0.3</version>
              </dependency>
          </dependencies>
          <build>
              <plugins>
                  <plugin>
                      <groupId>org.apache.maven.plugins</groupId>
                      <artifactId>maven-surefire-plugin</artifactId>
                      <version>2.19.1</version>
                      <configuration>
                          <skipTests>false</skipTests>
                          <includes>
                              <include>**/*Tests.java</include>
                          </includes>
                      </configuration>
                  </plugin>
              </plugins>
          </build>
      </project>

    • 在 src/test/java 下创建 Java Class 进行编写自动化测试脚本。脚本如下:

      public class RanzhiTestCase{
          // 声明成员变量
          private WebDriver driver;
          private String baseUrl;
      
          @Before
          public void setUp(){
              this.driver = new FirefoxDriver();
              this.baseUrl = "http://localhost:808/ranzhi/www";
      
          @After
          public void tearDown(){
              this.driver.quit();
          }
      
          @Test
          public void testLogIn() {
              // 声明局部变量,传递全局的driver给它进行操作
              WebDriver driver = this.driver;
              // 步骤1
              // 用局部变量driver 打开然之的登录地址
              driver.get(baseUrl);
              // 让java代码停止运行1秒钟,等待浏览器进一步响应
              try {
                  Thread.sleep(1000);
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
      
              // 断言:检查是否打开了正确的登录地址
              Assert.assertEquals("登录页面打开错误",
                      this.baseUrl + "/sys/user-login-L3JhbnpoaS93d3cvc3lzLw==.html",
                      driver.getCurrentUrl());
              // 步骤2
              // 输入用户名 密码 进行登录
              // 输入用户名
              WebElement accountElement = driver.findElement(By.id("account"));
              accountElement.clear();
              accountElement.sendKeys("admin");
              // 输入密码
              WebElement passwordElement = driver.findElement(By.id("password"));
              passwordElement.clear();
              passwordElement.sendKeys("123456");
              // 点击登录按钮
              driver.findElement(By.id("submit")).click();
      
              try {
                  Thread.sleep(2000);
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
              Assert.assertEquals("登录页面登录跳转失败",
                      this.baseUrl + "/sys/index.html",
                      driver.getCurrentUrl());
          }
          }
    • Selenium 推荐的 Page Object 设计模式进行方案设计

      单元测试之旅--预见优秀 软件开发,编程语言,互联网,发明人,覆盖率 【Java】 220702s77mdrxjjrojjgxx 5

3.4 使用 Jenkins 进行持续质量审查

  • 什么持续集成

    持续集成,Continuous integration ,简称CI。

    随着软件开发复杂度的不断提高,团队开发成员间如何更好地协同工作以确保软件开发的质量已经慢慢成为开发过程中不可回避的问题。尤其是近些年来,敏捷(Agile) 在软件工程领域越来越红火,如何能再不断变化的需求中快速适应和保证软件的质量也显得尤其的重要。

    持续集成正是针对这一类问题的一种软件开发实践。首先我们看一下,敏捷教父 Martin Fowler 对持续集成的定义:

    Martin Fowler: Continuous Integration is a software development practice where members of a team integrate their work frequently, usually each person integrates at least daily - leading to multiple integrations per day. Each integration is verified by an automated build (including test) to detect integration errors as quickly as possible. Many teams find that this approach leads to significantly reduced integration problems and allows a team to develop cohesive software more rapidly.

    具体定义:持续集成式一种软件开发实践。它倡导团队的成员必须经常的集成它们的工作,通常至少每天一次甚至更多次集成。每次集成都需要通过自动化的构建(包括编译代码、构建应用、部署程序以及自动化测试)来验证,从而尽早尽快的发现集成中的错误。大量的团队利用这样的方式来更快的开发内聚的软件。大大减少此过程中的集成问题。

    具体的流程图如下: 单元测试之旅--预见优秀 软件开发,编程语言,互联网,发明人,覆盖率 【Java】 220702uvrdmdxddgdm7m25 6

    持续集成强调开发人员提交了新代码之后,立刻进行构建、(单元、自动化)测试。根据测试结果,我们可以确定新代码和原有代码能否正确地集成在一起。

    首先,解释下集成。我们所有项目的代码都是托管在SVN服务器上。每个项目都要有若干个单元测试,并有一个所谓集成测试。所谓集成测试就是把所有的单元测试跑一遍以及其它一些能自动完成的测试。只有在本地电脑上通过了集成测试的代码才能上传到SVN服务器上,保证上传的代码没有问题。所以,集成指集成测试。

    再说持续。不言而喻,就是指长期的对项目代码进行集成测试。既然是长期,那肯定是自动执行的,否则,人工执行则没有保证,而且耗人力。对此,我们有一台服务器,它会定期的从SVN中检出代码,并编译,然后跑集成测试。每次集成测试结果都会记录在案。完成这方面工作的就是下面要介绍的Jenkins软件。当然,它的功能远不止这些。在我们的项目中,执行这个工作的周期是1天。也就是,服务器每1天都会准时地对SVN服务器上的最新代码自动进行一次集成测试。 单元测试之旅--预见优秀 软件开发,编程语言,互联网,发明人,覆盖率 【Java】 220702obj3q5j3d2ma9ja2 7

  • Jenkins环境搭建

    Jenkins,原名Hudson,2011年改为现在的名字,它 是一个开源的实现持续集成的软件工具。

    Hudson是在2004年的夏

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有帐号?注册

x
一起共享资源,共建精品资源平台。记得一定要收藏我们网站。www.gjdhjc.com ||||| 还有我们的网址导航:www.58q8.com【链接失效可以留言看到第一时间补帖,如果懒的回复我也是没办法了】
微信扫一扫,每天教程更新!
想学习最新知识关注“今日教程”微信公众号!站长每天更新教程,每天八条,欢迎关注支持!!!
快速回复 返回顶部 返回列表