首先想骂一下《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)”(见含糊测试)。
最好的解决方案是使用“新鲜夹具”,同时避免为每个用例编写卸载代码。为达到这个目的,我们可以使用能被自动垃圾回收掉的内存中夹具。然而,当创建的对象是持久化时(如,他们被保存到数据库中),这个办法就失效了。(未完。。。。)
现在将最前面一部份试着翻译一下,希望能对写好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)”(见含糊测试)。
最好的解决方案是使用“新鲜夹具”,同时避免为每个用例编写卸载代码。为达到这个目的,我们可以使用能被自动垃圾回收掉的内存中夹具。然而,当创建的对象是持久化时(如,他们被保存到数据库中),这个办法就失效了。(未完。。。。)