2016年11月30日 星期三

Testing: Assert類別的使用

前言

前幾篇文章,讓我們對單元測試有了基本認識,從前面的練習可以稍微知道單元測試需要做哪些事情,可以透過Annotation幫我們在測試上提供程序上的輔助。目前我們都可以順利地對程式進行測試,接下來稍微認識一下Assert類別的用法,看看這個它能提供些什麼?

Assert類別

Assert類別提供許多靜態(static)方法,讓我們可以用來聲明某個特定的假設應該成立。為何要用這個類別?我們也可以在測試程式中加入if-else的判斷,就像先前提到的,我可以像手動測試一樣,寫些判斷,不是也能達到同樣目的?還記得單元測試應該就是要簡單、易讀?加上單元測試也不該出現判斷語句,因為當你出現判斷邏輯時,也表示你應該對它進行測試,這樣不就沒完沒了?所以我們應該使用Assert類別,並且當你測試失敗時,它會對你提出警告,藉由Assert類別在斷言失敗時給予你什麼訊息,好讓你對程式碼進行修正。

  • assertEquals
    • assertEquals(String message, Object expected, Object actual)
    • assertEquals(Object expected, Object actual)
    • assertEquals(String message, String expected, String actual)
    • assertEquals(String expected, String actual)
    • assertEquals(String message, double expected, double actual, double delta)
    • assertEquals(double expected, double actual, double delta)
    • assertEquals(String message, float expected, float actual, float delta)
    • assertEquals(float expected, float actual, float delta)
    • assertEquals(String message, long expected, long actual)
    • assertEquals(long expected, long actual)
    • assertEquals(String message, boolean expected, boolean actual)
    • assertEquals(boolean expected, boolean actual)
    • assertEquals(String message, byte expected, byte actual)
    • assertEquals(byte expected, byte actual)
    • assertEquals(String message, char expected, char actual)
    • assertEquals(char expected, char actual)
    • assertEquals(String message, short expected, short actual)
    • assertEquals(short expected, short actual)
    • assertEquals(String message, int expected, int actual)
    • assertEquals(int expected, int actual)
assertEquals我們很常用,主要是在斷言原生資料型態(primitive)與物件的相等,就類似我們平常在程式中使用obj1.equals(obj2)是同樣的。而有些assertEquals裡面可以置入訊息(message),也就是當你斷言失敗時,你希望提供什麼訊息,範例如下。

    @Test
    public void testAssertEquals() throws Exception {
        String s1 = new String("Test");
        String s2 = new String("Test");

        assertEquals(s1, s2);
    }

    @Test
    public void testAssertEqualsWithMessage() throws Exception {
        String s1 = new String("Test1");
        String s2 = new String("Test2");

        assertEquals("測試結果", s1, s2);
    }

junit.framework.ComparisonFailure: 測試結果
Expected :Test1
Actual   :Test2

  • assertSame
    • assertSame(String message, Object expected, Object actual)
    • assertSame(Object expected, Object actual)
  • assertNotSame
    • assertNotSame(String message, Object expected, Object actual)
    • assertNotSame(Object expected, Object actual)
assertSame/assertNotSame就類似我們平常使用(a == b) / (a != ib)是同樣的,是用來判斷兩個物件的實體或參照的記憶體是否相同,用以下案例就可以知道與assertEquals的差異了,程式碼如下。

public class AssertSameTest {

    @Test
    public void testAssertSame1() throws Exception {
        // 兩個物件各分配了一個記憶體空間,所以不一樣。
        String s1 = new String("test1");
        String s2 = new String("test1");

        assertNotSame(s1, s2);
    }

    @Test
    public void testAssertSame2() throws Exception {
        // s2參照s1同一個記憶體空間,所以一樣。
        String s1 = new String("test1");
        String s2 = s1;

        assertSame(s1, s2);
    }

    @Test
    public void testAssertSame3() throws Exception {
        // s1分配了一個記憶體空間,而s2使用字串池(String pool),所以不一樣。
        String s1 = new String("test1");
        String s2 = "test1";

        assertNotSame(s1, s2);
    }

    @Test
    public void testAssertSame4() throws Exception {
        // s1與s2都使用字串池,為了節省記憶體空間,只要字串池內的值相同,都是指向同一個記憶體空間,所以一樣。
        String s1 = "test1";
        String s2 = "test1";

        assertSame(s1, s2);
    }
}
  • assertTrue(boolean condition)
  • assertFalse
    • assertFalse(String message, boolean condition)
    • assertFalse(boolean condition)
其實看名稱就能知道斷言某個條件是否為true/false,而assertFalse同樣也能提供當斷言失敗時,所要顯示的訊息,程式碼如下。

public class AssertTrueTest {

    @Test
    public void testAssertTrue() throws Exception {
        boolean actualTrue = true;

        assertTrue(actualTrue);
    }

    @Test
    public void testAssertFalse() throws Exception {
        boolean actualFalse = false;

        assertFalse(actualFalse);
    }

    @Test
    public void testAssertFalseWithMessage() throws Exception {
        boolean actualTrue = true;

        assertFalse("測試失敗", actualTrue);
    }
}

junit.framework.AssertionFailedError: 測試失敗
  • fail
    • fail(String message)
    • fail()
fail就是讓你的測試案例聲明失敗,通常我們會在拋出例外時,在try區域使用,強制在沒有成功拋出例外時聲明,程式碼如下。

    @Test
    public void testFailWithMessage() throws Exception {
        fail("測試失敗");
    }

junit.framework.AssertionFailedError: 測試失敗

    @Test
    public void testFail() throws Exception {
        fail();
    }

junit.framework.AssertionFailedError
  • assertNotNull
    • assertNotNull(Object object)
    • assertNotNull(String message, Object object)
  • assertNull
    • assertNull(Object object)
    • assertNull(String message, Object object)
assertNotNull/assertNull也很容易理解,就是斷言物件是否為Null/Not Null,同樣都提供斷言失敗時,是否提供訊息,程式碼如下。

    @Test
    public void testNull() throws Exception {
        Object obj = null;

        assertNull(obj);
    }

    @Test
    public void testNotNullWithMessage() throws Exception {
        Object obj = null;

        assertNotNull("測試失敗", obj);
    }

junit.framework.AssertionFailedError: 測試失敗
  • assertArrayEquals
與assertEquals類似,也提供測試失敗時的訊息顯示,但用來判斷陣列裡面的內容是否相同,可省去單用assertEquals的麻煩,程式碼如下。

    @Test
    public void testAssertArrayEqualsTest() throws Exception {
        String[] array1 = new String[]{"John", "Xavier", "Same", "Justin"};
        String[] array2 = new String[]{"John", "Xavier", "Same", "Justin"};

        assertArrayEquals(array1, array2);
    }

小結

以上我們了解了Assert類別的用法,你可以把這些斷言方式看做是對各種不同條件的一種文法修飾,讓你在測試案例中,讓程式碼更清晰、易讀,而這些方法也簡單易學、方便、容易記憶。然而,上述的方法都有提供message參數,卻不建議帶入這個參數,原因在於訊息提供在測試框架中都有提供了,這個參數就有點像是程式碼裡的註解而已,反而顯得多餘,如果一定要使用這個參數,還不如將你的測試方法名稱取得更直覺或有意一些,以上只是為了示範而已。

Source Code

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

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

2016年11月28日 星期一

Testing: Android環境的單元測試

前言

在前一篇Testing: 單元測試簡介一文中介紹完單元測試後,也認識了不依賴單元測試框架,而改用手動撰寫單元測試是非常耗時和麻煩的,好不容易讓已寫好的程式碼運行穩定,當下次撰寫新的程式碼時,那些擾人的手動判斷又要重複一次,真是太可怕了。完全手工的方式撰寫,機械化地執行它們,很容易出錯也浪費時間,我們希望能透過自動化建置來解決這些問題,這時我們就需要單元測試框架的輔助。

第一個單元測試

在Android的開發中,撰寫的語言是以Java為基礎,所以測試的框架便是JUnit。當你開啟一個新的Android專案時,你無須引入JUnit任何的函式庫,Gradle當中就已經幫你包含進去了,如下所示。

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])

    androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
        exclude group: 'com.android.support', module: 'support-annotations'
    })

    compile 'com.android.support:appcompat-v7:25.0.1'

    testCompile 'junit:junit:4.12'
}

Android Studio同時也會為你建置一個單元測試案例(Test case),可以先看看這個預設的測試案例具備什麼元素,如下所示。

public class ExampleUnitTest {

    @Test
    public void addition_isCorrect() throws Exception {
        assertEquals(4, 2 + 2);
    }
}

這個測試案例的類別名稱為ExampleUnitTest,並且隸屬於test package底下,而這個package都是放置與單元測試相關的案例,也就是直接運行在JVM層的測試。這個類別包含了一個名為@Test的annotation,測試案例稱為addition_isCorrect(),裡面寫了assertEquals(4, 2 + 2)的程式碼,所以可以知道撰寫一個測試案例至少要具備這些元素。

  • 測試類別: 這個類別主要包含你應有的測試案例。
  • Annotation: 若沒有增加這個@Test這個Annotation,編譯時就會無法得知這個方法是否為測試方法。你也可以試著將這個移除,就會發現原本左邊的測試按鈕會消失,變為一般的方法了,如下圖。

  • Assertion: Assertion的意思為斷言,其實就是斷定你所預期的行為是否正常。

測試結果

當知道具備這些基本元素後,可以試著執行看看,並觀察其結果。在檔案列表中,對著該測試類別點選右鍵,選擇Run ‘XXXXXX’,如下圖。


或是程式碼中針對某個測試案例執行也行,點選方法左邊的執行圖示即可,如下圖。


執行完後的結果如下圖所示,綠燈是通過;紅燈則是失敗。



小結

順利執行完簡單的Android單元測試,對於基本的實作有了初步概念,一個測試案例至少需具備測試類別名稱、Test的Annotation,以及你所斷言的預期行為,下一篇我們要正式進入練習單元測試的領域啦!