2016年11月29日 星期二

Testing: 第一個單元測試

前言

前一篇Testing: Android環境的單元測試中,我們利用Android Studio的環境,加上用簡單預設的測試案例,介紹了該環境中如何運行單元測試。接下來則要正式寫一個測試案例,並且說明需要認識些什麼?

FileParser程式

由於我不討論測試驅動開發,所以不以該方式來建立我們的程式碼,直接先建立一個名為FileParser的類別,而這個類別有一個isValidLogFileName()的方法,主要是將傳進來的檔名轉成小寫後,檢查檔案名稱是否以”.exe"做結尾,是的話就回傳”true”,反之返回”false”,程式碼如下。

public class FileParser {

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

        return fileName.toLowerCase().endsWith(".exe");
    }
}

運行第一個測試案例

接著,我們建立一個名為FileParserTest1的測試類別,並且新增一個名為testFileNameWithCorrectSuffixInUppercaseIsConsideredValid()的測試案例,用來檢驗輸入一個檔名,是否傳回預期的布林值,建立過程如下圖,程式碼如下。




    @Test
    public void testFileNameWithCorrectSuffixInUppercaseIsConsideredValid() throws Exception {
        // Arrange
        FileParser fileParser = new FileParser();
        String fileName = "Whatever.EXE";
        boolean expectedResult = true;

        // Act
        boolean actualResult = fileParser.isValidLogFileName(fileName);

        // Assert
        assertEquals(expectedResult, actualResult);
    }

從這段程式碼和建立過程中,可以知道撰寫一個測試案例很簡單,透過Android Studio就能為我們產生所需要的案例,並且只需要給予這個測試案例一個名稱,當然命名能表現這個測試的意圖是最好的,雖然名稱很長,卻很容易知道這個測試是為了檢驗一個存在大小寫的檔案名稱是否真能正確的轉為小寫,並且以.exe為結尾。至於該不該在每個測試案例中以test為開頭,其實不用,因為已經很明顯就是一個測試方法了,只是個人習慣以test為開頭而已。

再來,該如何撰寫一個測試案例的內容?在XUnit的測試模式(Pattern)中,有個3A原則,也就是一個單元測試主要包含三個行為。

  • 準備(Arrange): 產生物件,並且運行必要的設置(例如: Mock、Stub、Spy物件或物件的初始化,若是靜態方法則毋需初始。)。 從上述程式碼也能看見,Arrange就是在進行測試物件的產生、準備傳進去的檔案名稱,以及預期的結果應該為”true”,如下圖。


  • 操作(Act): 針對你的測試物標,進行測試的行為,簡言之,就是針對你的測試目標進行呼叫和使用。Act也就是呼叫了isValidLogFileName方法,並且將檔案名稱作為傳入參數,如下圖。


  • 斷言(Assert): 最後你預期測試目標的某個行為是在你預期的。透過assertEquals的斷言方法,斷言實際結果與預期結果是相等的,如下圖。


最後,來執行看看是否順利通過測試?如下圖所示,我們很順利的通過第一個測試了。


完成其他測試案例

順利完成第一個測試案例,但對目標物件的測試還沒有結束,前一個案例是測試傳入的字串有大小寫字元的夾雜,若將全部轉為小寫,是否也能正常運作?我們繼續完成這個案例,程式碼如下所示。

    @Test
    public void testFileNameWithCorrectSuffixInLowercaseIsConsideredValid() throws Exception {
        // Arrange
        FileParser fileParser = new FileParser();
        String fileName = "whatever.exe";

        // Act
        boolean actualResult = fileParser.isValidLogFileName(fileName);

        // Assert
        assertTrue(actualResult);
    }

這個測試案例與前一個差不多,差別就在我傳入的檔案名稱改成全部小寫,以及斷言的方法改成assertTrue,這個方法只要帶入一個實際的boolean值就可以了。執行後,也是順利通過測試。

測試例外

至目前為止,都是測試一般的狀況,但有時會面臨到例外的情況,像FileParser程式裡面就包含了一個例外,也就是在檔案名稱為空的情形下會拋出IllegalArgumentException,這時又該如何進行測試?同樣地,如同上面的流程建立一個測試檔案名稱為空的案例,並且執行它,程式碼與執行結果如下。

    @Test
    public void testEmptyFileNameResultsInExceptionBeingThrown() throws Exception {
        // Arrange
        FileParser fileParser = new FileParser();
        String fileName = "";

        // Act
        boolean actualResult = fileParser.isValidLogFileName(fileName);

        // Assert
        assertFalse(actualResult);
    }


可以看到測試失敗了,而且不管你怎樣斷言結果,它始終是失敗的,因為這邊已經拋出例外,程式就是無法正常運行,該怎麼斷言這個結果?從這個結果看來,遇到檔案名稱為空的情形下,就預期測試會拋出IllegalArgumentException例外,並且錯誤訊息會顯示”請提供檔名!”。可以重新改寫測試案例,程式碼與執行結果如下。

    @Test
    public void testEmptyFileNameResultsInExceptionBeingThrown() throws Exception {
        // Arrange
        FileParser fileParser = new FileParser();
        String fileName = "";
        String expectedErrorMessage = "請提供檔名!";

        try {
            // Act
            fileParser.isValidLogFileName(fileName);
            fail("這個測試預期拋出IllegalArgumentException例外!");
        } catch (IllegalArgumentException expected) {
            // Assert
            assertEquals(expectedErrorMessage, expected.getMessage());
        }
    }


順利通過測試了,可以觀察一下,我使用try-catch來捕捉例外,並且從catch區域來進行斷言,而在try區域刻意呼叫一個fail方法,確保測試只要順利通過try區域就視為失敗。不過,測試例外還可以使用另一種簡潔的方法,可以透過@Test這個Annotation來斷言拋出的例外,程式碼與執行結果如下。

    @Test (expected = IllegalArgumentException.class)
    public void testExceptionForEmptyFileNameMakesSense() throws Exception {
        FileParser fileParser = new FileParser();
        String fileName = "";
        fileParser.isValidLogFileName(fileName);
    }


測試的程式碼是不更簡潔、乾淨了?無論使用哪種,都是可以的。

Setup與Teardown

完成測試案例後,可以觀察前幾個測試案例,每個都在Arrange的階段中初始FileParser物件,重複對我們來說是很困擾的事情,為了改進這個方式,可以利用Setup方法來為我們在每個案例執行前進行該物件的初始化,這樣就不用在每個測試案例中撰寫重複的程式碼。新增一個FileParserTest2的測試類別,透過右鍵產生一個Setup方法,如下圖。



可以看到setup方法內做的事情很簡單,就是初始化該物件而已,其相對的Annotation為@Before。然而我們有時會需要在每個測試結束時,釋放掉某些資源,這時就可以利用Teardown這個方法,其相對的Annotation為@After,產生方式如同Setup,程式碼如下。

    @After
    public void tearDown() throws Exception {
        // 不建議這樣做,僅為示範。
        this.fileParser = null;
    }

Teardown裡面的事情僅為示範,實際上也不需要這樣釋放這個資源。所以,實際採用Setup和Teardown的測試案例如下。

public class FileParserTest2 {
    private FileParser fileParser;

    @Before
    public void setUp() throws Exception {
        this.fileParser = new FileParser();
    }

    @Test
    public void testFileNameWithCorrectSuffixInUppercaseIsConsideredValid() throws Exception {
        // Arrange
        String fileName = "Whatever.EXE";
        boolean expectedResult = true;

        // Act
        boolean actualResult = this.fileParser.isValidLogFileName(fileName);

        // Assert
        assertEquals(expectedResult, actualResult);
    }

    @Test
    public void testFileNameWithCorrectSuffixInLowercaseIsConsideredValid() throws Exception {
        // Arrange
        String fileName = "whatever.exe";

        // Act
        boolean actualResult = this.fileParser.isValidLogFileName(fileName);

        // Assert
        assertTrue(actualResult);
    }

    @Test
    public void testEmptyFileNameResultsInExceptionBeingThrown() throws Exception {
        // Arrange
        String fileName = "";
        String expectedErrorMessage = "請提供檔名!";

        try {
            // Act
            this.fileParser.isValidLogFileName(fileName);
            fail("這個測試預期拋出IllegalArgumentException例外!");
        } catch (IllegalArgumentException expected) {
            // Assert
            assertEquals(expectedErrorMessage, expected.getMessage());
        }
    }

    @Test (expected = IllegalArgumentException.class)
    public void testExceptionForEmptyFileNameMakesSense() throws Exception {
        String fileName = "";
        this.fileParser.isValidLogFileName(fileName);
    }

    @After
    public void tearDown() throws Exception {
        // 不建議這樣做,僅為示範。
        this.fileParser = null;
    }
}

Beforeclass與Afterclass

介紹完Setup與Teardown後,我們來看看Beforeclass(對應的Annotation為@BeforeClass)與Afterclass(對應的Annotation為@AfterClass)這兩個方法。有些物件有時可能只需要在一開始初始與釋放,不需要每次執行時都做這些事情,這時就可以使用Beforeclass與Afterclass,做個實驗來觀察其差異,程式碼與執行結果如下。

public class BeforeClassAndAfterClassTest {

    @BeforeClass
    public static void beforeClass() throws Exception {
        System.out.println("beforeClass_called");
        System.out.println();
    }

    @Before
    public void setUp() throws Exception {
        System.out.println("setUp_called");
        System.out.println();
    }

    @Test
    public void testcase1() throws Exception {
        System.out.println("testcase1_called");
        System.out.println();
    }

    @Test
    public void testcase2() throws Exception {
        System.out.println("testcase2_called");
        System.out.println();
    }

    @After
    public void tearDown() throws Exception {
        System.out.println("tearDown_called");
        System.out.println();
    }

    @AfterClass
    public static void afterClass() throws Exception {
        System.out.println("afterClass_called");
        System.out.println();
    }
}

執行結果:
beforeClass_called

setUp_called

testcase1_called

tearDown_called

setUp_called

testcase2_called

tearDown_called

afterClass_called

有看到setup與teardown是在每個測試案例執行前後都被呼叫了?而beforeclass與afterclass則是最初與最後才被呼叫,這就是之間的差異。

忽略測試

有時測試程式發生問題,但是你又暫時不想去更動,這時你可以先忽略該測試案例,只讓其他測試案例執行,這時我們可以使用@Ignore這個Annoation,我們可以看看差異,程式碼與執行結果如下。

不使用@Ignore: 

public class IgnoreTest {

    @Test
    public void testcase1() throws Exception {
        System.out.println("testcase1_called");
        System.out.println();
    }

    @Test
    public void testcase2() throws Exception {
        System.out.println("testcase2_called");
        System.out.println();
    }

    @Test
    public void testcase3() throws Exception {
        System.out.println("testcase3_called");
        System.out.println();
    }

    @Test
    public void testcase4() throws Exception {
        System.out.println("testcase4_called");
        System.out.println();
    }

    @Test
    public void testcase5() throws Exception {
        System.out.println("testcase5_called");
        System.out.println();
    }
}

不使用@Ignore的執行結果:

testcase1_called

testcase2_called

testcase3_called

testcase4_called

testcase5_called

忽略case3與case5:

public class IgnoreTest {

    @Test
    public void testcase1() throws Exception {
        System.out.println("testcase1_called");
        System.out.println();
    }

    @Test
    public void testcase2() throws Exception {
        System.out.println("testcase2_called");
        System.out.println();
    }

    @Test
    @Ignore
    public void testcase3() throws Exception {
        System.out.println("testcase3_called");
        System.out.println();
    }

    @Test
    public void testcase4() throws Exception {
        System.out.println("testcase4_called");
        System.out.println();
    }

    @Test
    @Ignore
    public void testcase5() throws Exception {
        System.out.println("testcase5_called");
        System.out.println();
    }
}

使用@Ignore的執行結果:

testcase1_called

testcase2_called

testcase4_called

使用的前後可以看出,測試案例3與測試案例5都被忽略而沒有執行,不過忽略的情形很少,也不建議使用這種方式,因為忽略測試程式碼很容易讓人混淆,會不清楚那些程式碼為何存在卻又不刪除。

組合測試

在某些情況,我們的測試類別可能會很多,但有時想執行部分的測試類別(例如: 當你只重構A與B類別,但是其他類別完全沒有更動過。),這時我們可以使用測試套件(Test Suite)組合你想要執行的測試類別,程式碼與執行結果如下。

@RunWith(Suite.class)
@Suite.SuiteClasses({
        FileParserTest1.class,
        FileParserTest2.class,
        IgnoreTest.class
})
public class Sample1TestSuite {
}


看到了?我們只執行了部分的測試類別,其餘未加入測試套件的都沒有被執行。@RunWith這個Annotation表示這個類別是以Suite來執行,@Suite.SuiteClasses()裡面則是以陣列的方式存放你要測試的類別名稱,以上述為例,這個組合測試就只測FileParserTest1、FileParserTest2與IgnoreTest這三個而已。

小結

至目前為止,我們已經知道該如何撰寫一個測試案例、3A原則、測試例外、Setup, Teardown, Beforeclass與Afterclass、忽略測試與組合測試這幾個議題,接下的內容會介紹重要的單元測試內容,讓我們慢慢來練習囉!

Source Code

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

沒有留言:

張貼留言