使用 Stub 和 Mock 進行單元測試

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

前幾天我和一些客戶在現場,他們問我關於單元測試和 mock 物件的事情。我決定將我們的一些討論寫成一個關於建立單元測試相依性 (協作者) 的教學。 我們討論了兩個選項,stubbing 和 mock 物件,並提供了一些簡單的範例來說明它們的用法,以及這兩種方法的優點和缺點。

在單元測試中,通常會 mock 或 stub 測試目標類別的協作者,以便測試獨立於協作者的實作。 能夠精確控制測試使用的測試資料,並驗證單元是否按預期執行,也是很有用的。

Stubbing

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;
    }
}

stub 協作者所做的只是傳回我們測試所需的值。

常見的做法是將此類 stub 內聯實現為匿名內部類別,例如


public void testActiveWhenCollaboratorIsActive() throws Exception {

    Service service = new SimpleService();
    service.setCollaborator(new Collaborator() {
        public boolean isActive() {
           return true;
        }
    });
    assertTrue(service.isActive());

}

這為我們節省了大量時間來維護作為單獨宣告的 stub 類別,並且還有助於避免 stub 實作的常見陷阱:在單元測試中重複使用 stub,以及專案中具體 stub 數量的爆炸式增長。

這張圖有什麼問題? 好吧,通常像這樣的服務中的協作者介面不像這個簡單的範例那麼簡單,並且內聯實現 stub 需要數十行未在服務中使用的空方法宣告。 此外,如果協作者介面發生變化(例如,新增了一個方法),我們必須手動更改所有測試案例中的所有內聯 stub 實作,這可能需要大量工作。

為了解決這兩個問題,我們從一個基底類別開始,而不是為每個測試案例重新實現介面,我們擴展了一個基底類別。 如果介面發生變化,我們只需要更改基底類別。 通常,基底類別將儲存在我們專案的單元測試目錄中,而不是在生產或主要來源目錄中。

例如,這是為定義的介面設計的合適的基底類別


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 可以自動在所有測試案例中進行名稱或簽章更改。

內聯 stub 方法非常有用並且可以快速實現,但是為了更好地控制測試案例,並確保如果服務物件的實作發生變化,測試案例也會相應地更改,mock 物件方法更好。

Mock 物件

使用 mock 物件(例如,來自 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 的測試將會失敗,並顯示一條明顯的訊息,指出未執行協作者上預期的方法呼叫。 在 stub 實作中,測試可能會也可能不會失敗:如果失敗了,錯誤訊息會很難理解;如果沒有失敗,那將只是偶然的。

為了修正失敗的測試,我們必須修改它以反映服務的內部實作。 不斷重新設計測試案例以反映實作的內部結構被一些人視為一種負擔,但實際上這是單元測試的本質。 我們正在測試單元的實作,而不是它與系統其餘部分的合約。 為了測試合約,我們將使用整合測試,並將服務視為黑盒子,由其介面而不是其實作定義。

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。 如果只有一個協作者,就像這裡一樣,這沒什麼大不了的,但是如果有許多協作者,那麼測試案例就會變得更容易編寫和閱讀。

何時使用 Stub 和 Mock?

如果 mock 物件更勝一籌,為什麼我們還要使用 stub? 這個問題很可能會把我們帶入宗教辯論的領域,我們現在會小心避免這種情況。 所以簡單的答案是,“做適合你的測試案例的事情,並建立最容易閱讀和維護的程式碼”。 如果使用 stub 的測試可以快速編寫和閱讀,並且你不太關心協作者的更改,或者在測試目標單元內部對協作者的使用,那麼這很好。 如果協作者不在你的控制之下(例如,來自第三方程式庫),通常 stub 可能更難編寫。

stubbing 比 mocks 更容易實現(和閱讀)的一個常見案例是,測試目標單元需要使用協作者上的巢狀方法呼叫。 例如,考慮一下如果我們更改我們的服務會發生什麼,因此它不再直接使用協作者的 isActive,而是巢狀呼叫另一個協作者(例如,另一個類別 Task)


public class SimpleService implements Service {

    public boolean isActive() {
        return !collaborator.getTask().isActive();
    }

}

為了在 EasyMock 2 中使用 mock 物件測試這個


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);

相同測試的 stub 實作將是


Service service = new SimpleService();
service.setCollaborator(new StubCollaboratorAdapter() {
    public Task getTask() {
        return (new StubTaskAdapter() {
            public boolean isActive() {
                return true;
            }
        }
    }
});
assertTrue(service.isActive());

就長度而言,兩者之間沒有太大的區別(忽略適配器基底類別中的程式碼,我們可以在其他測試中重複使用)。 mock 版本更強大(原因如上所述),所以我們更喜歡它。 但是如果我們必須使用 EasyMock 1,因為我們無法使用 Java 5,情況可能會有所不同:實現 mock 版本會醜陋得多。 這是它


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();

測試長度增加了一半,相應地更難閱讀和維護。 在現實中,事情很容易變得更糟。 在這種情況下,我們可能會為了輕鬆而考慮 stub 實作。 當然,mock 物件的真正信徒會指出這是一種虛假的節約,並且單元測試在長期內將比使用 stub 的測試更強大和更好。

獲取 Spring 新聞通訊

透過 Spring 新聞通訊保持聯繫

訂閱

領先一步

VMware 提供培訓和認證,以加速您的進度。

了解更多

獲得支援

Tanzu Spring 在一個簡單的訂閱中提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位檔案。

了解更多

即將到來的活動

查看 Spring 社群中所有即將到來的活動。

檢視全部