2016年12月8日 星期四

Testing: Android的單元測試相關內容

前言

因為自己在學習單元測試及閱讀相關書籍的過程中,總覺得資料很多,但觀念都很分散,無法很有系統地瞭解單元測試議題,所以在自我學習後,我練習一些例子幫助我瞭解單元測試,所以將這些內容有系統並循序漸進地整理起來。

相關觀念


框架使用


Testing: 隔離框架(Isolation framework) - 使用PowerMock驗證靜態方法

前言

Testing: 隔離框架(Isolation framework) - 使用Mockito建立測試替身一文中,我們練習如何使用Mockito框架模擬測試替身後,接著要來學習PowerMock的框架,輔助我們測試與驗證靜態方法。

PowerMock的使用

PowerMock使用起來相當簡單,首先我們在gradle中引用PowerMock,程式碼如下。

dependencies {
    testCompile 'org.powermock:powermock-module-junit4:1.6.2'
    testCompile 'org.powermock:powermock-api-mockito:1.6.2'
}

以RegisterManager為例

  • RegisterManager物件
public class RegisterManager {
    private IEmailRegister emailRegister;
    private IMobileRegister mobileRegister;

    public RegisterManager(IEmailRegister emailRegister, IMobileRegister mobileRegister) {
        this.emailRegister = emailRegister;
        this.mobileRegister = mobileRegister;
    }

    public void register(String content) {
        if (RegistrationFormat.checkFormat(content)) {
            this.emailRegister.emailRegister();
        } else {
            this.mobileRegister.mobileRegister();
        }
    }
}
  • RegistrationFormat類別
public class RegistrationFormat {

    public static boolean checkFormat(String content) {
        boolean isEmail = false;

        if (content.contains("@")) {
            return isEmail;
        }

        return !isEmail;
    }
}
  • IEmailRegister介面
public interface IEmailRegister {
    void emailRegister();
}
  • IMobileRegister介面
public interface IMobileRegister {
    void mobileRegister();
}

RegisterManager的功能相當簡單,它提供了Email與Mobile這兩種註冊方法,透過RegistrationFormat類別的靜態方法檢查,若是屬於Email格式,就使用Email註冊,Mobile註冊則反之,接著我們來看測試程式。

@RunWith(PowerMockRunner.class)
@PrepareForTest(RegistrationFormat.class)
public class RegisterManagerWithPowerMockitoTest {

    @Test
    public void testRegisterIsEmail() throws Exception {
        // Arrange
        IEmailRegister mockEmailRegister = Mockito.mock(IEmailRegister.class);
        IMobileRegister mockMobileRegister = Mockito.mock(IMobileRegister.class);
        PowerMockito.mockStatic(RegistrationFormat.class);
        Mockito.when(RegistrationFormat.checkFormat(Mockito.anyString())).thenReturn(true);
        RegisterManager registerManager = new RegisterManager(mockEmailRegister, mockMobileRegister);

        // Act
        registerManager.register(Mockito.anyString());

        // Assert
        Mockito.verify(mockEmailRegister).emailRegister();
    }

    @Test
    public void testRegisterIsMobile() throws Exception {
        // Arrange
        IEmailRegister mockEmailRegister = Mockito.mock(IEmailRegister.class);
        IMobileRegister mockMobileRegister = Mockito.mock(IMobileRegister.class);
        PowerMockito.mockStatic(RegistrationFormat.class);
        Mockito.when(RegistrationFormat.checkFormat(Mockito.anyString())).thenReturn(false);
        RegisterManager registerManager = new RegisterManager(mockEmailRegister, mockMobileRegister);

        // Act
        registerManager.register(Mockito.anyString());

        // Assert
        Mockito.verify(mockMobileRegister).mobileRegister();
    }

    @Test
    public void testRegistrationFormatCheckFormatIsCalled() throws Exception {
        // Arrange
        IEmailRegister mockEmailRegister = Mockito.mock(IEmailRegister.class);
        IMobileRegister mockMobileRegister = Mockito.mock(IMobileRegister.class);
        PowerMockito.mockStatic(RegistrationFormat.class);
        Mockito.when(RegistrationFormat.checkFormat(Mockito.anyString())).thenReturn(false);
        RegisterManager registerManager = new RegisterManager(mockEmailRegister, mockMobileRegister);

        // Act
        registerManager.register(Mockito.anyString());

        // Assert
        PowerMockito.verifyStatic(Mockito.times(1));
        RegistrationFormat.checkFormat(Mockito.anyString());
    }
}

要使用PowerMock首先要依循以下方式:
  • @RunWith(PowerMockRunner.class): 使用@RunWith的Annotation,並且類別指定PowerMockRunner.class。
  • @PrepareForTest(RegistrationFormat.class): 使用@PrepareForTest的Annotation,聲明要Mock的靜態方法類別,這邊指定RegistrationFormat.class。
  • PowerMockito.mockStatic(RegistrationFormat.class): 使用mockStatic方法Mock靜態類別

接著預期我們的測試程式,可以使用下列方法。
  • Mockito.when(RegistrationFormat.checkFormat(Mockito.anyString())).thenReturn(false): 如同Mockito的方法,我們指定RegistrationFormat使用checkFormat的靜態方法時,所欲回傳的值。
  • PowerMockito.verifyStatic(Mockito.times(1)): 先斷言該靜態方法執行的次數。
  • RegistrationFormat.checkFormat(Mockito.anyString()): 實際執行該方法。

這邊需要注意的是,PowerMock斷言的方式有些奇怪,要先斷言再執行呼叫,值得注意喔!這邊也能順利通過測試,如下圖。


小結

我們都順利討論完隔離框架的使用啦!但目前為止,這些議題是個人學習單元測試上,覺得非常重要的觀念,希望對大家都有幫助,之後也會不定期更新相關議題,若有任何意見,也歡迎提供給我,謝謝大家!

Source Code

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

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

2016年12月7日 星期三

Testing: 再論依賴注入

前言

前一篇文章Testing: 再論測試替身中,已經討論全部的測試替身,也了解測試替身的目的,接著本文再討論其它的依賴注入方式。

再論依賴注入

先前介紹建構子依賴注入時,有提到所謂的依賴注入就是透過一個基於介面的入口,我們可以在一個類別中注入一個介面的實作,讓它的方法可以利用這種介面來實現,而依賴注入分為下列幾種方式。我們已經介紹過建構子這個依賴注入方式,接著繼續介紹其它建構子的注入方法。
  • 建構子(Constructor)
  • Setter方法
  • 工廠類別(Factory Class)
  • 工廠方法(Factory Method)
  • 映射(Reflection)

建構子依賴注入

先前介紹的是利用建構子聲明一個參數,將所需的依賴型態傳入,但有時我們不見得會有參數的建構子,若在無參數的建構子時,我們又該如何設計注入?同樣以先前的FileParser物件來說明,先建立一個公開的無參數建構子,程式碼如下。
  • FileParserWithoutParameterConstructor物件
public class FileParser {
    private IExtensionManager fileExtensionManager;

    public FileParser() {
        this(new FileExtensionManagerImp());
    }

    protected FileParser(IExtensionManager fileExtensionManager) {
        this.fileExtensionManager = fileExtensionManager;
    }

    public boolean isValidLogFileName(String fileName) {
        return this.fileExtensionManager.isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
    }
}
  • StubExtensionManager物件
public class StubExtensionManager implements IExtensionManager {
    public boolean shouldExtensionsBeValid;

    @Override
    public boolean isValid(String fileName) {
        return this.shouldExtensionsBeValid;
    }
}
  • FileParserWithoutParameterConstructorTest測試程式
public class FileParserWithoutParameterConstructorTest {

    @Test
    public void testNameShorterCharactersIsValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        StubExtensionManager fake = new StubExtensionManager();
        fake.shouldExtensionsBeValid = true;
        FileParserProxy fileParser = new FileParserProxy(fake);

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

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

    @Test
    public void testNameShorterThan6CharactersIsValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        StubExtensionManager fake = new StubExtensionManager();
        fake.shouldExtensionsBeValid = true;
        FileParserProxy fileParser = new FileParserProxy(fake);

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

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

    @Test
    public void testNameShorterCharactersIsNotValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        StubExtensionManager fake = new StubExtensionManager();
        fake.shouldExtensionsBeValid = false;
        FileParserProxy fileParser = new FileParserProxy(fake);

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

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

    @Test
    public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        StubExtensionManager fake = new StubExtensionManager();
        fake.shouldExtensionsBeValid = false;
        FileParserProxy fileParser = new FileParserProxy(fake);

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

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

    private class FileParserProxy extends FileParserWithoutParameterConstructor {

        public FileParserProxy(IExtensionManager fileExtensionManager) {
            super(fileExtensionManager);
        }
    }
}

從上面可以看出,只是藉由FileParserProxy來繼承一個FileParserWithoutParameterConstructor,並為FileParserProxy設計一個公開且帶有參數的建構子,這樣測試程式則可以使用它並傳入想使用的Stub物件,替換掉我們的依賴物件了,測試也同樣順利完成,如下所示。


Setter依賴注入

Setter依賴注入也很簡單,只要使用一個Setter方法,並且將依賴傳入即可,程式碼如下。
  • FileParserWithSetterInjection物件
public class FileParserWithSetterInjection {
    private IExtensionManager extensions;

    public FileParserWithSetterInjection() {
        this.extensions = new FileExtensionManagerImp();
    }

    public void setExtensionManager(IExtensionManager extensions) {
        this.extensions = extensions;
    }

    public boolean isValidLogFileName(String fileName) {
        return extensions.isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
    }
}
  • FileParserWithSetterInjectionTest測試程式
public class FileParserWithSetterInjectionTest {

    @Test
    public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        StubExtensionManager fake = new StubExtensionManager();
        fake.shouldExtensionsBeValid = true;

        // Act
        FileParserWithSetterInjection log = new FileParserWithSetterInjection();
        log.setExtensionManager(fake);

        // Assert
        Assert.assertFalse(log.isValidLogFileName("short.ext"));
    }
}

從以上程式碼可以看出,我們多加入一個setExtensionManager方法,與建構子依賴注入的方式差不多,測試時就直接將Stub傳入即可,我們同樣順利通過測試囉!


工廠類別依賴注入

工廠類別依賴注入也很簡單,只是將注入的方法移到一個單例(Singleton)的工廠類別物件,再提供一個setInstance的方法就完成,程式碼如下。
  • FileParserWithFactoryClassInjection物件
public class FileParserWithFactoryClassInjection {
    private IExtensionManager fileExtensionManager;

    public FileParserWithFactoryClassInjection() {
        this.fileExtensionManager = ExtensionManagerFactory.create();
    }

    public boolean isValidLogFileName(String fileName) {
        return this.fileExtensionManager.isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
    }
}
  • ExtensionManagerFactory工廠物件
public class ExtensionManagerFactory {
    private static IExtensionManager customImplementation = null;

    public static IExtensionManager create() {
        if (customImplementation != null) {
            return customImplementation;
        }

        return new FileExtensionManagerImp();
    }

    public static void setInstance(IExtensionManager implementation) {
        customImplementation = implementation;
    }
}
  • FileParserWithFactoryClassInjectionTest測試程式
public class FileParserWithFactoryClassInjectionTest {

    @Test
    public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        StubExtensionManager fake = new StubExtensionManager();
        fake.shouldExtensionsBeValid = true;
        ExtensionManagerFactory.setInstance(fake);

        // Act
        FileParserWithFactoryClassInjection log = new FileParserWithFactoryClassInjection();

        // Assert
        assertFalse(log.isValidLogFileName("short.exe"));
    }
}

看到了?我們透過ExtensionManagerFactory.setInstance(fake)這樣的方式,把Stub給注入進去,接著就能順利通過測試啦!如下圖。


工廠方法依賴注入

工廠方法依賴注入則是在待測物件內建立一個工廠方法,產生相對應的依賴物件,當測試時覆寫該方法替換想要的依賴物件即可,程式如下。
  • FileParserWithFactoryMethodInjection物件
public class FileParserWithFactoryMethodInjection {

    protected IExtensionManager getExtensionManager() {
        return new FileExtensionManagerImp();
    }

    public boolean isValidLogFileName(String fileName) {
        return getExtensionManager().isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
    }
}
  • FileParserWithFactoryMethodInjectionTest測試程式
public class FileParserWithFactoryMethodInjectionTest {

    @Test
    public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        final StubExtensionManager fake = new StubExtensionManager();
        fake.shouldExtensionsBeValid = true;
        FileParserWithFactoryMethodInjection log = new FileParserWithFactoryMethodInjection() {

            @Override
            protected IExtensionManager getExtensionManager() {
                return fake;
            }
        };

        // Act
        boolean actualResult = log.isValidLogFileName("shortName.ext");

        // Assert
        Assert.assertTrue(actualResult);
    }

    @Test
    public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtensions() throws Exception {
        // Arrange
        final StubExtensionManager fake = new StubExtensionManager();
        fake.shouldExtensionsBeValid = true;
        FileParserWithFactoryMethodInjectionProxy log = new FileParserWithFactoryMethodInjectionProxy();
        log.extensionManager = fake;

        // Act
        boolean actualResult = log.isValidLogFileName("shortName.ext");

        // Assert
        Assert.assertTrue(actualResult);
    }

    class FileParserWithFactoryMethodInjectionProxy extends FileParserWithFactoryMethodInjection {
        public IExtensionManager extensionManager;

        @Override
        protected IExtensionManager getExtensionManager() {
            return this.extensionManager;
        }
    }
}

在測試時有兩種方式,一種你可以另外產生Stub物件,像是上述程式內的FileParserWithFactoryMethodInjectionProxy類別,另一種則是在測試案例中用匿名類別的方式也是能達到同樣目的,如同testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension測試案例一樣,透過這種方式,我們也能順利通過測試,如下圖。


映射依賴注入

映射依賴注入比較麻煩,就是要利用映射機制來修改原本屬性的存取權限,然後把依賴物件給注入,程式碼如下。
  • FileParserWithReflectionInjection物件
public class FileParserWithReflectionInjection {
    private IExtensionManager extensionManager;

    public FileParserWithReflectionInjection() {
        this(new FileExtensionManagerImp());
    }

    protected FileParserWithReflectionInjection(IExtensionManager extensionManager) {
        this.extensionManager = extensionManager;
    }

    public boolean isValidLogFileName(String fileName) {
        return this.extensionManager.isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
    }
}
  • FileParserWithReflectionInjectionTest測試程式
public class FileParserWithReflectionInjectionTest {

    @Test
    public void testOverridePrivateModifierOfField() throws Exception {
        // Arrange
        StubExtensionManager fake = new StubExtensionManager();
        fake.shouldExtensionsBeValid = true;
        FileParserWithReflectionInjection log = new FileParserWithReflectionInjection();
        this.injectToField(log, "extensionManager", fake);

        // Act
        boolean actualResult = log.isValidLogFileName("validLogFile.ext");

        // Assert
        assertTrue(actualResult);
    }

    private void injectToField(Object target, String fieldName, Object dependency) {
        try {
            Field field = target.getClass().getDeclaredField(fieldName);

            if (!field.isAccessible()) {
                field.setAccessible(true);
            }

            field.set(target, dependency);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

從上面的測試程式碼來看,新增了一個injectToField方法,這個方法有三個參數,分別為測試物件、測試物件的屬性名稱,以及依賴物件,再來可以透過getClass().getDeclaredField(“屬性名稱"),取得測試物件的Field,這時就能去修改它的權限,把private設定成public,再透過field.set()方法則能把依賴物件注入了,最後我們也是成功完成測試了,如下圖。


小結

我們對於測試替身以及依賴注入的介紹夠多了,為何使用測試替身和依賴注入?原因在於需要隔離測試目標,這樣才能模擬出所有情境,並且測試到應有的行為。但目前我們介紹測試替身都是使用手動建置,手動建置有它的成本,但為何要先介紹手動建置?個人認為應該先理解原理,再去尋求更快捷的方法,所以接下來我們就可以利用其他便利的框架,為我們的單元測試提供更多的服務囉!

Source Code

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

2016年12月6日 星期二

Testing: 再論測試替身

前言

Testing: 解除依賴!測試替身與依賴注入一文中,認識了測試替身與依賴注入,並且知道這兩者的目的為何,但都只知其一,接下來要深入瞭解其他方式與用法。

Fake(偽造物件)讓你的程式順利測試,又不會有副作用。

下面這張是關於測試替身的類型,在前一篇內容有出現過,但只提到了Stub這種測試替身,那其他像是Fake、Spy和Mock又是甚麼?首先,先來提Fake!


這種測試替身對我們來說一點也不陌生,開發者即使沒有寫單元測試時,也會用一些假資料,這種假資料有點類似真實資料的簡單版本,這就是Fake,它既不影響到你的程式,也能模擬幾近真實的場景。假設我們有個UserRepository的介面,這個UserRepository在真實情況是透過資料庫連結後,尋找User物件資料,類別圖與程式碼如下。

  • User物件
public class User {
    private int id;
    private String userName;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }
}
  • UserRepository介面
public interface UserRepository {
    void save(User user);

    User findById(int id);

    User findByUserName(String userName);
}
  • DatabaseUserRepository物件
public class DatabaseUserRepository implements UserRepository {

    @Override
    public void save(User user) {
        // 連接資料庫,並且將User存入資料庫中。
    }

    @Override
    public User findById(int id) {
        // 如果連接資料庫成功,則從資料庫透過Id尋找該User,找不到則回傳空物件。

        return null;
    }

    @Override
    public User findByUserName(String userName) {
        // 如果連接資料庫成功,則從資料庫透過userName尋找該User,找不到則回傳空物件。

        return null;
    }
}
  • Client物件
public class Client {
    private UserRepository userRepository;

    public Client(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public void saveUser(User user) {
        // 其他商業邏輯

        this.userRepository.save(user);
    }

    public User findById(int id) {
        // 其他商業邏輯

        return this.userRepository.findById(id);
    }

    public User findByUserName(String userName) {
        // 其他商業邏輯

        return this.userRepository.findByUserName(userName);
    }
}

User物件就是單純的資料物件;UserRepository介面提供資料的操作,無論你是使用資料庫、網路或檔案系統;DatabaseUserRepository物件則是UserRepository的實作,這裡採用資料庫存取,但細節不實作,僅用註解示意;Clien物件就是一個操作UserRepository的使用端物件,考量現實環境,這邊用註解模擬其他商業邏輯。

從類別圖我們可以看見,用戶端要是真的測試這個場景,就必須用到真實的外部資源,但是依照單元測試的特性,我們就是無法依賴外部資源也要進行測試啊!這時就可以使用Fake物件來模擬這種情況,同時也能進行測試,類別圖與程式碼如下。

  • FakeUserRepository物件
public class FakeUserRepository implements UserRepository {
    private Collection<User> users = new ArrayList<>();

    @Override
    public void save(User user) {
        if (this.findById(user.getId()) == null) {
            this.users.add(user);
        }
    }

    @Override
    public User findById(int id) {
        for (User user : users) {
            if (user.getId() == id) {
                return user;
            }
        }

        return null;
    }

    @Override
    public User findByUserName(String userName) {
        for (User user : users) {
            if (user.getUserName().equals(userName)) {
                return user;
            }
        }

        return null;
    }
}
  • UserRepositoryTest測試類別
public class UserRepositoryTest {
    private Client client;
    private FakeUserRepository fake;

    @Before
    public void setUp() throws Exception {
        this.fake = new FakeUserRepository();
        this.client = new Client(this.fake);
    }

    @Test
    public void testFindByIdIsActuallyFound() throws Exception {
        // Arrange
        this.addFakeUsers();

        // Act
        User actualResult = this.client.findById(1);

        // Assert
        assertThat(actualResult.getId(), equalTo(1));
    }

    @Test
    public void testFindByIdIsNotFound() throws Exception {
        // Arrange
        this.addFakeUsers();

        // Act
        User actualResult = this.client.findById(0);

        // Assert
        assertThat(actualResult, nullValue());
    }

    @Test
    public void testFindByUserNameIsActuallyFound() throws Exception {
        // Arrange
        this.addFakeUsers();

        // Act
        User actualResult = this.client.findByUserName("Xavier3");

        // Assert
        assertThat(actualResult.getUserName(), equalTo("Xavier3"));
    }

    @Test
    public void testFindByUserNameIsNotFound() throws Exception {
        // Arrange
        this.addFakeUsers();

        // Act
        User actualResult = this.client.findByUserName("Xavier0");

        // Assert
        assertThat(actualResult, nullValue());
    }

    private void addFakeUsers() {
        for (int i = 0; i < 5; i++) {
            User user = new User();
            user.setId(i + 1);
            user.setUserName("Xavier" + (i + 1));

            this.fake.save(user);
        }
    }
}

這個FakeUserRepository就是所謂的Fake物件,它模擬了存取資料庫的情況。最後我們成功通過測試啦!如下圖。這時你就可以在你的場景中用它來替換掉緩慢的現實場景,以及望塵莫及的依賴性。


Spy(測試間諜),來偷取秘密吧!

接著來談Spy!先前有提過,對於單元測試的結果斷定,會涉及三種形式,其中一種就是預期被測物件與相依物件的互動,這種往往沒有回傳值可以讓你斷言,舉個例子,以下有兩個方法。
  • 有回傳值
public String concat(String first, String second) {
     ............................
}

對於有回傳值的檢驗方式,直覺就是將兩個String參數傳進去,並且斷言回傳值是什麼就好。這沒什麼問題,畢竟回傳值的確是我們最關心的,那下列的方法該如何測試?
  • 沒有回傳值
public void recordMessages(Level level, String message) {
        for (ILogger each : loggers) {
            each.write(level, message);
        }
}

這裡並沒有回傳值供我們斷言,這個方法做的就是一個Logger集合的訊息寫入,要驗證這個方法是否正常工作,唯一的方式就是事後我們檢查列表。就像是你派臥底警察,然後他回報給你所知道的一切。有時這一點無需使用測試替身,因為參數本身就提供測試足夠的訊息,可以讓你獲取額外資訊,但在某些情境中,參數可能資訊不足,我們可以看以下例子。
  • ILogger介面
public interface ILogger {
    void write(Level level, String message);
}
  • LoggerSystem物件
public class LogSystem {
    private final ILogger[] loggers;

    public LogSystem(ILogger... loggers) {
        this.loggers = loggers;
    }

    public void recordMessages(Level level, String message) {
        for (ILogger each : loggers) {
            each.write(level, message);
        }
    }
}

以上的程式碼可以知道,被測的物件是一個LogSystem物件,裡面有著一個記錄不同Logger的陣列。當我們向LogSystem執行recordMessages方法時,應該向所有的Logger物件寫入同樣的訊息。在測試的觀點來看,我們無法知道指定的訊息是否被寫入,然而Logger物件也只有提供一個write方法,資訊顯然也不足讓測試者獲取,這時可以悄悄地讓Spy登場了。
  • SpyLogger物件
public class SpyLogger implements ILogger {
    private List<String> logMessages = new ArrayList<>();

    @Override
    public void write(Level level, String message) {
        this.logMessages.add(this.concatenated(level, message));
    }

    public boolean received(Level level, String message) {
        return this.logMessages.contains(concatenated(level, message));
    }

    private String concatenated(Level level, String message) {
        return level.getName() + ": " + message;
    }
}
  • LogSystem測試物件
public class LogSystemTest {

    @Test
    public void testWriteEachMessageToAllTargets() throws Exception {
        // Arrange
        SpyLogger spy1 = new SpyLogger();
        SpyLogger spy2 = new SpyLogger();
        SpyLogger spy3 = new SpyLogger();
        SpyLogger spy4 = new SpyLogger();
        SpyLogger spy5 = new SpyLogger();
        LogSystem logTarget = new LogSystem(spy1, spy2, spy3, spy4, spy5);

        // Act
        logTarget.recordMessages(Level.INFO, "這些是訊息!");

        // Assert
        assertThat(spy1.received(Level.INFO, "這些是訊息!"), is(true));
        assertThat(spy2.received(Level.INFO, "這些是訊息!"), is(true));
        assertThat(spy3.received(Level.INFO, "這些是訊息!"), is(true));
        assertThat(spy4.received(Level.INFO, "這些是訊息!"), is(true));
        assertThat(spy5.received(Level.INFO, "這些是訊息!"), is(true));
    }
}

看到了嗎?這就是測試間諜,如同其他測試替身,你把派出這些間諜,當作參數傳入,然後你讓測試間諜擴充功能紀錄已發送的訊息,並且提供測試鞭打拷問它的方法,是否收到指定的訊息。最後,我們也成功通過測試了!如下圖。


Mock(模擬物件),間諜老大就是你!

其實Mock就是一種全能的測試替身,可以算是Stub與Spy的結合,你既可以指定它所回傳的預設值,同時也可以像Spy那樣擴充它的功能去記錄過去所發生的事情,好提供足夠的資訊讓測試程式知道,如下列程式。
  • ICheckInFee介面
public interface ICheckInFee {
    int getFee(Customer customer);

    double getDiscountedFee(Customer customer);
}
  • Customer資料物件
public class Customer {
    private boolean isVIP;

    public boolean isVIP() {
        return isVIP;
    }

    public void setVIP(boolean VIP) {
        isVIP = VIP;
    }
}
  • BookStore操作物件
public class BookStore {
    private ICheckInFee checkInFee;
    private double income = 0;

    public BookStore(ICheckInFee checkInFee) {
        this.checkInFee = checkInFee;
    }

    public void checkInFee(List<Customer> customers) {
        for (Customer customer : customers) {
            boolean isVIP = customer.isVIP();

            if (isVIP) {
                this.income += this.checkInFee.getDiscountedFee(customer);
            } else {
                this.income += this.checkInFee.getFee(customer);
            }
        }
    }

    public double getIncome() {
        return income;
    }
}

以上是個簡單的書店物件,用來計算VIP和一般顧客的費用,以這個程式的測試觀點,checkInFee方法是不返回任何值的,唯一能獲取income資料的是getIncome方法,然而要測試checkInFee方法是否功能正常,我們則要檢驗BookStore與ICheckInFee間的互動,並且在是否為VIP時,各自呼叫哪個方法,程式碼如下。
  • MockCheckInFee物件
public class MockCheckInFee implements ICheckInFee {
    public int getFee_called_counter = 0;
    public int getDiscountedFee_called_counter = 0;

    @Override
    public int getFee(Customer customer) {
        this.getFee_called_counter++;
        return 1000;
    }

    @Override
    public double getDiscountedFee(Customer customer) {
        this.getDiscountedFee_called_counter++;
        return 1000 * 0.8;
    }
}
  • BookStoreTest測試物件
public class BookStoreTest {

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

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

        // Assert
        assertThat(mockCheckInFee.getFee_called_counter, equalTo(2));
        assertThat(mockCheckInFee.getDiscountedFee_called_counter, equalTo(4));
    }

    @Test
    public void testGetIncome() throws Exception {
        // Arrange
        MockCheckInFee mockCheckInFee = new MockCheckInFee();
        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;
    }
}

我們讓MockCheckInFee除了擔任Stub所需要的角色外,我們同時還多了getFee_called_counter與getDiscountedFee_called_counter這兩個成員變數記錄各自方法被呼叫了幾次,提供足夠的資訊讓測試程式了解,這就是Mock強大的功能囉!最後,我們也完成這次的測試,如下圖。


小結

至目前為止,我們討論完剩下的測試替身了,它們是開發者的測試工具,也沒有硬性規定只能使用哪一種,甚至混合使用或身兼多職都是可以的。當然,我們還是有些啟發式準則能知道這些測試替身出現的合適場景。
  • 如果你關心的是測試物件與其他物件的交互,而且你著重協作者的方法調用,你就有可能需要一個Mock物件。
  • 如果你的環境很複雜,你又想執行這個複雜的環境,那可以考慮使用Fake物件。
  • 如果你想使用Mock,但又想精簡它,可能可以考慮簡化成Spy物件。
  • 如果你的被測物件只關心合作物件帶給你的值,可以使用Stub物件就好。
還是覺得太難記?這裡有些好記的方式,Stub/Fake和Spy/Mock物件差異在於,我們不會對前者斷言,卻會對後者斷言;Stub管的是值的查詢,Mock則是管方法操作。持續練習單元測試吧!

Source Code

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

2016年12月4日 星期日

Testing: 解除依賴!測試替身與依賴注入

前言

至目前為止,我們已經了解為何要做單元測試、建立單元測試的環境、如何撰寫簡單的測試,以及斷言工具,接下來要討論的是關於如何讓我們的單元測試容易測試,會提到何謂依賴、Stub測試替身,以及建構子依賴注入三個議題。

何謂依賴?

我們認識一些關於單元測試的基礎知識後,現在來看看如何控制外部的依賴關係。什麼是依賴?在物件導向的觀點中,當一個物件與另一個物件之間有互動、呼叫、使用、委派等等的關係,都是屬於一種依賴關係,然而這些依賴有強弱之分,若是不當的設計產生高耦合,當你在測試時無法順利的切換或控制依賴的物件時,這時就有重構的必要,並且需要讓你的程式變得可測試或更有彈性。尤其涉及物件的生成時,我們將物件的產生交由給某個物件,這時控制權就是在這個物件身上,當你要測試時,就無法有彈性的抽換測試,以我們先前使用的FileParser為例,我稍微把FileParser弄得複雜一點,變成以下這樣。
  • 原始的FileParser
public class FileParser {

    public boolean isValidLogFileName(String fileName) {
        if (fileName == null || fileName.length() == 0) {
            throw new IllegalArgumentException("請提供檔名!");
        }

        return fileName.toLowerCase().endsWith(".exe");
    }
}
  • 修改後的FileParser
public class FileParser {

    public FileParser() {}

    public boolean isValidLogFileName(String fileName) {
        FileExtensionManager fileExtensionManager = new FileExtensionManager();

        return fileExtensionManager.isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
    }
}

我們把原本isValidLogFileName的邏輯抽出到FileExtensionManager這個物件,並且多加了FileHelper的靜態方法,來判斷檔名是否超過五個字,以下為FileExtensionManager與FileHelper的程式碼。
  • FileExtensionManager
public class FileExtensionManager {

    public boolean isValid(String fileName) {
        if (fileName == null || fileName.length() == 0) {
            throw new IllegalArgumentException("請提供檔名!");
        }

        // 假設需要連接資料庫、檔案系統或是網路來判斷回傳值,但為求簡單,直接回傳false,表示連接失敗。
        return false;
    }
  • FileHelper
public class FileHelper {

    public static String basenameWithoutExtension(String fileName) {
        String basename = new File(fileName).getName();

        if (basename.contains(".")) {
            basename = basename.substring(0, basename.indexOf("."));
        }

        return basename;
    }
}

原本的邏輯抽離到FileExtensionManager物件,然而這個物件我們假設它需要使用資料庫、檔案系統或是網路等外部資源,然後透過這些方式處理檔名後,再回傳布林值。這裡為了簡化,我們就不實作外部資源相關的程式碼,直接回傳一個false,表示連接以上的方式造成失敗;FileHelper就單純取得副檔名小數點前的檔名,並且回傳而已。

看起來似乎沒什麼問題,程式碼也非常簡單,但從設計所帶來的問題,FileParser對FileExtensionManager產生了依賴,變得難以測試,如下圖。為什麼?首先,我們直接生成了FileExtensionManager物件,再來FileExtensionManager的程式碼使用了外部環境的依賴,記得單元測試的特性?其中之一就是"它應該要獨立於其他測試的運行",若是被測單元使用一個或多個真實環境或依賴物件,那這就是整合測試,你的測試程式碼無法根本無法控制這些外部資源。


該如何讓FileParser變得好測試?在物件導向的設計方向中,我們可以使用一個間接層,透過介面化的方式,將這層邏輯給抽離出來,這樣就能好測試了嗎?我們先來看看抽出一個介面會變成什麼樣子,類別圖與程式碼如下。


public interface IExtensionManager {
    boolean isValid(String fileName);
}

public class FileExtensionManagerImp implements IExtensionManager {

    @Override
    public boolean isValid(String fileName) {
        return false;
    }
}

public class FileParser {

    public FileParser() {}

    public boolean isValidLogFileName(String fileName) {
        IExtensionManager fileExtensionManager = new FileExtensionManagerImp();

        return fileExtensionManager.isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
    }
}

稍微重構了一下,但是這樣修改跟之前的程式碼不是差不多?我還是沒辦法測試不是嗎?沒錯,所以這時我們就需要使用測試替身和依賴注入的方式,來協助我們完成FileParser的測試。

測試替身 - 先論Stub

所謂的測試替身就是創建一系列「僅供測試」的工具物件,用來隔離被測程式碼、加速執行測試、使隨機行為變得確定、模擬特殊情況,以及使測試存取物件時能隱藏訊息。測試替身有Stub、Fake、Spy、Mock四種,如下圖。


Stub在中文有人翻譯為測試樁或測試殘片,但我直接統一用Stub。以上四種都是一種模擬資料物件,Stub比較簡潔,通常也沒做什麼事,就實作介面後,回傳預設值就好,所以我們可以用上面的程式碼產生一個Stub,類別圖與程式碼如下。

public class StubExtensionManager implements IExtensionManager {
    public boolean shouldExtensionsBeValid;

    @Override
    public boolean isValid(String fileName) {
        return this.shouldExtensionsBeValid;
    }
}

很簡單吧!直接實作IExtensionManager介面,現在就產生好一個模擬的測試資料物件,接下來就是要把原本FileParser內的抽換掉,這時需要利用依賴注入的技巧來幫我們達成。

依賴注入 - 先論建構子依賴注入

所謂的依賴注入(Dependency Injection, DI)就是在你的測試單元中注入一個模擬物件,藉由這個模擬物件協助你完成測試。依賴注入常用的技巧有下列五種方式,這邊我們先以建構子這種方式來實現,如下。

  • 建構子
  • Setter方法
  • 工廠類別
  • 工廠方法
  • 映射

建構子這個定義我就不說明,相信大家都知道。所謂的建構子注入,就是我們將要傳進來的物件,藉由建構子聲明來傳入,以下是程式碼。

public class FileParser {
    private IExtensionManager fileExtensionManager;

    public FileParser(IExtensionManager fileExtensionManager) {
        // 使用建構子注入方便傳入測試物件
        this.fileExtensionManager = fileExtensionManager;
    }

    public boolean isValidLogFileName(String fileName) {
        // 原本產生依賴的程式碼,為求方便,我直接註解來觀察差異。
        // IExtensionManager fileExtensionManager = new FileExtensionManagerImp();

        return this.fileExtensionManager.isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
    }
}

我把原本的程式給註解,方便觀察差異。我直接在建構子中,聲明了要傳入一個IExtensionManager的型態物件,這樣就可以隨時抽換要使用的測試替身。從FileParser的觀點,其實就只需要IExtensionManager回傳布林值,true或false這兩種情況,至於是不是透過外部資源來獲得這兩個值,就不該是FileParser所需關注的,這個又稱為關注點分離。接下來,就來完成測試吧!

完成測試
測試有四種情況,我們根據下列情況完成這四種測試,條件與程式碼如下。


isValid檔名字元是否超過5測試結果
Case1truetruetrue
Case2truefalsefalse
Case3falsetruefalse
Case4falsefalsefalse

public class FileParserTest {

    @Test
    public void testNameShorterCharactersIsValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        StubExtensionManager fake = new StubExtensionManager();
        fake.shouldExtensionsBeValid = true;
        FileParser fileParser = new FileParser(fake);

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

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

    @Test
    public void testNameShorterThan6CharactersIsValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        StubExtensionManager fake = new StubExtensionManager();
        fake.shouldExtensionsBeValid = true;
        FileParser fileParser = new FileParser(fake);

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

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

    @Test
    public void testNameShorterCharactersIsNotValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        StubExtensionManager fake = new StubExtensionManager();
        fake.shouldExtensionsBeValid = false;
        FileParser fileParser = new FileParser(fake);

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

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

    @Test
    public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension() throws Exception {
        // Arrange
        StubExtensionManager fake = new StubExtensionManager();
        fake.shouldExtensionsBeValid = false;
        FileParser fileParser = new FileParser(fake);

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

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

順利通過單元測試啦!以上的單元測試就是針對那四種情況設計的,可以自己練習看看,執行結果如下。


小結

我們順利完成測試,大概也知道若遇到依賴關係時,我們該如何抽離成介面、建立測試替身,以及藉由建構子依賴注入讓我們的測試更容易,接下來的內容會討論其他測試替身與依賴注入的方法,讓我們持續學習單元測試吧!

Source Code

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

Testing: AssertThat與Hamcrest匹配器的使用

前言

在Testing: Assert類別的使用一文中,我們已經知道Assert類別提供許多簡單、易懂的斷言方法,協助開發者撰寫測試案例時的判斷依據。接著,這篇內容會提到assertThat的使用,讓撰寫測試時的斷言可讀性更高。

AssertThat與Hamcrest匹配器(Matcher)

assertThat語法是另一種斷言方式,需要和Hamcrest工具一起使用。而所謂的Hamcrest是一組API和匹配器的實作,在Android Studio就可以直接使用它,用來將物件與各種期望進行匹配,再結合assertThat語法,讓測試程式更好讀。這樣說明好像有點抽象,先前的斷言語法,我們大概了解怎麼使用,但有時候測試的判斷會更為複雜,我們這時就可以利用assertThat與Hamcrest匹配器。寫個程式,讓我們可以觀察使用assertEquals和assertThat之間的差異。假設有個名為AppendWordAndContent的類別,有個appendWordAndContent方法,程式碼如下。

public class AppendWordAndContent {

    public String appendWordAndContent(String appendingWord, String content) {
        StringBuilder sb = new StringBuilder();
        sb.append(appendingWord).append(" ").append(content);

        return sb.toString();
    }
}

在AppendWordAndContent方法,提供一個將word和content兩個字串合併的方法,測試的方式如下。

    @Test
    public void testOutputHasLineNumbersCase1WithAssertTrue() throws Exception {
        // Arrange
        String content = "這是assertThat的測試案例。";

        // Act
        String output = this.appendWordAndContent.appendWordAndContent("1st", content);

        // Assert
        assertTrue(output.indexOf("1st") != -1);
    }

我們使用了assertTrue(output.indexOf("1st") != -1); 其實在assert裡面使用判斷不是很好,加上也不夠直覺,可讀性滿差的,可以改用AssertThat(actual, matcher)這種斷言方式來聲明,程式碼如下。

    @Test
    public void testOutputHasLineNumbersCase1WithAssertThat() throws Exception {
        // Arrange
        String content = "這是assertThat的測試案例。";
        int NO_RESULT = -1;

        // Act
        String output = this.appendWordAndContent.appendWordAndContent("1st", content);

        // Assert
        assertThat(output.indexOf("1st"), is(not(NO_RESULT)));
    }

在改寫後的程式中,做了兩件事情。首先將-1這樣的魔術數字把它改成一個具有意義的的變數”NO_RESULT”,當結果等於NO_RESULT時視為找不到結果,再來,!=的地方改成了is(not(NO_RESULT)),最後就變成了assertThat(output.indexOf("1st"), is(not(NO_RESULT))),這樣就變得好讀許多。

Ham crest匹配器

這裡提供幾個好用的匹配器,有興趣可以自行運用看看。
  • Iterable匹配,程式如下。
    • hasItems(T... items)
    @Test
    public void testHasItems() throws Exception {
        List<Integer> testIntArray = Arrays.asList(1, 2, 3, 5);

        assertThat(testIntArray, hasItems(5, 3, 2));
    }
  • 字串匹配,程式如下。
    • startsWith(java.lang.String prefix)
    @Test
    public void testStartsWith() throws Exception {
        String word = "preview";

        assertThat(word, startsWith("pre"));
    }
    • endsWith(java.lang.String suffix)
    @Test
    public void testEndsWith() throws Exception {
        String fileName = "word.exe";

        assertThat(fileName, endsWith("exe"));
    }
    • containsString(java.lang.String substring)
    @Test
    public void testContainsString() throws Exception {
        String content = "1st" + " " + "Unit Test Sample!";

        assertThat(content, containsString("1st"));
    }
  • 條件匹配,程式如下。
    • is(T value)
    @Test
    public void testIs() throws Exception {
        String content = "1st" + " " + "Unit Test Sample!";

        assertThat(content.charAt(2), is('t'));
    }
    • anyOf(java.lang.Class<T> type)
    @Test
    public void testAnyOf() throws Exception {
        String content = "1st" + " " + "Unit Test Sample!";

        assertThat(content, anyOf(startsWith("1st"), startsWith("1")));
    }
    • instanceOf(java.lang.Class<?> type)
    @Test
    public void testInstanceOf() throws Exception {
        Exception e1 = new IllegalArgumentException();

        assertThat(e1, instanceOf(IllegalArgumentException.class));
    }
    • nullValue()
    @Test
    public void testNullValue() throws Exception {
        Object object = null;

        assertThat(object, nullValue());
    }

另外,你也可以匯入Hamcres-All的函式庫,讓你的程式引用完整的匹配器,gradle設定如下。更多的使用方式,大家可以自行查詢官方文件,以下僅舉一個範例。

    testCompile 'org.hamcrest:hamcrest-all:1.3'

    @Test
    public void testHasSize() throws Exception {
        List<Integer> testIntArray = Arrays.asList(1, 2, 3, 5);

        assertThat(testIntArray, hasSize(4));
        assertThat(testIntArray, contains(1, 2, 3, 5));
        assertThat(testIntArray, containsInAnyOrder(2, 3, 5, 1));
    }

客製化匹配器

如果上述提供給您的匹配器不敷使用,想要客製化也是可以的,我們可以來實作看看。實作一個匹配器需要繼承BaseMatcher這個抽象類別,並且實作matches和describeTo這兩個方法,實作方式如下。

首先我們先建立一個Role的資料物件,做為之後匹配器的測試,程式碼如下。

public class Role {
    private String id;

    public Role(String id) {
        this.id = id;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }
}

再來建立一個名為RoleOf的匹配器,並且繼承BaseMatcher<Role>。matches方法的參數item是當你使用assertThat時傳進來的物件,我們就要依據這個物件實作你要的邏輯,這邊就判斷員工編號(id)是否以”a”開頭;在describeTo方法內,我們則是可以在測試失敗時,欲顯示的訊息,程式碼如下。

    public class RoleOf extends BaseMatcher<Role> {

        @Override
        public boolean matches(Object item) {
            if (!(item instanceof Role)) {
                return false;
            }

            Role role = (Role) item;
            if (role.getId() == null || role.getId().length() == 0) {
                return false;
            }

            return role.getId().toLowerCase().startsWith("a") ? true : false;
        }

        @Override
        public void describeTo(Description description) {
            description.appendText("管理人員的編號應以a為首!");
        }
    }

執行測試,程式碼與結果如下。

    @Test
    public void testRoleOf() throws Exception {
        Role role = new Role("b123456789");

        assertThat(role, new RoleOf());
    }

java.lang.AssertionError:
Expected: 管理人員的編號應以a為首!
     but: was <com.xy.unittestsample.sample3.Role@593634ad>
Expected :管理人員的編號應以a為首!

Actual   :<com.xy.unittestsample.sample3.Role@593634ad>

我們可以看到測試失敗了,原因在於測試內容並非為a開頭的字串,錯誤訊息也正確顯示出來,但這邊卻只顯示Actual   :<com.xy.unittestsample.sample3.Role@593634ad>,這樣我們無法正確得知是什麼錯誤,所以我們可以用下面的方式改進,覆寫describeMismatch這個方法即可,程式如下。

    public class RoleOf extends BaseMatcher<Role> {

        @Override
        public boolean matches(Object item) {
            if (!(item instanceof Role)) {
                return false;
            }

            Role role = (Role) item;
            if (role.getId() == null || role.getId().length() == 0) {
                return false;
            }

            return role.getId().toLowerCase().startsWith("a") ? true : false;
        }

        @Override
        public void describeTo(Description description) {
            description.appendText("管理人員的編號應以a為首!");
        }

        @Override
        public void describeMismatch(Object item, Description description) {
            super.describeMismatch(item, description);

            if (item == null) {
                description.appendText("物件是空的!");
            }

            Role role = (Role) item;
            description.appendText("該角色為一般人員!").appendText("員工編號為: ").appendText(role.getId());
        }
    }

java.lang.AssertionError:
Expected: 管理人員的編號應以a為首!
     but: was <com.xy.unittestsample.sample3.Role@593634ad>該角色為一般人員!員工編號為: b123456789
Expected :管理人員的編號應以a為首!

Actual   :<com.xy.unittestsample.sample3.Role@593634ad>該角色為一般人員!員工編號為: b123456789

看到了?這邊將實際的員工編號顯示出來,這樣才方便我們去重構錯誤的程式!最後我們就使用一個以”a"開頭的員工編號,讓這個測試通過吧!程式如下。

    @Test
    public void testRoleOfIsCorrect() throws Exception {
        Role role = new Role("a123456789");

        assertThat(role, new RoleOf());
    }

但看看上面的測試,你可能覺得這樣的寫法怎麼不像Hamcrest匹配器那樣,當然你可以用一個靜態工廠方法來達到同樣的目的,程式碼與測試如下。

public class RoleMatcher {

    public static Matcher<Role> isAdmin() {
        return new RoleOf();
    }
}

    @Test
    public void testRoleOfIsCorrectWithFactoryMethod() throws Exception {
        Role role = new Role("a123456789");

        assertThat(role, isAdmin());
    }

小結

以上我們知道該如何利用assertThat與Macher讓我們的測試程式的可讀性更高,相信多練習,我們一定可以將測試程式寫得更好,希望大家能多多練習囉!

Source Code

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