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

发表于:2018-2-12 09:49

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:凌俣画师Linty    来源:51Testing软件测试网采编

  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-TRIP 彻底的(Thorough)
  好的单元测试应该是彻底的,它们测试了所有可能会出现问题的情况。一个极端是每行代码、代码可能每一个分支、每一个可能抛出的异常等等,都作为测试对象。另一个极端是仅仅测试最可能的情形——边界条件、残缺和畸形的数据等等。事实上这是一个项目层面的决策问题。
  另外请注意:Bug往往集中的出现在代码的某块区域中,而不是均匀的分布在代码的每块区域中的。对于这种现象,业内引出了一个著名的战斗口号“不要修修补补,完全重写!”。一般情况下,完全抛弃一块Bug很多的代码块,并进行重写会令开销更小,痛苦更少。
  总之,单元测试越多,代码问题越少。
  A-TRIP 可重复(Repeatable)
  每一个测试必须可以重复的,多次执行,并且结果只能有一个。这样说明,测试的目标只有一个,就是测试应该能够以任意的的顺序一次又一次的执行,并且产生相同的结果。意味着,测试不能依赖不受控制的任何外部因素。这个话题引出了“测试替身”的概念,必要的时候,需要用测试替身来隔离所有的外界因素。
  如果每次测试执行不能产生相同的结果,那么真相只有一个:代码中有真正的Bug。
  A-TRIP 独立的(Independent)
  测试应该是简洁而且精炼的,这意味着每个测试都应该有强的针对性,并且独立于其它测试和环境。请记住,这些测试,可能在同一时间点,被多个开发人员运行。那么在编写测试的时候,确保一次只测试了一样东西。
  独立的,意味着你可以在任何时间以任何顺序运行任何测试。每一个测试都应该是一个孤岛。
  A-TRIP 专业的(Professional)
  测试代码需要是专业的。意味着,在多次编写测试的时候,需要注意抽取相同的代码逻辑,进行封装设计。这样的做法是可行的,而且需要得到鼓励。
  测试代码,是真实的代码。在必要的时候,需要创建一个框架进行测试。测试的代码应该和产品的代码量大体相当。所以测试代码需要保持专业,有良好的设计。
  ●生产力的因素
  这里我们讨论生产力的问题。
  当单元测试越来越多的时候,团队的测试覆盖率会快速的提高,不用再花费时间修复过去的错误,待修复缺陷的总数在下降。测试开始清晰可见的影响团队工作的质量。但是当测试覆盖率不断提高的时候,我们是否要追求100%的测试覆盖率呢?
  事实上,那些确实的测试,不会给团队带来更多价值,花费更多精力来编写测试不会带来额外的收益。很多测试未覆盖到的代码,在项目中事实上也没有用到。何必测试那些空的方法呢?同时,100%的覆盖率并不能确保没有缺陷——它只能保证你所有的代码都执行了,不论程序的行为是否满足要求,与其追求代码覆盖率,不如将重点关注在确保写出有意义的测试。
  当团队已经达到稳定水平——曲线的平坦部分显示出额外投资的收益递减。测试越多,额外测试的价值越少。第一个测试最有可能是针对代码最重要的区域,因此带来高价值与高风险。当我们为几乎所有事情编写测试后,那些仍然没有测试覆盖的地方,很可能是最不重要和最不可能破坏的。
  接下来分析一个测试因素影响的图:
  编排.png
  事实上,大多数代码将测试作为质量工具,沿着曲线停滞了。从这里看,我们需要找出影响程序员生产力的因素。本质上,测试代码的重复和多余的复杂性会降低生产力,抵消测试带来的正面影响。最直接的两个影响生产力的因素:反馈环长度和调试。这两者是在键盘上消耗程序员时间的罪魁祸首。如果在错误发生后迅速学习,那么花在调试上的时间是可以大幅避免的返工——同时,反馈环越长,花在调试上的时间越多。
  等待对变更进行确认和验证,在很大程度上牵扯到测试执行的速度,这个是上述强调的反馈环长度和调试时间的根本原因之一。另外三个根本原因会影响程序员的调试量。
  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[i]) {
        temp = list[i];
      }
    }
    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[i]) {
                 temp = list[i];
             }
         }
         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 方法。
  一般来说,一个测试方法包括了多个断言。当其中一个断言失败的时候,整个测试方法将会被终止——从而导致该方法中剩下的断言将会无法执行了。此时,不能有别的想法,只能先修复当前失败的断言,以此类推,不断地修复当前失败的断言,通过一个个测试,慢慢前行。

上文内容不用于商业目的,如涉及知识产权问题,请权利人联系博为峰小编(021-64471599-8017),我们将立即处理。
21/212>
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号