[C 語言] 程式設計教學:撰寫簡易的測試程式 (Test Programs)

【分享本文】
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

    前言

    測試程式 (test programs) 是用於確認主程式是否正確運行的程式,程式實際發布時不會隨著主程式發布出去,算是在開發期間輔助開發的程式。理論上,即使完全不寫測試程式,也不影響程式的發布;但我們總是要透過一些手段確認程式正確運行,故我們或多或少會寫一些測試程式。

    撰寫測試程式是一個先苦後甘的過程,一開始會增加額外的工作量,但隨著程式碼增加,測試程式可以協助我們確認那些部分是需要修改的、那些部分不需要更動。我們日後在重構 (refactoring) 主程式時,透過先前寫的測試程式,可以很快地確認翻新後的實作是否符合預期的行為。

    許多現代高階語言加入測試相關的功能,像是 Go (golang) 的 testing 套件即內建在標準函式庫中。C 語言本身沒有測試相關的功能,有些第三方方案像是 CUnit 可以補足這方面的功能。不過,這類方案需要引入外部相依函式庫,本文介紹一個相對簡單且不需外部函式庫的方式。

    測試程式的寫法

    根據主程式類型的不同,測試程式的寫法也相異。如果主程式是函式庫,就寫一個呼叫該函式庫的外部程式即可,這應該是最單純的情境;如果主程式是終端機程式,可以視情境檢查其狀態碼或是用外部程式去抓住其輸出後對輸出解析;如果主程式是網頁程式,可以用 HTTPie 去模擬 HTTP 動作或是用 Selenium 寫爬蟲去爬自己寫的網站;如果主程式是圖形介面程式,就可能會用到 Sikuli 之類的程式去操作圖形程式。

    寫測試程式的原則是儘量減少人為介入,能用 assert 巨集檢查的程式就不要輸出到終端機人工檢閱。理想的測試程式是在輸入 make test 後完全不需要人為介入即可跑完所有測試程式並確認主程式是否正確。Makefile 的部分,需要自己撰寫設定去串連,不在本文的討論範圍之內,讀者可到這裡學習 GNU Make 的使用方式。

    測試 C 語言函式庫

    在本節中,我們引入兩個 C 巨集:TESTERROR,前者用來確認測試程式是否正確執行,後者用來發出錯誤訊息。

    TEST 的定義如下:

    #define TEST(test) { \
            if (!(test)) { \
                fprintf(stderr, "(%s:%d) Failed on %s\n", __FILE__, __LINE__, #test); \
                exit(EXIT_FAILURE); \
            } \
        }
    

    TEST 巨集接受一個參數 test,當 test 在條件句中為偽 (false) 時,會印出 test 所在的位置並終止程式,回傳程式狀態錯誤的狀態碼。

    ERROR 的定義如下:

    #define ERROR(msg, ...) { \
            fprintf(stderr, "(%s:%d) " msg "\n", __FILE__, __LINE__, ##__VA_ARGS__); \
        }
    

    ERROR 除了表示錯誤訊息的 msg 參數外,後面還帶有不定數量的參數 ...,這些不定參數是為了配合 msg 內的格式化輸出。ERROR 巨集不會中止程式,只是在終端機吐出錯誤訊息。

    假定我們在檢查動態陣列,其測試程式如下:

    bool test_array_is_empty(void);
    bool test_array_push(void);
    bool test_array_unshift(void);
    bool test_array_at(void);
    bool test_array_set_at(void);
    bool test_array_from_builtin(void);
    bool test_array_to_builtin(void);
    bool test_array_contains(void);
    bool test_array_shift(void);
    bool test_array_pop(void);
    bool test_array_insert_at(void);
    bool test_array_remove_at(void);
    bool test_array_shrink(void);
    bool test_array_iter(void);
    
    int main(void)
    {
        TEST(test_array_is_empty());
        TEST(test_array_push());
        TEST(test_array_unshift());
        TEST(test_array_at());
        TEST(test_array_set_at());
        TEST(test_array_from_builtin());
        TEST(test_array_to_builtin());
        TEST(test_array_contains());
        TEST(test_array_shift());
        TEST(test_array_pop());
        TEST(test_array_insert_at());
        TEST(test_array_remove_at());
        TEST(test_array_shrink());
        TEST(test_array_iter());
    
        return 0;
    }
    
    // Implement test functions here.
    

    由此可知,測試程式由許多測試函式所組成,當其中一個測試函式錯誤時,即會引發測試程式的錯誤並提早結束測試程式。典型的測試框架會試著跑完所有的測試,不會在引發單一錯誤時即結束測試程式;這是因為早期編譯軟體的速度很慢,會想儘可能地在單一編譯中抓出最多的錯誤;現在編譯軟體的速度比以前快多了,這一點就不是我們最大的考量。

    我們來看一個測試 array_set_at 函式的測試函式:

    bool test_array_set_at(void)
    {
        array_int_t *arr = array_int_new();
        if (!arr) {
            ERROR("Failed to allocate an array");
            goto ERROR;
        }
    
        int data[] = {3, 4, 5};
        for (size_t i = 0; i < sizeof(data) / sizeof(int); i++) {
            if (!array_int_push(arr, data[i])) {
                ERROR("Failed to push data");
                goto ERROR;
            }
        }
    
        array_int_set_at(arr, 1, 99);
    
        int data1[] = {3, 99, 5};
        for (size_t i = 0; i < sizeof(data1) / sizeof(int); i++) {
            int temp = array_int_at(arr, i);
            if (!(temp == data1[i])) {
                ERROR("Wrong data %d", temp);
                goto ERROR;
            }
        }
    
        array_int_free(arr);
    
        return true;
    
    ERROR:
        if (arr)
            array_int_free(arr);
    
        return false;
    }
    

    這個測試函式的行為相當簡單,先建立一個動態陣列物件 arr,在更動其元素的前後分別讀取其所有元素的值,就可以確認 array_int_set_at 的確有正常運作。

    這個測試函式在正常運作時會跑完所有的流程、釋放 arr 物件的記憶體、回傳真 (true);但異常時會跳到 ERROR 標籤所在的位置、清除資源、回傳偽 (false)。藉由測試函式的回傳值就可確認該測試函式是否正常運作。

    測試 C 語言終端機程式

    對 C 語言來說,編譯出來的終端機程式是獨立的外部程式,無法直接存取其內的函式。對於外部程式來說,可檢查 (1) 狀態碼 (2) 終端機輸出。

    測試狀態碼的方式是在執行完該終端機程式後馬上檢查系統狀態碼,如節錄以下 Makefile:

    test:
            ./program
            if [ $$? -ne 0 ]; then echo "Failed program state"; exit 1; fi
    

    透過檢查內建環境變數 $? 是否為零,可知程式是否正確運行。一般終端機程式會將零視為程式正確運行,其他的數字則會隨不同的系統解讀相異。

    檢查終端機輸出則較複雜,要先收集程式的標準輸出和標準錯誤,再寫程式去判讀這些輸出的內容。直接用 C 手刻這類檢查程式比較辛苦,筆者在類 Unix 系統上寫終端機程式時,通常會搭配 bats 測試框架來檢查終端機程式的輸出。bats 是以 Bash 寫成的,而幾乎所有的類 Unix 系統都有 Bash,安裝上相當容易。

    以下 shell 命令稿以 bats 檢查 Hello World 程式:

    #!/usr/bin/env bats
    
    PROGRAM=hello
    DIST_DIR=dist
    
    @test "Test main program" {
        run ./$DIST_DIR/$PROGRAM
        [ "$output" == "Hello World" ]
    }
    

    雖然這個測試程式很短,但可看出來 bats 的作者相當具有巧思,將 Bash 命令稿變成一種用於測試的領域專用語言 (DSL)。更多 bats 的用法請前往 bats 專案主網站。

    不過,Windows 系統沒有 Bash,故無法使用 bats 框架;目前筆者也沒有找好良好的終端機程式測試框架,雖然筆者先前試著用 VBScript 寫,但實際上效果不佳,這裡就不展示出來了。

    【分享本文】
    Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email
    【追蹤新文章】
    Facebook Twitter Plurk