【單元測試】FFF模擬框架

1. 前言

在學習單元測試的過程中,使用「模擬框架」隔離依賴是一項必須要掌握的技術。目前模擬框架有很多,琳琅滿目,參差不齊。針對C語言模擬框架的初學者,我推薦FFF框架,因爲該框架簡單,易上手,而且有助於初學者掌握模擬框架的幕後原理。有了基礎,再去學習更復雜、更高端的框架,就遊刃有餘了。

注:在衆多的C語言模擬框架中,FFF框架算是一個比較冷門的框架,至少截止我書稿本文時是如此,在網上鮮有相關資料,可見算不上主流,但FFF框架很適合模擬框架的初學者,這也正是我撰寫本文的目的。

2. FFF框架簡介

官網:https://github.com/meekrosoft/fff

FFF全稱Fake Function Framework,是一個用於單元測試的C語言輕量型模擬框架。整個框架就一個頭文件fff.h,全部用宏定義實現的框架,非常簡潔,你只要include該頭文件,就能使用該框架了。

FFF框架的優點:

  • 很容易創建C語言的存根和模擬對象。
  • 它很簡單,只需包含一個頭文件,就可以開始了。

3. 入門體驗

3.1 下載fff.h頭文件

git clone https://github.com/meekrosoft/fff.git

我們只需要其中的fff.h頭文件即可,其他不需要。

說明:克隆下來的代碼中有個gtest文件夾,這是谷歌的Google Test單元測試框架,FFF框架是模擬框架,需要區分兩者,本文重點講解的是FFF模擬框架,不會涉及單元測試框架。

3.2 初次體驗

直接上例子:

void UI_init(void)
{
    DISPLAY_init();
}

UI_init函數調用了DISPLAY_init接口。在沒有DISPLAY_init接口實現代碼的情況下(只知道接口的聲明,如下所示),如何對UI_init函數進行單元測試?

void DISPLAY_init();

使用FFF框架很容易創建DISPLAY_init模擬函數,只要三行代碼:

#include "fff.h"
DEFINE_FFF_GLOBALS;
FAKE_VOID_FUNC(DISPLAY_init);

完整測試代碼下如下:

// test.c
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

#include "fff.h"
DEFINE_FFF_GLOBALS;
FAKE_VOID_FUNC(DISPLAY_init);

void UI_init(void)
{
    DISPLAY_init();
}

#define ASSERT_EQ(A, B) assert((A) == (B))

int main()
{
    UI_init();
    ASSERT_EQ(DISPLAY_init_fake.call_count, 1);
    return 0;
}

可以編譯成功:

gcc -o test test.c

爲何如此神奇,只要兩個宏(DEFINE_FFF_GLOBALS 和 FAKE_VOID_FUNC)就能模擬出接口,FFF框架幕後到底幹了什麼?要解開謎底,只要進行一次宏展開就能知曉。對test.c進行宏展開:

gcc -E -P test.c >> test.prescan

查看test.prescan展開後的代碼(只列出兩個宏對應的關鍵代碼,略有刪減):

/* 以下是 DEFINE_FFF_GLOBALS 宏展開後的代碼 */
typedef void(*fff_function_t)(void);
typedef struct {
    fff_function_t call_history[(50u)];
    unsigned int call_history_idx;
} fff_globals_t;
fff_globals_t fff;

/* 以下是 FAKE_VOID_FUNC(DISPLAY_init) 宏展開後的代碼 */
typedef struct DISPLAY_init_Fake {
    unsigned int call_count;
    unsigned int arg_history_len;
    unsigned int arg_histories_dropped;
    int custom_fake_seq_len;
    int custom_fake_seq_idx;
    void(*custom_fake)(void);
    void(**custom_fake_seq)(void);
} DISPLAY_init_Fake;
DISPLAY_init_Fake DISPLAY_init_fake;

void DISPLAY_init(void)
{
    if (DISPLAY_init_fake.call_count < (50u)) {
    } else {
        DISPLAY_init_fake.arg_histories_dropped++;
    }
    DISPLAY_init_fake.call_count++;

    if (fff.call_history_idx < (50u))
        fff.call_history[fff.call_history_idx++] = (fff_function_t)DISPLAY_init;;
    if (DISPLAY_init_fake.custom_fake_seq_len) {
        if (DISPLAY_init_fake.custom_fake_seq_idx < DISPLAY_init_fake.custom_fake_seq_len) {
            DISPLAY_init_fake.custom_fake_seq[DISPLAY_init_fake.custom_fake_seq_idx++]();
        } else {
            DISPLAY_init_fake.custom_fake_seq[DISPLAY_init_fake.custom_fake_seq_len - 1]();
        }
    }
    if (DISPLAY_init_fake.custom_fake)
        DISPLAY_init_fake.custom_fake();
}

void DISPLAY_init_reset(void)
{
    memset(&DISPLAY_init_fake, 0, sizeof(DISPLAY_init_fake));
    DISPLAY_init_fake.arg_history_len = (50u);
};

耐心看完以上代碼,基本上就能知道是咋回事了。

  • DEFINE_FFF_GLOBALS 宏定義了一個結構體全局變量,用於記錄、跟蹤函數調用的歷史記錄。

  • FAKE_VOID_FUNC 宏定義是關鍵所在,其語法爲:

    FAKE_VOID_FUNC(fn [,arg_types*])
    
    • 該宏定義了一個名爲fn的模擬函數,模擬函數返回值類型爲void,形參列表爲arg_types(可選項,不填就表示形參爲void)。
    • 該宏還定義了一個名爲fn_fake的結構體全局變量,該結構體包含有關模擬函數fn的所有狀態信息,例如call_count會在每次調用模擬函數fn時遞增。
    • 該宏還定義了一個名爲fn_reset的函數,用於重置模擬函數fn的狀態。fn_reset函數往往是在執行測試用例前(或後)被調用,即setup或teardown中調用,以免影響其他測試用例,或者被其他測試用例影響。

總結起來就是,示例中 FAKE_VOID_FUNC(DISPLAY_init) 宏定義了一個結構體,兩個函數:

  • DISPLAY_init_Fake 結構體
  • void DISPLAY_init(void) 函數
  • void DISPLAY_init_reset(void) 函數

至此,基本上就整明白FFF模擬框架的幕後原理了。

4. 深入學習

接下來,更深入的瞭解下FFF框架。

4.1 模擬函數形參

如果要定義帶有形參的模擬函數,比如:

void DISPLAY_output(char * message);

可以這樣:

FAKE_VOID_FUNC(DISPLAY_output, char *);

測試用例(UI_write_line函數會調用DISPLAY_output接口):

void test(void)
{
    char msg[] = "helloworld";
    UI_write_line(msg);
    ASSERT_EQ(DISPLAY_output_fake.call_count, 1);
    ASSERT_EQ(strcmp(DISPLAY_output_fake.arg0_val, msg), 0);
}

在FAKE_VOID_FUNC宏定義中,函數名之後緊接的是函數的形參列表(示例中是char指針),每個形參在fn_fake結構體中都有argN_val變量與之對應(N從0開始)。欲知代碼詳情,宏展開。

4.2 模擬函數返回值

如果要定義帶有函數返回值的模擬函數,應該使用FAKE_VALUE_FUNC宏,其語法爲:

FAKE_VALUE_FUNC(return_type, fn [,arg_types*]);
  • return_type是模擬函數fn的返回值類型,爲必填項。
  • fn是模擬函數名,爲必填項。
  • arg_types是模擬函數fn的形參列表,爲可選項。

例如:

unsigned int DISPLAY_get_line_capacity();
unsigned int DISPLAY_get_line_insert_index();

可以這樣:

FAKE_VALUE_FUNC(unsigned int, DISPLAY_get_line_capacity);
FAKE_VALUE_FUNC(unsigned int, DISPLAY_get_line_insert_index);

測試用例:

void test(void)
{
    // 設定 DISPLAY_get_line_insert_index 函數預期返回值
    DISPLAY_get_line_insert_index_fake.return_val = 1;

    ASSERT_EQ(DISPLAY_get_line_insert_index(), 1);
}

欲知代碼詳情,宏展開。模擬更復雜的函數,例如:

double pow(double base, double exponent);

可以這樣:

FAKE_VALUE_FUNC(double, pow, double, double);

4.3 重置模擬函數狀態

好的單元測試會隔離每個測試用例,因此重置模擬函數fn的狀態對每個單元測試都至關重要。每個模擬函數fn都有對應的fn_reset接口,用於重置fn的狀態信息和呼叫計數。最好的做法是在測試用例的setup中調用fn_reset以重置模擬函數fn的狀態。例如:

void setup(void)
{
    // Register resets
    RESET_FAKE(DISPLAY_init);
    RESET_FAKE(DISPLAY_clear);
    RESET_FAKE(DISPLAY_output_message);
    RESET_FAKE(DISPLAY_get_line_capacity);
    RESET_FAKE(DISPLAY_get_line_insert_index);
    FFF_RESET_HISTORY();
}

RESET_FAKE 宏會調用相應模擬函數的fn_reset接口以重置fn的狀態。而 FFF_RESET_HISTORY 宏用於重置函數調用歷史記錄,後面章節會講到函數調用歷史記錄。

4.4 模擬函數調用記錄

如果你要測試一個函數,這個函數依次調用了functionA、functionB、functionA接口,想在測試用例中檢查接口調用順序是否符合預期,怎麼測?FFF框架內部維護着所有模擬函數的調用歷史記錄,因此很容易測試。例如:

FAKE_VOID_FUNC(voidfunc2, char, char);
FAKE_VALUE_FUNC(long, longfunc0);

void test(void)
{
    longfunc0();
    voidfunc2();
    longfunc0();

    ASSERT_EQ(fff.call_history[0], (void *)longfunc0);
    ASSERT_EQ(fff.call_history[1], (void *)voidfunc2);
    ASSERT_EQ(fff.call_history[2], (void *)longfunc0);
}

如果要重置函數調用歷史記錄,可以使用 FFF_RESET_HISTORY() 宏,一般是在setup中調用該宏。

4.5 模擬函數參數記錄

默認情況下,框架內部會記錄每個模擬函數的最後十次被調用的參數值,每個僞函數的每個參數值都會記錄。

void test(void)
{
    voidfunc2('g', 'h');
    voidfunc2('i', 'j');
    ASSERT_EQ('g', voidfunc2_fake.arg0_history[0]);
    ASSERT_EQ('h', voidfunc2_fake.arg1_history[0]);
    ASSERT_EQ('i', voidfunc2_fake.arg0_history[1]);
    ASSERT_EQ('j', voidfunc2_fake.arg1_history[1]);
}

注意,RESET_FAKE 會清除對應僞函數的參數歷史記錄。

4.6 模擬函數返回值序列

在單元測試中,有時候會多次調用同一個外部依賴函數,並且期望每次調用都返回不同值。FFF框架實現此操作的方法是,爲模擬函數指定返回值序列。例如:

// faking "long longfunc();"
FAKE_VALUE_FUNC(long, longfunc0);

void test(void)
{
    long myReturnVals[3] = { 3, 7, 9 };
    SET_RETURN_SEQ(longfunc0, myReturnVals, 3);
    ASSERT_EQ(myReturnVals[0], longfunc0());
    ASSERT_EQ(myReturnVals[1], longfunc0());
    ASSERT_EQ(myReturnVals[2], longfunc0());
    ASSERT_EQ(myReturnVals[2], longfunc0());
    ASSERT_EQ(myReturnVals[2], longfunc0());
}

通過使用SET_RETURN_SEQ宏指定返回值序列,模擬函數將按順序返回數組中給出的值。當到達序列的末尾時,模擬函數將繼續無限期地返回序列中的最後一個值。

4.7 宏備忘錄

描述 例子
FAKE_VOID_FUNC(fn [,arg_types*]); 定義一個模擬函數,函數返回值類型爲void,函數名爲fn,函數形參爲arg_types(可選項)。 FAKE_VOID_FUNC(DISPLAY_init);
FAKE_VOID_FUNC(DISPLAY_output_message, const char*);
FAKE_VALUE_FUNC(return_type, fn [,arg_types*]); 定義一個模擬函數,函數返回值類型爲return_type,函數名爲fn,函數形參爲arg_types(可選項)。 FAKE_VALUE_FUNC(int, DISPLAY_get_line_insert_index);
FAKE_VOID_FUNC_VARARG(fn [,arg_types*], …); 定義一個帶有可變參數的模擬函數,函數返回值類型爲void,函數名爲fn,函數形參爲arg_types(可選項)。 FAKE_VOID_FUNC_VARARG(fn, const char*, …)
FAKE_VALUE_FUNC_VARARG(return_type, fn [,arg_types*], …); 定義一個帶有可變參數的模擬函數,函數返回值類型爲return_type,函數名爲fn,函數形參爲arg_types(可選項)。 FAKE_VALUE_FUNC_VARARG(int, fprintf, FILE*, const char*, …)
RESET_FAKE(fn); 重置模擬函數fn的狀態信息。 RESET_FAKE(DISPLAY_init);

4.8 更多學習

更多學習,可以參閱FFF框架官網中的使用手冊(英文):https://github.com/meekrosoft/fff

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章