1. 程式人生 > >Mockito:一個強大的用於Java開發的模擬測試框架

Mockito:一個強大的用於Java開發的模擬測試框架

為什麼需要模擬? 

  在我們一開始學程式設計時,我們所寫的物件通常都是獨立的。hello world之類的類並不依賴其他的類(System.out除外),也不會操作別的類。但實際上軟體中是充滿依賴關係的。我們會基於service類寫操作類,而service類又是基於資料訪問類(DAOs)的,依次下去。

圖1 類的依賴關係

  單元測試的思路就是我們想在不涉及依賴關係的情況下測試程式碼。這種測試可以讓你無視程式碼的依賴關係去測試程式碼的有效性。核心思想就是如果程式碼按設計正常工作,並且依賴關係也正常,那麼他們應該會同時工作正常。

  下面的程式碼就是這樣的例子:

import java.util.ArrayList;
public class Counter {
     public Counter() {
     }
     public int count(ArrayList items) {
          int results = 0;
          for(Object curItem : items) {
               results ++;
          }
          return results;
     }
} 
  如你所見,上面的例子十分簡單,但它闡明瞭要點。當你想要測試count方法時,你會針對count方法本身如何工作去寫測試程式碼。你不會去測試ArrayList是否正常工作,因為你預設它已經被測過並且工作正常。你唯一的目標就是測試對ArrayList的使用。
  模擬物件的概念就是我們想要建立一個可以替代實際物件的物件。這個模擬物件要可以通過特定引數呼叫特定的方法,並且能返回預期結果。

模擬有哪些關鍵點?

  在談到模擬時,你只需關心三樣東西:設定測試資料,設定預期結果,驗證結果。一些單元測試方案根本就不涉及這些,有的只涉及設定測試資料,有的只涉及設定預期結果和驗證。

Stubbing (樁)

  Stubbing就是告訴fake當與之互動時執行何種行為過程。通常它可以用來提供那些測試所需的公共屬性(像getters和setters)和公共方法。

  當談到stubbing方法,通常你有一系列的選擇。或許你希望返回一個特殊的值,丟擲一個錯誤或者觸發一個事件,此外,你可能希望指出方法被呼叫時的不同行為(即通過傳遞匹配的型別或者引數給方法)。

  這咋一聽起來工作量很大,但通常並非這樣。許多mocking框架的一個重要功能就是你不需要提供stub 的實體方法,也不用在執行測試期間stub那些未被呼叫的方法或者未使用的屬性。

設定預期

  Fake的一個關鍵的特性就是當你用它進行模擬測試時你能夠告訴它你預期的結果。

例如,你可以要求一個特定的函式被準確的呼叫3次,或不被呼叫,或呼叫至少兩次但不超過5次,或者需要滿足特定型別的引數、特定值和以上任意的組合的呼叫。可能性是無窮的。

  通過設定預期結果告訴fake你期望發生的事情。因為它是一個模擬測試,所以實際上什麼也沒發生。但是,對於被測試的類來說,它並無法區分這種情況。所以fake能夠呼叫函式並讓它做它該做的。

  值得注意的是,大多數模擬框架除了可以建立介面的模擬測試外,還可以建立公有類的模擬測試。

驗證預期結果

  設定預期和驗證預期是同時進行的。設定預期在呼叫測試類的函式之前完成,驗證預期則在它之後。所以,首先你設定好預期結果,然後去驗證你的預期結果是否正確。

  在一個單元測試中,如果你設定的預期沒有得到滿足,那麼這個單元測試就是失敗了。例如,你設定預期結果是 ILoginService.login函式必須用特定的使用者名稱和密碼被呼叫一次,但是在測試中它並沒有被呼叫,這個fake沒被驗證,所以測試失敗。


模擬的好處是什麼? 

   提前建立測試; TDD(測試驅動開發)  

  這是個最大的好處吧。如果你建立了一個Mock那麼你就可以在service介面建立之前寫Service Tests了,這樣你就能在開發過程中把測試新增到你的自動化測試環境中了。換句話說,模擬使你能夠使用測試驅動開發。 

   團隊可以並行工作 

  這類似於上面的那點;為不存在的程式碼建立測試。但前面講的是開發人員編寫測試程式,這裡說的是測試團隊來建立。當還沒有任何東西要測的時候測試團隊如何來建立測試呢?模擬並針對模擬測試!這意味著當service藉口需要測試時,實際上QA團隊已經有了一套完整的測試元件;沒有出現一個團隊等待另一個團隊完成的情況。這使得模擬的效益型尤為突出了。 

   你可以建立一個驗證或者演示程式。 

  由於Mocks非常高效,Mocks可以用來建立一個概念證明,作為一個示意圖,或者作為一個你正考慮構建專案的演示程式。這為你決定專案接下來是否要進行提供了有力的基礎,但最重要的還是提供了實際的設計決策。 

  為無法訪問的資源編寫測試 

  這個好處不屬於實際效益的一種,而是作為一個必要時的“救生圈”。有沒有遇到這樣的情況?當你想要測試一個service介面,但service需要經過防火牆訪問,防火牆不能為你開啟或者你需要認證才能訪問。遇到這樣情況時,你可以在你能訪問的地方使用MockService替代,這就是一個“救生圈”功能。 

  Mock 可以分發給使用者 

      在有些情況下,某種原因你需要允許一些外部來源訪問你的測試系統,像合作伙伴或者客戶。這些原因導致別人也可以訪問你的敏感資訊,而你或許只是想允許訪問部分測試環境。在這種情況下,如何向合作伙伴或者客戶提供一個測試系統來開發或者做測試呢?最簡單的就是提供一個mock,無論是來自於你的網路或者客戶的網路。soapUI mock非常容易配置,他可以執行在soapUI或者作為一個war包釋出到你的java伺服器裡面。 

  隔離系統 

      有時,你希望在沒有系統其他部分的影響下測試系統單獨的一部分。由於其他系統部分會給測試資料造成干擾,影響根據資料收集得到的測試結論。使用mock你可以移除掉除了需要測試部分的系統依賴的模擬。當隔離這些mocks後,mocks就變得非常簡單可靠,快速可預見。這為你提供了一個移除了隨機行為,有重複模式並且可以監控特殊系統的測試環境。 


Mockito 框架

  Mockito 是一個基於MIT協議的開源java測試框架。 
  Mockito區別於其他模擬框架的地方主要是允許開發者在沒有建立“預期”時驗證被測系統的行為。對mock物件的一個批評是測試程式碼與被測系統高度耦合,由於Mockito試圖通過移除“期望規範”來去除expect-run-verify模式(期望--執行--驗證模式),因此使耦合度降低到最低。這樣的突出特性簡化了測試程式碼,使它更容易閱讀和修改了。

你可以驗證互動:

// 模擬的建立,對介面進行模擬
List mockedList = mock(List.class);
// 使用模擬物件
mockedList.add("one");
mockedList.clear();
// 選擇性地和顯式地驗證
verify(mockedList).add("one");
verify(mockedList).clear();  

或者存根方法呼叫:

// 你不僅可以模擬介面,任何具體類都行
LinkedList mockedList = mock(LinkedList.class);
// 執行前準備測試資料
when(mockedList.get(0)).thenReturn("first");
// 接著列印"first"
System.out.println(mockedList.get(0));
// 因為get(999)未對準備資料,所以下面將列印"null".
System.out.println(mockedList.get(999)); 

一個使用Mockito框架的簡單Java程式碼示例



圖2 不使用Mock框架


圖3 使用Mockito框架

步驟 1:  在IDE中建立一個普通的Java專案 

  在Eclipse、NetBeans或IntelliJ IDEA中建立一個普通的Java專案。

步驟 2:  新增java原始碼  

  類Person.java:

package mockitodemo;

public class Person
{
    private final Integer personID;
    private final String personName;
    public Person( Integer personID, String personName )
    {
        this.personID = personID;
        this.personName = personName;
    }
    public Integer getPersonID()
    {
        return personID;
    }
    public String getPersonName()
    {
        return personName;
    }
} 
  介面PersonDAO.java
package mockitodemo;

public interface PersonDao
{
    public Person fetchPerson( Integer personID );
    public void update( Person person );
} 
  類PersonService.java
package mockitodemo;

public class PersonService
{
    private final PersonDao personDao;
    public PersonService( PersonDao personDao )
    {
        this.personDao = personDao;
    }
    public boolean update( Integer personId, String name )
    {
        Person person = personDao.fetchPerson( personId );
        if( person != null )
        {
            Person updatedPerson = new Person( person.getPersonID(), name );
            personDao.update( updatedPerson );
            return true;
        }
        else
        {
            return false;
        }
    }
} 

步驟 3:  新增單元測試類.  

  接下來為類PersonService.java建立單元測試用例。我們使用JUnit 4.x和Mockito 1.9.5。可以設計測試用例類PersionServiceTest.java為如下,程式碼中有詳細註釋說明:

package mockitodemo;

import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import static org.junit.Assert.*;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.ArgumentCaptor;
import static org.mockito.Mockito.*;

/**
 * PersonService的單元測試用例
 *
 * @author jackzhou
 */
public class PersonServiceTest {

    @Mock
    private PersonDao personDAO;  // 模擬物件
    private PersonService personService;  // 被測類

    public PersonServiceTest() {
    }

    @BeforeClass
    public static void setUpClass() {
    }

    @AfterClass
    public static void tearDownClass() {
    }

    // 在@Test標註的測試方法之前執行
    @Before
    public void setUp() throws Exception {
        // 初始化測試用例類中由Mockito的註解標註的所有模擬物件
        MockitoAnnotations.initMocks(this);
        // 用模擬物件建立被測類物件
        personService = new PersonService(personDAO);
    }

    @After
    public void tearDown() {
    }

    @Test
    public void shouldUpdatePersonName() {
        Person person = new Person(1, "Phillip");
        // 設定模擬物件的返回預期值
        when(personDAO.fetchPerson(1)).thenReturn(person);
        // 執行測試
        boolean updated = personService.update(1, "David");
        // 驗證更新是否成功
        assertTrue(updated);
        // 驗證模擬物件的fetchPerson(1)方法是否被呼叫了一次
        verify(personDAO).fetchPerson(1);
        // 得到一個抓取器
        ArgumentCaptor<Person> personCaptor = ArgumentCaptor.forClass(Person.class);
        // 驗證模擬物件的update()是否被呼叫一次,並抓取呼叫時傳入的引數值
        verify(personDAO).update(personCaptor.capture());
        // 獲取抓取到的引數值
        Person updatePerson = personCaptor.getValue();
        // 驗證呼叫時的引數值
        assertEquals("David", updatePerson.getPersonName());
        // asserts that during the test, there are no other calls to the mock object.
        // 檢查模擬物件上是否還有未驗證的互動
        verifyNoMoreInteractions(personDAO);
    }

    @Test
    public void shouldNotUpdateIfPersonNotFound() {
        // 設定模擬物件的返回預期值
        when(personDAO.fetchPerson(1)).thenReturn(null);
        // 執行測試
        boolean updated = personService.update(1, "David");
        // 驗證更新是否失敗
        assertFalse(updated);
        // 驗證模擬物件的fetchPerson(1)方法是否被呼叫了一次
        verify(personDAO).fetchPerson(1);
        // 驗證模擬物件是否沒有發生任何互動
        verifyZeroInteractions(personDAO);
        // 檢查模擬物件上是否還有未驗證的互動
        verifyNoMoreInteractions(personDAO);
    }    

    /**
     * Test of update method, of class PersonService.
     */
    @Test
    public void testUpdate() {
        System.out.println("update");
        Integer personId = null;
        String name = "Phillip";
        PersonService instance = new PersonService(new PersonDao() {

            @Override
            public Person fetchPerson(Integer personID) {
                System.out.println("Not supported yet.");
                return null;
            }

            @Override
            public void update(Person person) {
                System.out.println("Not supported yet.");
            }
        });
        boolean expResult = false;
        boolean result = instance.update(personId, name);
        assertEquals(expResult, result);
        // TODO review the generated test code and remove the default call to fail.
        fail("The test case is a prototype.");
    }
}
  這裡setUpClass()、tearDownClass()、setUp()、tearDown()稱為測試夾具(Fixture),就是測試執行程式(test runner)在執行測試方法之前進行初始化、或之後進行回收資源的工作。JUnit 4之前是通過setUp、tearDown方法完成。在JUnit 4中,仍然可以在每個測試方法執行之前初始化欄位和配置環境,當然也是通過註解完成。在JUnit 4中,通過@Before標註setUp方法;@After標註tearDown方法。在一個測試類中,甚至可以使用多個@Before來註解多個方法,這些方法都是在每個測試之前執行。說明一點,一個測試用例類可以包含多個打上@Test註解的測試方法,在執行時,每個測試方法都對應一個測試用例類的例項。@Before是在每個測試方法執行前均初始化一次,同理@Ater是在每個測試方法執行完畢後均執行一次。也就是說,經這兩個註解的初始化和登出,可以保證各個測試之間的獨立性而互不干擾,它的缺點是效率低。另外,不需要在超類中顯式呼叫初始化和清除方法,只要它們不被覆蓋,測試執行程式將根據需要自動呼叫這些方法。超類中的@Before方法在子類的@Before方法之前呼叫(與建構函式呼叫順序一致),@After方法是子類在超類之前執行。
  這裡shouldUpdatePersonName()、shouldNotUpdateIfPersonNotFound()和testUpdate()都是測試PersonService的update()方法,它依賴於PersonDao介面。前兩者使用了模擬測試。testUpdate()則沒有使用模擬測試。下面是測試結果:

  可以看出,使用模擬測試的兩個測試成功了,沒有使用模擬測試的testUpdate()失敗。對於模擬測試,在測試用例類中要先宣告依賴的各個模擬物件,在setUp()中用MockitoAnnotations.initMocks()初始化所有模擬物件。在進行模擬測試時,要先設定模擬物件上方法的返回預期值,執行測試時會呼叫模擬物件上的方法,因此要驗證這些方法是否被呼叫,並且傳入的引數值是否符合預期。對於testUpdate()測試,我們需要自己建立測試PersonService.update()所需的所有PersonDao資料,因為我們只知道公開的PersonDao介面,其具體實現類(比如從資料庫中拿真實的資料,或寫入到資料庫中)可能由另一個團隊在負責,以適配不同的資料庫系統。這樣的依賴關係無疑使單元測試比較麻煩,而要拿真正PersonDao實現來進行測試,那也應該是後期整合測試的任務,把不同的元件整合到一起在真實環境中測試。有了模擬測試框架,就可以最大限度地降低單元測試時的依賴耦合性。

關注點 

 + Mock框架是什麼.

 + 為什麼要在測試中使用Mockito

參考