單元測試中的存根(Stubs)和模擬物件(Mocks)

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

前幾天我在客戶那裡,他們問了我關於單元測試和模擬物件的問題。我決定將我們討論的一些內容寫成一篇關於為單元測試建立依賴(協作者)的教程。我們討論了兩種方案:存根和模擬物件,並給出了一些簡單的例子來**闡明**它們的用法以及兩種方法的優缺點。

在單元測試中,模擬或存根被測類的協作者是很常見的,這樣測試就獨立於協作者的實現。這樣做也很有用,可以精確控制測試中使用的資料,並驗證單元的行為是否符合預期。

存根

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


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 可以自動更改所有測試用例中的名稱或簽名。

內聯存根方法非常有用且實現快速,但為了對測試用例有更多的控制權,並確保如果服務物件的實現發生變化,測試用例也相應變化,模擬物件方法更好。

模擬物件

使用模擬物件(例如來自 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 社群中所有即將舉行的活動。

檢視全部