JUnit 的出現為開發人員帶來了福音。遺憾的是,許多人仍然認為學會 JUnit API,編寫幾個測試,最後得到一個測試良好的應用程序就足夠了。這種想法比不進行任何測試還要糟,因為這會導致對代碼健康狀態的誤解。學習 JUnit 是測試中最容易的一部分。編寫優秀的測試則是較困難的一個環節。本文將介紹一些常見的 JUnit 反模式,並說明如何解決它們。
兩個月前,我和妻子決定在廚房裡裝上木鑲板。這是我第一次裝修房子,我帶著一股盲目樂觀主義精神,使用鐵錘和釘子干起了裝修。但這樣做幾乎是一場災難,因為我用不好鐵錘。最後,妻子不得不重新修整被我敲打得高低不平的鑲板和出現的裂縫。
在裝修臥室時,我認為已學到了一些經驗教訓,這次借來了岳父的氣釘槍。僅用了裝修廚房十分之一的時間,就裝修完了臥室,但是氣釘槍不能彌補我在其他方面的失誤 —— 例如忘記了保持木板頂部的水平,切割木板時切錯了位置,忘記檢查木板,將有裂紋的木板釘了上去,等等。還出現了其他許多問題,這些問題幸好都被細心的妻子注意到了。通過此事,我認識到:氣釘槍不如一個木匠。
JUnit:氣釘槍式測試工具
我認為,JUnit 很像爸爸的氣釘槍。JUnit 出現之前,測試不是不可能的,但是非常困難。事實上,它困難到了致使通常沒有人願意進行測試。即使進行測試,也僅僅是對那些看起來特別復雜或脆弱,以致人們有理由進行額外測試的那部分。
JUnit 就是專門解決此問題的工具。這裡不可告人的秘密是,此現象致使許多編程人員實際上樂於 編寫一些測試。這樣就造成了編程人員編寫測試,而客戶期盼測試的情形。盡管仍有一些堅持者,但多數客戶現在開始傾向於使用我們在測試領域的新霸主 JUnit。(有關熱愛測試的更多信息,請參閱 參考資料)。
問題是,JUnit 不是萬能藥,它是一種名副其實的工具。像其他優秀的工具一樣(JUnit 是最優秀的工具之一),JUnit 只做一件事情,並且能出色地完成,它提供一個用於執行測試的框架。具體表現在:
JUnit 提供一個用於編寫測試的模板,該模板可以安裝、執行和卸載。
它允許您在層次結構中組織測試。
它允許您自動而又方便地執行測試。
它減少了來自執行過程中的測試報告量,允許使用同一測試套件中的不同測試操作程序。
盡管 JUnit 功能強大,使用起來很簡單,但是,它也存在許多不足之處,需要其他工具來填補這些缺陷。以下是 JUnit 無法做到的:
對被測試的單元自動生成測試。
提供覆蓋條件。
編寫了劣質的測試時進行提示。
闡明的觀點
Robert Binder 編寫了一本好書,名稱為 Testing Object-Oriented Systems: Models, Patterns, and Tools。Binder 是一位少有的天才人物 —— 一個測試聖人。作為一本測試方面的參考資料,該書的價值是無法衡量的。Binder 在本書的開頭再次談及 Scott Meyers 的測試問題。這個問題就是為 Triangle 對象編寫單元測試。
Java™ 技術實現了一個采用三個邊長的構造函數。每一邊各有一個 getters 和 setters。該技術實現有三種方法:isIsosceles()、isScalene() 和 isEquilateral(),其中每一種方法都可以返回 true 或 false,具體情況取決於三角形的配置。triangle 還是 Polygon 類型的一個子類, 後者由 Figure 類派生而來。Figure 是代表對象的抽象類,該類可以通過光柵顯示描繪。現在面臨的挑戰是如何編寫此類的測試。
Binder 從 Meyers 的原始程序解決方案中列出了 33 個測試,並提供了 32 個與面向對象的問題屬性有密切關系的測試。所以現在一共有 65 個測試。除非是影響生命安全的重要軟件,否則您可能從來不會如此詳細地測試代碼,也不會了解到原來它是如此測試的。原因不是您有生理缺陷或者懶惰。而是您沒有受過測試方面的訓練,還因為您將專用開發時間都消耗在了編程技巧上,而不是消耗在測試能力上。該怎麼辦呢?JUnit 可讓測試變得簡單易行。
反模式
本部分將介紹幾個反模式,其中的錯誤現象是我們經常遇到的或易犯的。
愉快路徑測試
愉快路徑測試 可以驗證被測系統的行為是否為所期望的行為。它們遵循每個正確的執行路徑。在功能測試中,愉快路徑與實際用例相同或相近。在單元測試中,它與實際用例相同或更小,因為單元服從於“單一職責原則”,您是測試它的單一職責。
實際上,愉快路徑測試並不是一個反模式。反模式是指在進行愉快路徑測試時開發程序的停止行為。愉快路徑不測試系統的錯誤部分(不愉快路徑)。編寫代碼時,通常考慮使用愉快路徑進行編寫。甚至在頭腦中用一些愉快路徑數據對它進行測試。邊界條件將等待未測試的、范圍之外數據,允許它們將您的應用程序帶到其管轄范圍之內。
假設您正在編寫一個包含方法 eval 的 Factorial 類,該方法攜帶 int 並返回該 int 的階乘。一個愉快路徑測試會確認 Factorial.eval(3) 返回的是 6。此代碼的實現不正確,但它仍返回正確的結果(誤報),這種幾率非常小:
public class Factorial {
public int eval(int _num) {
if (_num == 1) { return 1; }
return _num * eval(_num - 1);
}
}
有些人會對此測試感到滿意並繼續操作,但是,請考慮下面這個實現:
public class Factorial {
public int eval(int _num) {
return 6;
}
}
出現誤報(false positive)會怎樣呢?如果您從未接觸過由測試驅動的開發(請參閱 參考資料),那麼您可能也會認為人人都能編寫如此頭腦簡單的實現。測試驅動的開發 (TDD) 中的一個練習就是首先編寫測試,然後執行可能運行的最簡單的操作 —— 如本例中的 return 6。
即使沒有使用 TDD 方法執行操作,並在正確的實現中出現一個錯誤,您仍會得到誤報。請考慮以下實現:
public class Factorial {
public int eval(int _num) {
if (_num == 1) { return 1; }
return _num + eval(_num - 1);
}
}
除了數字的序列是相加的,而不是相乘的之外,這個算法與第一個算法幾乎是相同的,對於值 3 和值 1(恰好出現這樣一個值),返回的值是一樣的,但是,對於其他任何值則會失敗。關鍵是碰巧通過一個測試並不困難。
這就是為什麼一定要進行兩次以上的愉快路徑測試。測試兩次可以明顯地減少一致通過的機率。尤其是測試值是 orthogonal (相互獨立或沒有關系)的情況下。例如,編寫一個值為 3 和 5 的測試,將很就可以看出前面的兩個實現是錯誤的。
確認測試和邊界測試
還需要考慮其他兩個測試類型:validity(或 domain)和 boundary。前者聲明無效數據(或域外數據)的正確行為,後者是愉快路徑測試的一種形式,但它聲明實現在域的邊界上可以正確地運行。
在這個示例中,請考慮在調用 Factorial.eval(-3) 時,將會發生什麼情況。很有可能用盡堆棧空間,造成程序崩潰。當然 -3 不是一個有效的輸入,所以使用它毫無意義。但是,在正確和錯誤之間還有一個中間方法,稱為 IllegalArgumentException,演示如下:
public class Factorial {
public int eval(int _num) {
if (_num < 1) {
throw new IllegalArgumentException(
"Parameter must be greater than 0: " + _num);
}
if (_num == 1) { return 1; }
return _num * eval(_num - 1);
}
}
編寫了階乘代碼後,您可能發現該代碼仍有錯誤。所以,讓我們談一下邊界測試。如果存在一個邊界,那麼輸入參數為 0,這是一個有效的輸入,從數學上說,0 的階乘是 1。執行前面的實現會導致測試失敗,因為您希望的返回值是 1,但得到的卻是 IllegalArgumentException。還應該檢查邊界的另一邊 -1,以驗證可以得到期望的 IllegalArgumentException,而不是一個整數。
對其他邊界的相應測試將留做練習供您操練。提示:如果執行 Factorial.eval(100) 將會發生什麼情況?
簡單測試
與愉快路徑反模式一樣,簡單測試反模式講的不是關於“是什麼”而是“不是 什麼”。若開發人員沒有經驗,並且代碼難以測試,則通常會出現這種症狀。結果,您會看到對容易測試 (equals 和 toString 往往很突出,參見清單 1) 的內容進行多次的測試,而被測單元的真正邏輯卻被忽略了。結果出現了許多不能檢測系統的傳遞測試,這會導致對代碼健康狀態的誤解。
清單 1. 一些容易測試的簽名
testEqualsReflexive()
testEqualsSymmetric()
testEqualsTransitive()
testEqualsOnNullParameter()
testEqualsWorksMoreThanOnce()
testEqualsFailsOnSubclass()
testEqualsIsStillReflexive()
進行系統測試之所以困難,是因為您經常嘗試測試某個方法,而不是檢測某個裝置。假設您要測試一個堆棧的實現,那麼您的測試簽名可能如清單 2 所示。
清單 2. 用於堆棧單元測試的可能測試簽名
testPopHappyPath();
testPopEmptyStack();
testPushHappyPath();
testPushFullStack();
testPeek();
其中有些測試很容易,如清單 3 所示。
清單 3. 用於空堆棧的單元測試
public void testPopEmptyStack() {
Stack stackUT = new Stack();
assertEquals(0, stackUT.getSize());
try {
stackUT.pop();
fail("Expected StackUnderflowException");
} catch (StackUnderflowException _expected) {}
}
但是,如何測試 push 的愉快路徑呢?
清單 4. 用於 stack.push() 的元單測試
public void testPushHappyPath() {
Stack stackUT = new Stack();
Object item = new Object();
stackUT.push(item);
// now what?
}
這是測試單元實現的常見錯誤,而不是單元與其客戶機簽定的契約。假設 push 方法的實現方式如下:
public class Stack {
private List elements;
...
public void push(Object _element) {
elements.add(_element);
}
}
您需要進行這一測試來驗證 elements List 現在是否含有 push 添加的 Object。所以,您要編寫如下測試:
public void testPushHappyPath() {
Stack stackUT = new Stack();
Object expectedElement = new Object();
stackUT.push(expectedElement);
List elements = stackUT.getElementsList();
assertEquals(1, elements.size());
assertEquals(expectedElement, elements.get(0));
}
其中的問題是破壞了封裝,原因是公開了被測單元的內幕。相反,要測試 push 是否將對象放入了列表,您應測試堆棧與客戶機簽定的契約。J.B. Rainsberger 將此稱為測試裝置 (fixture)。
現在,您的測試如清單 5 所示。
清單 5. 用於堆棧裝置的單元測試
public void testPushPop() {
Stack stackUT = new Stack();
Object expectedElement = new Object();
assertEquals(expectedElement, stackUT.push(expectedElement).pop();
assertTrue(stackUT.isEmpty());
}
public void testFILO() {
Stack stackUT = new Stack();
Object expectedOne = new Object();
Object expectedTwo = new Object();
stackUT.push(expectedOne);
stackUT.push(expectedTwo);
assertEquals(expectedTwo, stackUT.pop());
assertEquals(expectedOne, stackUT.pop());
assertTrue(stackUT.isEmpty());
}
您將不會再破壞封裝,原因是您沒有聲明單元在封裝中如何運行。相反,您充分利用了該裝置顯示的嚴密內聚性。擁有可以推動但不能彈出的堆棧沒有任何意義,因此,您可以將這些方法作為堆棧暴露給其客戶機的契約的一部分進行測試。
當編寫代碼時,應考慮到這個契約 —— 您將要編寫的特定內容都將暴露給它的客戶機,無論此內容是一個方法、一個類,還是一個與類交互的組。該契約是您要測試的一個內容,而不是實現細節。以這種形式進行測試將有助於該契約的形式化,使該契約更為明確並能夠通過測試得到很好的定義,而不會處於不確定和非正式狀態。
過度復雜的測試
當測試明顯正確時,該測試通常會成功。如果測試很復雜,以致於不能立即斷定它是否正確,那麼您將無法知道該測試是否因為是錯誤的測試(甚至更糟的是不知道它是否正被錯誤地傳遞)而導致失敗。當被測系統需要一個復雜的設置或暴露需要拆分的復雜數據結構時,通常會出現這種情況。
請考慮這樣一個例子,在這個例子中有一個代碼,該代碼攜帶一些客戶數據並將其寫出,保存到一個有固定記錄的文件中,以便在舊式系統中使用。您大概不會對記錄是否為正確格式的測試感興趣 —— 在這些方面,您已經進行了許多測試。您要測試的是記錄中是否存在正確的數據。在這種情況下,很容易看到如清單 6 所示的測試。
清單 6. 過度復雜的測試
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import junit.framework.TestCase;
public class RecordTest extends TestCase {
public void testRecordContainsCorrectCustomerData() {
// setup
String expectedName = "Estragon";
int expectedId = 1001;
String [] expectedItemNames = {"A man", "A plan", "A canal", "Suez"};
Customer customer = new Customer(expectedId, expectedName, expectedItemNames);
// execute
BillingCenter.processCustomer(customer);
// assert results
File file = new File("customer.rec");
assertTrue(file.exists());
FileInputStream fis = new FileInputStream(file);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte [] buffer = new byte[16];
int numRead;
while ((numRead = fis.read(buffer)) >= 0) {
baos.write(buffer, 0, numRead);
}
byte [] record = baos.toByteArray();
assertEquals(128, record.length); // exactly one record
String actualName = new String(record, 0, 15).trim();
assertEquals(expectedName, actualName);
int [] temp = new int[4];
temp[0] = record[15];
temp[1] = record[16];
temp[2] = record[17];
temp[3] = record[18];
int actualId = (temp[0] << 24) & (temp[1] << 16) & (temp[2] << 8) & temp[3];
assertEquals(expectedId, actualId);
int itemFieldLength = 16;
int itemFieldOffset = 19;
for(int i = 0; i < 4; ++i) {
String actualItemName = new String(record,
itemFieldOffset + itemFieldLength * i, itemFieldLength);
assertEquals(expectedItemNames[i], actualItemName.trim());
}
}
}
唉呀!這裡發生了什麼?除了確認被測系統的正確性外,還可以將測試作為文檔提供。它們應該充當系統正確行為的指示。這個測試的目的是為了顯示:在使用適當填充的 Customer 對象調用 BillingCenter 對象上的靜態 processCustomer 方法時,會導致一個適當格式的記錄被寫入 customer.rec 文件中。但此目標在執行測試所需的所有 I/O 文件、字節轉換文件、字段偏移(field-offsetting)文件中是無法實現的。
測試代碼可能比其要測試的代碼更復雜。我不能保證這個測試是否正確,但是,我在這裡將它寫了出來。我們還要做其他一些事情。讓我們進一步簡化並抽象該測試,使其更像是一個測試(參見清單 7)。
清單 7. 一個簡單的測試
public class RecordTestImproved extends TestCase {
public void testRecordContainsCorrectCustomerData() {
// setup
String expectedName = "Estragon";
int expectedId = 1001;
String [] expectedItemNames = {"A man", "A plan", "A canal", "Suez"};
Customer customer = new Customer(expectedId, expectedName);
// execute
BillingCenter.processCustomer(customer);
// assert results
RecordFileFacade records = new RecordFileFacade("customer.rec");
assertEquals(1, records.getTotalRecords());
RecordFacade record = records.get(0);
assertEquals(expectedName, record.getName());
assertEquals(expectedId, record.getId());
for(int i = 0; i < 4; ++i) {
assertEquals(expectedItemNames[i], record.getItemName(i));
}
}
}
現在,測試代碼清楚地表示出了該測試的意圖。毫無疑問,此測試是正確的,因為它已經完成了設置預期值,調用被測試的系統,調用 getter 和作出聲明。該邏輯被應用到了 RecordFileFacade 和 RecordFacade 類中。RecordFileFacade 負責從文件中讀取數據,並成批將它們送入記錄中。RecordFacade 負責解析每條記錄,並通過 Java 語言友好測試方法公開這些這些數據。這個測試的另一個優點是 RecordFileFacade 和 RecordFacade 現在也能夠測試。當拆分記錄的邏輯保存在該測試中時,將無法對其進行測試。
最好將該邏輯應用到基礎結構中。一個優秀的測試程序應當滿足以下條件:
設置
聲明預期結果
練習被測試的單元
獲得實際結果
聲明實際結果是否與預期結果相符
一個測試良好的應用程序不僅僅包含應用程序代碼和測試。一定數量的基礎結構代碼可以充當測試程序與被測系統之間的適配器。此用途有兩個:其一,可以允許測試清楚地表示其意圖,其二,通過將復雜的代碼抽象到獨立層中,還能夠為該層編寫測試。
結束語
在許多方法中,使用 JUnit 進行測試更方便。測試編寫代碼越來越趨向於進行壞的測試和好的測試。但是,1,000 個壞的傳遞測試比不進行試測更糟糕,因為壞的測試會給您一個錯誤的自信意識。
編寫測試時,一定要注意所編寫測試的質量:
不要僅測試愉快路徑,還要測試邊界條件和范圍之外的值。
不要測試實現,而是要測試裝置。
不要使您的測試代碼比被測代碼更復雜。
總之,要通過不懈的努力來擴展您的測試技巧,使之成為專業開發的一部分。在測試工作方面,不要將全部精力都用在編程技巧上。