使用存根和模擬進行單元測試

工程 | Dave Syer | 2007 年 1 月 15 日 | ...

前幾天我與一些客戶在一起,他們問我關於單元測試和模擬物件的問題。我決定將我們討論的一些內容寫成一篇關於為單元測試建立依賴項(協作者)的教程。我們討論了兩種選擇,存根和模擬物件,並給出了一些簡單的例子來說明兩者的用法、優點和缺點。

在單元測試中,為了使測試獨立於被測類的協作者的實現,通常會模擬(mock)或存根(stub)協作者。能夠精確控制測試中使用的資料,並驗證單元是否按預期工作,也是一項有用的能力。

存根(Stubbing)

存根方法易於使用,並且不為單元測試引入額外的依賴。基本技術是實現協作者的具體類,這些類僅表現出協作者的整體行為中被測試類所需的小部分。例如,考慮一個服務實現正在被測試的情況。該實現有一個協作者。


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)的方法更好。

模擬物件(Mock Objects)

使用模擬物件(例如,來自 EasyMockJMock)我們可以對被測單元的內部實現進行高度控制的測試。

為了在實踐中看到這一點,請考慮上面的示例,並使用 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 的上述測試將因一條顯而易見的錯誤訊息而失敗,該訊息指出未執行協作者上的預期方法呼叫。在存根實現中,測試可能失敗,也可能不失敗:如果失敗,錯誤訊息將是晦澀難懂的;如果不失敗,那麼這僅僅是偶然的。

要修復失敗的測試,我們必須修改它以反映服務的內部實現。一些人認為,為了反映實現細節而不斷重寫測試用例是一種負擔,但實際上,單元測試的本質就是必須這樣做。我們測試的是單元的實現,而不是它與系統的其他部分的契約。要測試契約,我們將使用整合測試,並將服務視為一個黑盒,由其介面而不是其實現來定義。

EasyMock 2

請注意,如果我們使用 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();

測試的長度增加了近一半,相應地也更難閱讀和維護。在實際情況中,事情很容易變得更糟。在這種情況下,為了省事,我們可能會考慮存根實現。當然,模擬物件的忠實信徒會指出,這是一種虛假的經濟,而單元測試將比使用存根的測試更健壯,對長期發展更好。

獲取 Spring 新聞通訊

透過 Spring 新聞通訊保持聯絡

訂閱

領先一步

VMware 提供培訓和認證,助您加速進步。

瞭解更多

獲得支援

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位制檔案,只需一份簡單的訂閱。

瞭解更多

即將舉行的活動

檢視 Spring 社群所有即將舉行的活動。

檢視所有