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

发表于:2018-2-13 10:25

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

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

  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。
  类之间的关系如下:
  car.png
  加速执行测试
  由于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){
            // 没有问题
      }
  }
  但是其实我们还在半路上,一些检查条件是命令行参数的显然结果,另一些是隐含的默认值。从这个角度改进,我们将测试分解成多个测试类。如下图所示:
  人格分裂方案.png
  这次重构意味着有一个测试关注于验证正确的默认值,另一个测试类验证显示设置的命令行值能正确工作,第三个指出应当如何处理错误的配置项。代码如下:
  ●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());
    }
  }


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

关注51Testing

联系我们

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

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

沪ICP备05003035号

沪公网安备 31010102002173号