2016年12月4日 星期日

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

沒有留言:

張貼留言