文章目錄
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