计划“写”一系列关于测试的文章,这部分文章都会以xUnit开头,内容来自己对《xUnit Test Patterns》一书的摘要和感受 本篇来自 Refactoring a Test 章节,代码的重构已经成为了大众的知识,但是对于测试这个冷门环节由于重视不够所以也很少有言及测试代码的重构。希望本篇能够通过一次重构的历程给大家带来一次对于测试的全新认识。 下面先看一下一段测试代码: 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); } } 注:Invoice是发货单的意思,可以推测本例意图是测试放入发货单的产品数量是否正确。 这段糟糕的冗长的测试代码对于我这样以前很少写TestCase(而且写的很不好)的同学来说,其实还是有很多值得学习的地方。 1、方法命名传达信息 testAddItemQuantity_severalQuantity_v1 这样的命名可以很好的表达这段测试代码的目的 2、注释 代码中分别注释了四个主要环节 Set up fixture、Exercise SUT、Verify outcome和 Teardown 其次是各种缺点了,下面一一分析。 重构 这部分会整理各个环节复杂的代码片段 验证部分 - 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); } 后一个语句 assertTrue("Invoice should have 1 item", false); 一定会失败,这句话的意思是在说如果 lineItems.size() 不等于1那么一定要执行失败,更好的表达方式是这样的 fail("Invoice should have exactly one line item");,于是有了第一段重构: 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"); } 注:这种重构方式是要方法传达信息,让方法更具有自解释的特性。 这段代码存在的另一个更大的问题是:使用了过多的 assertEquals 语句,而这些语句的目的是验证 LineItem 是否被构造正确,上面说过这段测试的主要目的是 “测试添加商品的数量是否正确”,至于测试 “ LineItem 是否被构造正确”应该放在另一个测试中进行,解决这种问题的一种重构方法是 Expected Object ,顾名思义可以定义一个期望的对象和从lineItems.get(0)取出来的对象做比较,这个对象拥有需要比较的所有字段,有了这个对象之后可以使用 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"); } 注:Object 提供的默认的 equals(obj) 方法使用的是引用比较 return (this == obj); 并不能满足要求,需要覆盖这个方法提供自己的实现。另外这里还使用了 Preserve Whole Object 模式,这种模式的好处是当 LineItem 再添加/减少字段的时候,我们不需要修改任何代码。 现在代码已经干净很多,但是还有一条 if 分支语句,分支语句会让测试代码的可能执行路径变多,不利于分析,解决这种 ConditionalTest Logic 的办法是使用 Guard Assertion ,可以很好的解决if分支问题: List lineItems = invoice.getLineItems(); // guard assert 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); 现在原来11行代码已经改成4行,而且可读性提高了很多。并且还可以更近一步把这段代码抽象成一个方法: assertContainsExactlyOneLineItem(invoice, expected); 清理现场部分 - Teardown 这部分代码主要用来还原现场,因为测试代码可能会导致当前测试上下文的变化,需要把上下文还原到测试代码运行前的初始状态(比如文件或者数据库操作等): } finally { // Teardown deleteObject(invoice); deleteObject(product); deleteObject(customer); deleteObject(billingAddress); deleteObject(shippingAddress); } 这段代码其实隐藏了一个bug,通常我们会使用 try-finally 语句来保证 finally中的代码一定会执行并在这个时候释放一些资源。但是如果这些 deleteObject 方法有一个执行失败抛出异常会导致余下的方法无法执行。解决办法可以这样: try { deleteObject(invoice); } finally { try { deleteObject(product); } finally { try { deleteObject(customer); } finally { try { deleteObject(billingAddress); } finally { deleteObject(shippingAddress); } } } } 这套代码非常复杂,如果把这段代码转移到 teardown 方法里面,可以让测试代码简洁一些,但是这并没有解决一个根本的问题 - 需要给每一个测试方法都写一个具体的teardown实现,同样也可以在 setup方法里面提前构建出需要的对象。这种处理方式是常见的 SharedFixture 模式,但是这种模式存在很多问题,比如同一个提前初始化的对象可能会被不同的TestCase使用并作出一些改变并导致一些莫名其妙的问题,所以应该尽量避免 SharedFixture 而是每次建立新的Fixture即使用 Fresh Fixture 模式,但是还需要避免给每个测试写具体的 teardown代码。 解决这个问题可以把每个TestCase中生成的对象注册到框架中,并在teardown方法中销毁所有注册的对象。 首先,在每个TestCase中注册对象: // Set up fixture billingAddress = new Address("1222 1st St SW", "Calgary", "Alberta", "T2N 2V2", "Canada"); registerTestObject(billingAddress); shippingAddress = new Address("1333 1st St SW", "Calgary", "Alberta","T2N 2V2", "Canada"); registerTestObject(shippingAddress); customer = new Customer(99, "John", "Doe", new BigDecimal("30"), billingAddress, shippingAddress); registerTestObject(shippingAddress); product = new Product(88, "SomeWidget", new BigDecimal("19.99")); registerTestObject(shippingAddress); invoice = new Invoice(customer); registerTestObject(shippingAddress); 注册代码可以这样实现: List testObjects; protected void setUp() throws Exception { super.setUp(); testObjects = new ArrayList(); } protected void registerTestObject(Object testObject) { testObjects.add(testObject); } 然后在 teardown 方法中集中销毁所有的对象: public void tearDown() { Iterator i = testObjects.iterator(); while (i.hasNext()) { try { deleteObject(i.next()); } catch (RuntimeException e) { // Nothing to do; we just want to make sure // we continue on to the next object in the list } } } 这种方式避免了 SharedFixture 的问题,同时也省去了销毁对象的麻烦,现在可以看一看重构后的代码: public void testAddItemQuantity_severalQuantity_v8(){ Address billingAddress = null; Address shippingAddress = null; Customer customer = null; Product product = null; Invoice invoice = null; // Set up fixture billingAddress = new Address("1222 1st St SW", "Calgary", "Alberta", "T2N 2V2", "Canada"); registerTestObject(billingAddress); shippingAddress = new Address("1333 1st St SW", "Calgary", "Alberta","T2N 2V2", "Canada"); registerTestObject(shippingAddress); customer = new Customer(99, "John", "Doe", new BigDecimal("30"), billingAddress, shippingAddress); registerTestObject(shippingAddress); product = new Product(88, "SomeWidget", new BigDecimal("19.99")); registerTestObject(shippingAddress); invoice = new Invoice(customer); registerTestObject(shippingAddress); // 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); }