2016年12月8日 星期四

Testing: 隔離框架(Isolation framework) - 使用Mockito建立測試替身

前言

之前花了很多篇章在提手動建置測試替身,也從練習中了解為何要有測試替身和依賴注入等觀念,但手動建置有些問題存在,也顯得非常不方便,這些測試替身遲早會越來越多,相對地,我們光是維護就很耗時間,所以我們需要隔離框架(Isolation framework)來輔助我們,這也是接下來要提到的內容。

手動建置測試替身的問題

  • 使用手動建置測試替身會有下列問題:
  • 建置測試替身很耗時間。
  • 測試替身多了,也是要維護的。
  • 若測試替身要保存多次呼叫的狀態時,你需要在測試替身實現相關程式碼。
  • 很難在其他測試重複使用測試替身,可能基本程式碼可以使用,但是介面有超過三個以上方法需要實現時,維護就成了問題。
所以為了讓我們更方便使用測試替身,我們需要隔離框架的協助,也就是一套提供公開使用的API,藉由這些API自動建置測試替身,可以比手動建置來得容易、有效率,而且更為簡潔。每個語言的單元測試框架大部分都有隔離框架,C++有mockpp和其他框架,Java有jMock、PowerMock...等,而在Android上則廣泛使用Mockito和PowerMock這兩套框架。

Mockito的使用

手動建置的測試替身就不再一一說明,先前都有足夠的例子可以練習,直接進入該如何使用Mockito來建置測試替身。首先,若要在Android Studio中使用Mockito框架,要先在gradle中加入Mockito,設定如下。

dependencies {
    testCompile 'org.mockito:mockito-core:2.2.28'
}

設定之後,我們都知道測試替身的用意,而使用Mockito來建置Mock物件有兩種做法,一個是利用mock這個方法(即使是Stub物件也是利用mock方法),另一種是@Mock這個Annotation,先介紹前者,這邊會以先前的FileParser和Bookstore來做練習。

以FileParser為例:mock方法

  • FileParserWithMockitoTest測試程式
public class FileParserWithMockitoTest {

    @Test
    public void testNameShorterCharactersIsValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        IExtensionManager stubExtensionManager = Mockito.mock(IExtensionManager.class);
        Mockito.when(stubExtensionManager.isValid(Mockito.anyString())).thenReturn(true);
        FileParser fileParser = new FileParser(stubExtensionManager);

        // Act
        boolean actualResult = fileParser.isValidLogFileName("short.txt");

        // Assert
        assertThat(actualResult, is(false));
    }

    @Test
    public void testNameShorterThan6CharactersIsValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        IExtensionManager stubExtensionManager = Mockito.mock(IExtensionManager.class);
        Mockito.when(stubExtensionManager.isValid(Mockito.anyString())).thenReturn(true);
        FileParser fileParser = new FileParser(stubExtensionManager);

        // Act
        boolean actualResult = fileParser.isValidLogFileName("short_file_name.txt");

        // Assert
        assertThat(actualResult, is(true));
    }

    @Test
    public void testNameShorterCharactersIsNotValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        IExtensionManager stubExtensionManager = Mockito.mock(IExtensionManager.class);
        Mockito.when(stubExtensionManager.isValid(Mockito.anyString())).thenReturn(false);
        FileParser fileParser = new FileParser(stubExtensionManager);

        // Act
        boolean actualResult = fileParser.isValidLogFileName("short.txt");

        // Assert
        assertThat(actualResult, is(false));
    }

    @Test
    public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        IExtensionManager stubExtensionManager = Mockito.mock(IExtensionManager.class);
        Mockito.when(stubExtensionManager.isValid(Mockito.anyString())).thenReturn(false);
        FileParser fileParser = new FileParser(stubExtensionManager);

        // Act
        boolean actualResult = fileParser.isValidLogFileName("short_file_name.txt");

        // Assert
        assertThat(actualResult, is(false));
    }

    @Test
    public void testVerifyIExtensionManagerCalledOneTimes() throws Exception {
        // Arrange
        IExtensionManager stubExtensionManager = Mockito.mock(IExtensionManager.class);
        Mockito.when(stubExtensionManager.isValid(Mockito.anyString())).thenReturn(false);
        FileParser fileParser = new FileParser(stubExtensionManager);

        // Act
        fileParser.isValidLogFileName("short_file_name.txt");

        // Assert
        // 當沒有指定times時,預設就是1次
        Mockito.verify(stubExtensionManager).isValid(Mockito.anyString());
        Mockito.verify(stubExtensionManager, Mockito.times(1)).isValid(Mockito.anyString());
    }
}

可以對照上述程式,用到Mockito框架的以下方法:
  • mock(Class<T> classToMock): 這邊是指定要模擬的類別或是介面。以上述程式為例,需要Mock的則是IExtensionManager。
  • when(T methodCall): 這裡表示要呼叫Mock的某個方法。就如同先前手動建立Stub時,我們希望呼叫IExtensionManager的isValid方法。
  • thenReturn(T value): 預期Mock呼叫某個方法後,所要回傳的值。我們希望當呼叫isValid方法之後,回傳true。
  • verify(T mock) / verify(T mock, VerificationMode mode): 檢驗Mock呼叫了某個方法幾次。以上述程式碼為例,Mockito.verify(stubExtensionManager, Mockito.times(1)).isValid(Mockito.anyString()),這段程式碼表示驗證isValid方法被呼叫了1次。
藉由Mockito提供的方法,我們就完成原本手動建置Stub的功能了,我們也能順利通過測試,如下圖。


以FileParser為例:@Mock的Annotation使用

  • FileParserWithMockitoAnnotationTest測試程式
public class FileParserWithMockitoAnnotationTest {
    @Mock
    private IExtensionManager stubExtensionManager;

    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
    }

    @Test
    public void testNameShorterCharactersIsValidEvenWithSupportedExtension() throws Exception {
        Mockito.when(this.stubExtensionManager.isValid(Mockito.anyString())).thenReturn(true);
        FileParser fileParser = new FileParser(this.stubExtensionManager);

        // Act
        boolean actualResult = fileParser.isValidLogFileName("short.txt");

        // Assert
        assertThat(actualResult, is(false));
    }

    @Test
    public void testNameShorterThan6CharactersIsValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        Mockito.when(this.stubExtensionManager.isValid(Mockito.anyString())).thenReturn(true);
        FileParser fileParser = new FileParser(this.stubExtensionManager);

        // Act
        boolean actualResult = fileParser.isValidLogFileName("short_file_name.txt");

        // Assert
        assertThat(actualResult, is(true));
    }

    @Test
    public void testNameShorterCharactersIsNotValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        Mockito.when(this.stubExtensionManager.isValid(Mockito.anyString())).thenReturn(false);
        FileParser fileParser = new FileParser(this.stubExtensionManager);

        // Act
        boolean actualResult = fileParser.isValidLogFileName("short.txt");

        // Assert
        assertThat(actualResult, is(false));
    }

    @Test
    public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        Mockito.when(this.stubExtensionManager.isValid(Mockito.anyString())).thenReturn(false);
        FileParser fileParser = new FileParser(this.stubExtensionManager);

        // Act
        boolean actualResult = fileParser.isValidLogFileName("short_file_name.txt");

        // Assert
        assertThat(actualResult, is(false));
    }
}

Annotation的使用很簡單,只要在Mock的類別或介面加上@Mock,接著使用MockitoAnnotations.initMocks(Object testClass)方法初始化即可,同樣方式也能順利通過測試喔!如下圖。


以Bookstore為例:mock方法

  • BookStoreWithMockitoTest測試程式
public class BookStoreWithMockitoTest {

    @Test
    public void testCheckInFeeNotVIP() throws Exception {
        // Arrange
        ICheckInFee mockCheckInFee = Mockito.mock(ICheckInFee.class);
        BookStore bookStore = new BookStore(mockCheckInFee);

        // Act
        bookStore.checkInFee(this.getCustomers());

        // Assert
        Mockito.verify(mockCheckInFee, Mockito.times(2)).getFee(Mockito.any(Customer.class));
    }

    @Test
    public void testCheckInFeeIsVIP() throws Exception {
        // Arrange
        ICheckInFee mockCheckInFee = Mockito.mock(ICheckInFee.class);
        BookStore bookStore = new BookStore(mockCheckInFee);

        // Act
        bookStore.checkInFee(this.getCustomers());

        // Assert
        Mockito.verify(mockCheckInFee, Mockito.times(4)).getDiscountedFee(Mockito.any(Customer.class));
    }

    @Test
    public void testGetIncome() throws Exception {
        // Arrange
        ICheckInFee mockCheckInFee = Mockito.mock(ICheckInFee.class);
        Mockito.when(mockCheckInFee.getFee(Mockito.any(Customer.class))).thenReturn(1000);
        Mockito.when(mockCheckInFee.getDiscountedFee(Mockito.any(Customer.class))).thenReturn(1000 * 0.8);
        BookStore bookStore = new BookStore(mockCheckInFee);

        // Act
        bookStore.checkInFee(this.getCustomers());

        // Assert
        assertThat(bookStore.getIncome(), equalTo(5200.0));
    }

    private List<Customer> getCustomers() {
        List<Customer> customers = new ArrayList<>();
        Customer customer1 = new Customer();
        Customer customer2 = new Customer();
        Customer customer3 = new Customer();
        Customer customer4 = new Customer();
        Customer customer5 = new Customer();
        Customer customer6 = new Customer();
        customer1.setVIP(true);
        customer2.setVIP(false);
        customer3.setVIP(false);
        customer4.setVIP(true);
        customer5.setVIP(true);
        customer6.setVIP(true);
        customers.add(customer1);
        customers.add(customer2);
        customers.add(customer3);
        customers.add(customer4);
        customers.add(customer5);
        customers.add(customer6);

        return customers;
    }
}

這邊的做法和FileParser的方式差不多,只是驗證結果有些差異,在Bookstore的checkInFee是void方法,我們測試則著重在ICheckInFee的getFee和getDiscountedFee方法是否被正確呼叫,而且呼叫次數是否如我們預期。以下是使用到Mockito的其他方法。
  • any(Class<T> class): 這個方法表示不限定傳入特定資料,只要是同型態即可,類似的還有anyString()。
最後也是順利通過測試囉,如下圖。


以Bookstore為例:@Mock的Annotation使用

  • BookStoreWithMockitoAnnotationTest測試程式
public class BookStoreWithMockitoAnnotationTest {
    @Mock
    private ICheckInFee mockCheckInFee;

    private BookStore bookStore;

    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
    }

    @Test
    public void testCheckInFeeNotVIP() throws Exception {
        // Arrange
        this.bookStore = new BookStore(this.mockCheckInFee);

        // Act
        this.bookStore.checkInFee(this.getCustomers());

        // Assert
        Mockito.verify(mockCheckInFee, Mockito.times(2)).getFee(Mockito.any(Customer.class));
    }

    @Test
    public void testCheckInFeeIsVIP() throws Exception {
        // Arrange
        this.bookStore = new BookStore(this.mockCheckInFee);

        // Act
        this.bookStore.checkInFee(this.getCustomers());

        // Assert
        Mockito.verify(mockCheckInFee, Mockito.times(4)).getDiscountedFee(Mockito.any(Customer.class));
    }

    @Test
    public void testGetIncome() throws Exception {
        // Arrange
        Mockito.when(this.mockCheckInFee.getFee(Mockito.any(Customer.class))).thenReturn(1000);
        Mockito.when(this.mockCheckInFee.getDiscountedFee(Mockito.any(Customer.class))).thenReturn(1000 * 0.8);
        BookStore bookStore = new BookStore(this.mockCheckInFee);

        // Act
        bookStore.checkInFee(this.getCustomers());

        // Assert
        assertThat(bookStore.getIncome(), equalTo(5200.0));
    }

    private List<Customer> getCustomers() {
        List<Customer> customers = new ArrayList<>();
        Customer customer1 = new Customer();
        Customer customer2 = new Customer();
        Customer customer3 = new Customer();
        Customer customer4 = new Customer();
        Customer customer5 = new Customer();
        Customer customer6 = new Customer();
        customer1.setVIP(true);
        customer2.setVIP(false);
        customer3.setVIP(false);
        customer4.setVIP(true);
        customer5.setVIP(true);
        customer6.setVIP(true);
        customers.add(customer1);
        customers.add(customer2);
        customers.add(customer3);
        customers.add(customer4);
        customers.add(customer5);
        customers.add(customer6);

        return customers;
    }
}

使用上是一樣的,所以不多做說明,大家自行練習。測試結果同樣順利,如下圖。


小結

目前為止跟大家分享了Mockito基本的使用,若想要知道更多用法,可以至其它網站資訊查詢。然而目前只是針對一般介面或類別驗證,有時會需要對靜態類別進行檢查,這時可以使用PowerMock,也是接下來要介紹的,持續練習單元測試吧!

Source Code

Github: https://github.com/xavier0507/UnitTestSample.git

沒有留言:

張貼留言