`
coolas
  • 浏览: 6521 次
  • 性别: Icon_minigender_1
  • 来自: 杭州
最近访客 更多访客>>
社区版块
存档分类
最新评论

重构一个UT测试(一)[翻译]

阅读更多
    首先想骂一下《XUint 测试模式 -测试码重构》这本书的译者。多好一本书被不负责任的翻译给糟蹋了。
   现在将最前面一部份试着翻译一下,希望能对写好UT有所帮助:
0.1 为什么要重构测试?
测试【这里指测试代码或用例】会迅速成为敏捷开发过程的瓶颈。对于从来没有体会过简单、易于理解的测试代码与复杂、迟钝、难以维护的测试代码之间区别的人来说,这可能不会马上显而易见。生产效率的差别会让人大吃一惊。
本书的这部份会作为全书的一个“激发式例子”,它将给你展示重构测试代码能够带来多大的改变。这个例子将会从一个复杂的测试用例开始,一步步地,将它重构为简单而易懂的测试用例。在这个过程中,我将指出一些关键的坏味(smells)以及用于去除它们的模式。希望能提起你更多的胃口。
0.2 一个复杂的测试用例
这儿有一个我在多个项目到测试用例中经常出现的一类。
public void testAddItemQuantity_severalQuantity_v1(){
      Address billingAddress = null;
      Address shippingAddress = null;
      Customer customer = null;
      Product product = null;
      Invoice invoice = null;
      try {
         //   Set up fixture
         billingAddress = new Address("1222 1st St SW",
               "Calgary", "Alberta", "T2N 2V2","Canada");
         shippingAddress = new Address("1333 1st St SW",
               "Calgary", "Alberta", "T2N 2V2", "Canada");
         customer = new Customer(99, "John", "Doe",
                                 new BigDecimal("30"),
                                 billingAddress,
                                 shippingAddress);
         product = new Product(88, "SomeWidget",
                               new BigDecimal("19.99"));
         invoice = new Invoice(customer);
         // Exercise SUT
         invoice.addItemQuantity(product, 5);
         // Verify outcome
         List lineItems = invoice.getLineItems();
         if (lineItems.size() == 1) {
            LineItem actItem = (LineItem) lineItems.get(0);
            assertEquals("inv", invoice, actItem.getInv());
            assertEquals("prod", product, actItem.getProd());
            assertEquals("quant", 5, actItem.getQuantity());
            assertEquals("discount", new BigDecimal("30"),
                           actItem.getPercentDiscount());
            assertEquals("unit price",new BigDecimal("19.99"),
                              actItem.getUnitPrice());
            assertEquals("extended", new BigDecimal("69.96"),
                           actItem.getExtendedPrice());
         } else {
            assertTrue("Invoice should have 1 item", false);
         }
      } finally {
         // Teardown
         deleteObject(invoice);
         deleteObject(product);
         deleteObject(customer);
         deleteObject(billingAddress);
         deleteObject(shippingAddress);
      }
   }
这个用例有点长,也比应有的情况更加地复杂。这个“晦涩测试”(page186)难以理解,因为测试用例中那么多行代码使得看清全貌很困难。它还有很多其他问题,我们会一一叙述。
0.3 清理测试用例
先让我们来看看测试用例的几个部份。
1) 清理验证逻辑
首先让关注验证预期结果的那部份。我们或许可以从断言去推断这个用例去验证的条件。
       List lineItems = invoice.getLineItems();
         if (lineItems.size() == 1) {
            LineItem actItem = (LineItem) lineItems.get(0);
            assertEquals("inv", invoice, actItem.getInv());
            assertEquals("prod", product, actItem.getProd());
            assertEquals("quant", 5, actItem.getQuantity());
            assertEquals("discount", new BigDecimal("30"),
                           actItem.getPercentDiscount());
            assertEquals("unit price",new BigDecimal("19.99"),
                              actItem.getUnitPrice());
            assertEquals("extended", new BigDecimal("69.96"),
                           actItem.getExtendedPrice());
         } else {
            assertTrue("Invoice should have 1 item", false);
         }
一个需要修复的简单问题就是最后一行那个愚钝的断言。调用带有false参数的assertTrue就是想使得测试断言失败,那么为什么不直接一点呢?将其改为对fail的调用:
      List lineItems = invoice.getLineItems();
      if (lineItems.size() == 1) {
         LineItem actItem = (LineItem) lineItems.get(0);
         assertEquals("inv", invoice, actItem.getInv());
         assertEquals("prod", product, actItem.getProd());
         assertEquals("quant", 5, actItem.getQuantity());
         assertEquals("discount", new BigDecimal("30"),
                        actItem.getPercentDiscount());
         assertEquals("unit price",new BigDecimal("19.99"),
                           actItem.getUnitPrice());
         assertEquals("extended", new BigDecimal("69.96"),
                        actItem.getExtendedPrice());
      } else {
         fail("Invoice should have exactly one line item");
      }
可以把这个改动看成是“方法提取”重构(Fowler),因为我们将一个带硬编码参数的“陈述预期式断言”用一个意图更明确的“单一预期断言”去封装来调用。
当然,这组断言还有更多的问题,比如,我们为什么需要这么多断言?结果是许多断言都在测试通过LineItem的构造函数设置的成员变量,而这些测试应该由其他的单元测试来覆盖。那么为什么要在这里重复这些断言呢?重复断言只会产生更多当逻辑发生变化时需要维护的代码。
我们的解决办法是使用针对“预期对象”的一个简单的断言来代替针对对象每一个字段的断言。首先我们定义一个与预期结果类似的对象。在这个例子中,我们创建一个预期的LineItem对象,它的每个字段都是预期的值,包括通过product初始化好的unitPrice和extenedPrice。
         List lineItems = invoice.getLineItems();
         if (lineItems.size() == 1) {
            LineItem expected =
               new LineItem(invoice, product, 5,
                            new BigDecimal("30"),
                            new BigDecimal("69.96"));
            LineItem actItem = (LineItem) lineItems.get(0);
            assertEquals("invoice", expected.getInv(),
                                    actItem.getInv());
            assertEquals("product", expected.getProd(),
                                    actItem.getProd());
            assertEquals("quantity",expected.getQuantity(),
                                    actItem.getQuantity());
            assertEquals("discount",
                         expected.getPercentDiscount(),
                         actItem.getPercentDiscount());
            assertEquals("unit pr", new BigDecimal("19.99"),
                                    actItem.getUnitPrice());
            assertEquals("extend pr",new BigDecimal("69.96"),
                                     actItem.getExtendedPrice());
         } else {
            fail("Invoice should have exactly one line item");
         }
一旦我们创建了“预期结果对象”,我们就可以使用针对对象的assertEquals:
       List lineItems = invoice.getLineItems();
       if (lineItems.size() == 1) {
          LineItem expected =
             new LineItem(invoice, product,5,
                          new BigDecimal("30"),
                          new BigDecimal("69.96"));
          LineItem actItem = (LineItem) lineItems.get(0);
          assertEquals("invoice", expected, actItem);
       } else {
          fail("Invoice should have exactly one line item");
       }
明显地,“保持对象完整”重构方法【Fowler】使得代码更加简洁。但是!为什么测试用例中会有if?如果一个用例中有多条路径分支,我们怎么知道它到底执行了哪个分支?最好能去掉这种“条件式测试逻辑”。幸运的是,“守卫式断言”这是用来处理此类情况。简单采用“卫语句代替条件表达式”重构方法【Fowler】,用针对相同条件的一个断言来代替if…else fail()语句序列。这种“守卫式断言”会在条件不满足时终止执行,而不用引入“条件式测试逻辑”。
      List lineItems = invoice.getLineItems();
      assertEquals("number of items", 1,lineItems.size());
      LineItem expected =
         new LineItem(invoice, product, 5,
                      new BigDecimal("30"),
                      new BigDecimal("69.96"));
      LineItem actItem = (LineItem) lineItems.get(0);
      assertEquals("invoice", expected, actItem);
{52}至此,我们将11行验证语句缩减到4行,另外,这4行代码也更为简单(and those 4lines are a lot simpler code to boot to boot in addition to everything else you have mentioned)。(注:没有根据我们所写的代码行数来领取报酬,真是件好事啊!这也是为何说KLOC是生产效率糟糕度量的一个例子)。不少人认识这些重构已经足够好了,但是,我们还可以让这些断言更加明了吗?我们真正想验证的到底是什么?我们想说的是,只有一个lineItem,它应该与我们定义的expectedLineItem完全一致。我们可以使用“抽取方法”重构定义一个“自定义断言”来明白地将这种验证给表现出来。
         LineItem expected =
            new LineItem(invoice, product, 5,
                         new BigDecimal("30"),
                         new BigDecimal("69.96"));
         assertContainsExactlyOneLineItem(invoice, expected);
这样好多了!现在,测试用例的验证部份只有两行。当我们将用例完整地再看看:
   public void testAddItemQuantity_severalQuantity_v6(){
      Address billingAddress = null;
      Address shippingAddress = null;
      Customer customer = null;
      Product product = null;
      Invoice invoice = null;
      try {
         //   Set up fixture
         billingAddress = new Address("1222 1st St SW",
                "Calgary", "Alberta", "T2N 2V2", "Canada");
         shippingAddress = new Address("1333 1st St SW",
                "Calgary", "Alberta", "T2N 2V2", "Canada");
         customer = new Customer(99, "John", "Doe",
                                 new BigDecimal("30"),
                                 billingAddress,
                                 shippingAddress);
         product = new Product(88, "SomeWidget",
                               new BigDecimal("19.99"));
         invoice = new Invoice(customer);
         // Exercise SUT
         invoice.addItemQuantity(product, 5);
         // Verify outcome
         LineItem expected =
            new LineItem(invoice, product, 5,
                         new BigDecimal("30"),
                         new BigDecimal("69.96"));
         assertContainsExactlyOneLineItem(invoice, expected);
      } finally {
         // Teardown
         deleteObject(invoice);
         deleteObject(product);
         deleteObject(customer);
         deleteObject(billingAddress);
         deleteObject(shippingAddress);
      }
   }
2) 清理夹具卸载逻辑
我们已经清理了结果验证逻辑,现在将注意力转向测试用例末尾的finally语句块。这些代码是做什么的?
      } finally {
         // Teardown
         deleteObject(invoice);
         deleteObject(product);
         deleteObject(customer);
         deleteObject(billingAddress);
         deleteObject(shippingAddress);
      }
大多数现代语言都有类似的tyr/finally结构,这些结构用来保证即使有错误或者异常发生时,某些代码还是会被执行。在一个“测试方法”中,finally语句块用于保证不管测试用例通过或不通过,那些清理用的代码都会执行到。一个失败断言会抛出一个异常,它会将执行控制交回“测试自动框架”[Test Automatin Framework]的异常处理代码,所以,我们使用finally块先去执行清理操作。这种方式让我们不再需要先捕获异常,再将其重新抛出。
这个测试用例中,finally语句块对测试中创建的每一个对象调用了deleteObject方法。很不幸,代码有一个致命的缺陷,你注意到了吗?
问题出在卸载过程本身。如果第一次调用deleteObject时抛出一个异常,会发生什么?正如代码所示那样,其他deleteObject调用不会被执行。解决方法是使用一个嵌套的try/finally语句将第一个deleteObject给包起来,这样可以保证第二个deleteObject调用总会被执行。但是,如果第二个调用失败了呢?这个例子中,我们需要总共6层的try/finally嵌套行。那将会使得测试用例的长度翻倍,我们不可能在第一个测试用例中编写和维护这么多代码。
      } finally {
         //      Teardown
         try {
            deleteObject(invoice);
         } finally {
            try {
               deleteObject(product);
            } finally {
               try {
                  deleteObject(customer);
               } finally {
                  try {
                     deleteObject(billingAddress);
                  } finally {
                     deleteObject(shippingAddress);
                  }
               }
            }
         }
问题出在我们现在有了一个“复杂卸载”(见含糊测试Obscure Test),要如何使得这段代码变好?我们怎么去测试这些测试代码?很明显,现在的办法并不十分有效。
当然,我们可以将这些代码移到tearDown方法中去,这样就能将它们从“测试方法”中移出。另外,由于tearDown方法的作用类似finally语句块,我们就能摆脱最外层的try/finally。遗憾的是,这个策略并没有触及到问题的根本:需要在每个测试用例中编写细节化的卸载代码。
我们可能通过使用“共享夹具”的方法一开始就避免创建对象,这样就不用在每个用例执行切换过程中去将对象销毁。不过,此方法会带来不少测试坏味,包括“不可重复式测试(Unrepeatable Test)”(见不稳定测试Erratic Test)、由于夹具共享引起交互的“交互式测试(Interacting Test)”。另外,共享夹具中对象的引用常常导致“神秘访客(Mystery Guests)”(见含糊测试)。
最好的解决方案是使用“新鲜夹具”,同时避免为每个用例编写卸载代码。为达到这个目的,我们可以使用能被自动垃圾回收掉的内存中夹具。然而,当创建的对象是持久化时(如,他们被保存到数据库中),这个办法就失效了。(未完。。。。)
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics