領先一步
VMware 提供培訓和認證,助您加速進步。
瞭解更多前幾天我與一些客戶在一起,他們問我關於單元測試和模擬物件的問題。我決定將我們討論的一些內容寫成一篇關於為單元測試建立依賴項(協作者)的教程。我們討論了兩種選擇,存根和模擬物件,並給出了一些簡單的例子來說明兩者的用法、優點和缺點。
在單元測試中,為了使測試獨立於被測類的協作者的實現,通常會模擬(mock)或存根(stub)協作者。能夠精確控制測試中使用的資料,並驗證單元是否按預期工作,也是一項有用的能力。
存根方法易於使用,並且不為單元測試引入額外的依賴。基本技術是實現協作者的具體類,這些類僅表現出協作者的整體行為中被測試類所需的小部分。例如,考慮一個服務實現正在被測試的情況。該實現有一個協作者。
public class SimpleService implements Service {
private Collaborator collaborator;
public void setCollaborator(Collaborator collaborator) {
this.collaborator = collaborator;
}
// part of Service interface
public boolean isActive() {
return collaborator.isActive();
}
}
為了測試 isActive 的實現,我們可能會有一個單元測試,如下所示:
public void testActiveWhenCollaboratorIsActive() throws Exception {
Service service = new SimpleService();
service.setCollaborator(new StubCollaborator());
assertTrue(service.isActive());
}
...
class StubCollaborator implements Collaborator {
public boolean isActive() {
return true;
}
}
存根協作者所做的不過是返回測試所需的值。
通常可以看到此類存根以內聯匿名內部類形式實現,例如:
public void testActiveWhenCollaboratorIsActive() throws Exception {
Service service = new SimpleService();
service.setCollaborator(new Collaborator() {
public boolean isActive() {
return true;
}
});
assertTrue(service.isActive());
}
這為我們節省了大量維護獨立宣告的存根類的時間,同時也有助於避免存根實現中的常見陷阱:在單元測試之間重用存根,以及專案中具體存根的數量爆炸式增長。
這張圖有什麼問題?嗯,通常服務中的協作者介面不像這個簡單示例那樣簡單,而內聯實現存根需要數十行對服務中未使用的空方法宣告。此外,如果協作者介面發生更改(例如,添加了一個方法),我們必須手動更改所有測試用例中的內聯存根實現,這可能會是大量的工作。
為了解決這兩個問題,我們從一個基類開始,而不是為每個測試用例重新實現介面,而是擴充套件一個基類。如果介面發生更改,我們只需更改基類。通常,基類將儲存在專案中的單元測試目錄中,而不是在生產或主源目錄中。
例如,這是為定義的介面編寫的合適基類:
public class StubCollaboratorAdapter implements Collaborator {
public boolean isActive() {
return false;
}
}
這是新的測試用例:
public void testActiveWhenCollaboratorIsActive() throws Exception {
Service service = new SimpleService();
service.setCollaborator(new StubCollaboratorAdapter() {
public boolean isActive() {
return true;
}
});
assertTrue(service.isActive());
}
現在,測試用例與不影響 isActive 方法的協作者介面更改隔離開來。事實上,使用 IDE,它也將與一些影響 isActive 方法的介面更改隔離開來——例如,IDE 可以自動在所有測試用例中進行名稱或簽名更改。
內聯存根方法非常有用且實現快捷,但為了更精確地控制測試用例,並確保如果服務物件的實現發生變化,測試用例也相應地發生變化,那麼模擬物件(mock object)的方法更好。
使用模擬物件(例如,來自 EasyMock 或 JMock)我們可以對被測單元的內部實現進行高度控制的測試。
為了在實踐中看到這一點,請考慮上面的示例,並使用 EasyMock 重寫。首先,我們看 EasyMock 1(即不利用 EasyMock 2 中的 Java 5 擴充套件)。測試用例如下所示:
MockControl control = MockControl.createControl(Collaborator.class);
Collaborator collaborator = (Collaborator) control.getMock();
control.expectAndReturn(collaborator.isActive(), true);
control.replay();
service.setCollaborator(collaborator);
assertTrue(service.isActive());
control.verify();
如果實現更改為以不同的方式使用協作者,那麼單元測試將立即失敗,向開發人員發出訊號,表明需要對其進行重寫。假設服務的內部發生了變化,不再使用協作者:
public class SimpleService implements Service {
...
public boolean isActive() {
return calculateActive();
}
}
使用 EasyMock 的上述測試將因一條顯而易見的錯誤訊息而失敗,該訊息指出未執行協作者上的預期方法呼叫。在存根實現中,測試可能失敗,也可能不失敗:如果失敗,錯誤訊息將是晦澀難懂的;如果不失敗,那麼這僅僅是偶然的。
要修復失敗的測試,我們必須修改它以反映服務的內部實現。一些人認為,為了反映實現細節而不斷重寫測試用例是一種負擔,但實際上,單元測試的本質就是必須這樣做。我們測試的是單元的實現,而不是它與系統的其他部分的契約。要測試契約,我們將使用整合測試,並將服務視為一個黑盒,由其介面而不是其實現來定義。
請注意,如果我們使用 Java 5 和 EasyMock 2,上述測試用例的實現可以得到簡化。
Collaborator collaborator = EasyMock.createMock(Collaborator.class);
EasyMock.expect(collaborator.isActive()).andReturn(true);
EasyMock.replay(collaborator);
service.setCollaborator(collaborator);
assertTrue(service.isActive());
EasyMock.verify(collaborator);
新的測試用例不再需要 MockControl。如果只有一個協作者,如本例所示,這沒什麼大不了的,但如果有多個協作者,那麼測試用例將變得更容易編寫和閱讀。
如果模擬物件更優越,為什麼我們還要使用存根呢?這個問題很可能會將我們引入宗教辯論的領域,我們現在會小心地避免。所以簡單的答案是,“做適合你的測試用例的事情,並建立最易於閱讀和維護的程式碼”。如果使用存根的測試易於編寫和閱讀,並且你不太關心協作者的變化,或者被測單元內部對協作者的使用,那麼這樣就可以了。如果協作者不在你的控制之下(例如,來自第三方庫),那麼編寫存根通常會更困難。
存根比模擬物件更容易實現(和閱讀)的一個常見場景是,被測單元需要對協作者進行巢狀方法呼叫。例如,考慮一下我們如何更改服務,使其不再直接使用協作者的 isActive,而是巢狀呼叫另一個協作者(來自不同的類,例如 Task):
public class SimpleService implements Service {
public boolean isActive() {
return !collaborator.getTask().isActive();
}
}
使用 EasyMock 2 中的模擬物件來測試這一點:
Collaborator collaborator = EasyMock.createMock(Collaborator.class);
Task task = EasyMock.createMock(Task.class);
EasyMock.expect(collaborator.getTask()).andReturn(task);
EasyMock.expect(task.isActive()).andReturn(true);
EasyMock.replay(collaborator, task);
service.setCollaborator(collaborator);
assertTrue(service.isActive());
EasyMock.verify(collaborator, task);
同一測試的存根實現將是:
Service service = new SimpleService();
service.setCollaborator(new StubCollaboratorAdapter() {
public Task getTask() {
return (new StubTaskAdapter() {
public boolean isActive() {
return true;
}
}
}
});
assertTrue(service.isActive());
從程式碼長度上看,兩者差別不大(忽略介面卡基類中的程式碼,這些程式碼可以在其他測試中重用)。模擬版本更健壯(原因如上所述),所以我們更傾向於它。但如果我們因為無法使用 Java 5 而不得不使用 EasyMock 1,情況可能會有所不同:實現模擬版本會非常醜陋。
MockControl controlCollaborator = MockControl.createControl(Collaborator.class);
Collaborator collaborator = (Collaborator) controlCollaborator.getMock();
MockControl controlTask = MockControl.createControl(Task.class);
Task task = (Task) controlTask.getMock();
controlCollaborator.expectAndReturn(collaborator.getTask(), task);
controlTask.expectAndReturn(task.isActive(), true);
controlTask.replay();
controlCollaborator.replay();
service.setCollaborator(collaborator);
assertTrue(service.isActive());
controlCollaborator.verify();
controlTask.verify();
測試的長度增加了近一半,相應地也更難閱讀和維護。在實際情況中,事情很容易變得更糟。在這種情況下,為了省事,我們可能會考慮存根實現。當然,模擬物件的忠實信徒會指出,這是一種虛假的經濟,而單元測試將比使用存根的測試更健壯,對長期發展更好。