計劃“寫”一系列關(guān)于測試的文章,這部分文章都會以xUnit開頭,內(nèi)容來自己對《xUnit Test Patterns》一書的摘要和感受
本篇來自 Refactoring a Test 章節(jié),代碼的重構(gòu)已經(jīng)成為了大眾的知識,但是對于測試這個冷門環(huán)節(jié)由于重視不夠所以也很少有言及測試代碼的重構(gòu)。希望本篇能夠通過一次重構(gòu)的歷程給大家?guī)硪淮螌τ跍y試的全新認(rèn)識。
下面先看一下一段測試代碼:
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是發(fā)貨單的意思,可以推測本例意圖是測試放入發(fā)貨單的產(chǎn)品數(shù)量是否正確。
這段糟糕的冗長的測試代碼對于我這樣以前很少寫TestCase(而且寫的很不好)的同學(xué)來說,其實還是有很多值得學(xué)習(xí)的地方。
1、方法命名傳達信息 testAddItemQuantity_severalQuantity_v1 這樣的命名可以很好的表達這段測試代碼的目的
2、注釋 代碼中分別注釋了四個主要環(huán)節(jié) Set up fixture、Exercise SUT、Verify outcome和 Teardown
其次是各種缺點了,下面一一分析。
重構(gòu)
這部分會整理各個環(huán)節(jié)復(fù)雜的代碼片段
驗證部分 - 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那么一定要執(zhí)行失敗,更好的表達方式是這樣的 fail("Invoice should have exactly one line item");,于是有了第一段重構(gòu):
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");
}
注:這種重構(gòu)方式是要方法傳達信息,讓方法更具有自解釋的特性。
這段代碼存在的另一個更大的問題是:使用了過多的 assertEquals 語句,而這些語句的目的是驗證 LineItem 是否被構(gòu)造正確,上面說過這段測試的主要目的是 “測試添加商品的數(shù)量是否正確”,至于測試 “ LineItem 是否被構(gòu)造正確”應(yīng)該放在另一個測試中進行,解決這種問題的一種重構(gòu)方法是 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 提供的默認(rèn)的 equals(obj) 方法使用的是引用比較 return (this == obj); 并不能滿足要求,需要覆蓋這個方法提供自己的實現(xiàn)。另外這里還使用了 Preserve Whole Object 模式,這種模式的好處是當(dāng) LineItem 再添加/減少字段的時候,我們不需要修改任何代碼。
現(xiàn)在代碼已經(jīng)干凈很多,但是還有一條 if 分支語句,分支語句會讓測試代碼的可能執(zhí)行路徑變多,不利于分析,解決這種 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);
現(xiàn)在原來11行代碼已經(jīng)改成4行,而且可讀性提高了很多。并且還可以更近一步把這段代碼抽象成一個方法: assertContainsExactlyOneLineItem(invoice, expected);
清理現(xiàn)場部分 - Teardown
這部分代碼主要用來還原現(xiàn)場,因為測試代碼可能會導(dǎo)致當(dāng)前測試上下文的變化,需要把上下文還原到測試代碼運行前的初始狀態(tài)(比如文件或者數(shù)據(jù)庫操作等):
} finally {
// Teardown
deleteObject(invoice);
deleteObject(product);
deleteObject(customer);
deleteObject(billingAddress);
deleteObject(shippingAddress);
}
這段代碼其實隱藏了一個bug,通常我們會使用 try-finally 語句來保證 finally中的代碼一定會執(zhí)行并在這個時候釋放一些資源。但是如果這些 deleteObject 方法有一個執(zhí)行失敗拋出異常會導(dǎo)致余下的方法無法執(zhí)行。解決辦法可以這樣:
try {
deleteObject(invoice);
} finally {
try {
deleteObject(product);
} finally {
try {
deleteObject(customer);
} finally {
try {
deleteObject(billingAddress);
} finally {
deleteObject(shippingAddress);
}
}
}
}
這套代碼非常復(fù)雜,如果把這段代碼轉(zhuǎn)移到 teardown 方法里面,可以讓測試代碼簡潔一些,但是這并沒有解決一個根本的問題 - 需要給每一個測試方法都寫一個具體的teardown實現(xiàn),同樣也可以在 setup方法里面提前構(gòu)建出需要的對象。這種處理方式是常見的 SharedFixture 模式,但是這種模式存在很多問題,比如同一個提前初始化的對象可能會被不同的TestCase使用并作出一些改變并導(dǎo)致一些莫名其妙的問題,所以應(yīng)該盡量避免 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);
注冊代碼可以這樣實現(xiàn):
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 的問題,同時也省去了銷毀對象的麻煩,現(xiàn)在可以看一看重構(gòu)后的代碼:
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);
}