pcunit_mock.rbを使ってC言語用のモック(スタブ)を生成する(シンボル重複エラーも解決)
pcunit_mock.rbについて
pcunit_mock.rbはPCUnitで使えるモック(スタブ)生成スクリプトです。
pcunit_mock.rbの主な機能は以下のとおりです。
- モックのソースを生成し、それをテストプロジェクトのビルドに追加することでモックが使用可能になる。
- エクスペクテーション(期待される引数や呼び出し回数や戻り値など)の設定APIを提供
- コールバック関数の登録APIを提供
使い方はREADMEのpcunit_mock.rbを参照してください。
id:ylgbk さんにpcunit_mock.rbの使用例を書いていただいたのでリンクを貼っておきます。わかりやすいモックの解説もあります。
ETロボコンにおけるモックの役割と作り方 - .logbook
ETロボコンで使うAPIをモック化してみた - .logbook
pcunit_mock.rbは、Unity用のモック生成ツールであるCMockを参考にしました。機能的には、CMockとほぼ同じです。
ただし、CMockは生成するモックのコードの内部でエクスペクテーションのオブジェクトの確保にmallocを使用しています。
pcunit_mock.rbは、エクスペクテーションのオブジェクトの確保はテストコード側でローカル変数やstatic変数で行うようにすることでmalloc使用を回避しています。PCUnitは、PC上でテストしたテストコードを、ターゲット上で動作させるのに移植しやすいことをコンセプトにした単体テストツールなので、生成するモックも標準関数依存を取り除いています。
生成コードの解説
モックのAPIで例にあげた、Foo.hのfunc関数のモックのコードが実際にどんな処理をしているか見てみます。
このヘッダファイルFoo.hのモックを生成します。
#ifndef FOO_H_INCLUDED #define FOO_H_INCLUDED int func(int a, const char *str); #endif /* FOO_H_INCLUDED */
pcunit_mock.rbでモックを生成します。
$ ruby path/to/pcunit_mock.rb Foo.h
生成されたモックのヘッダファイルmock_Foo.hは以下のとおりです。
#ifndef MOCK_FOO_H_INCLUDED #define MOCK_FOO_H_INCLUDED #include "Foo.h" void mock_Foo_init(void); #define mock_Foo_verify() mock_Foo_verify_aux(__FILE__, __LINE__) void mock_Foo_verify_aux(const char *file, unsigned int line); typedef struct { int retval; struct { int a; const char *str; } expected; struct { unsigned int a:1; unsigned int str:1; } ignored; } func_Expectation; typedef int (*func_Callback)(int a, const char *str); #define func_expect(expectations, num) func_expect_aux(expectations, num, __FILE__, __LINE__) void func_expect_aux(const func_Expectation *expectations, int num, const char *file, unsigned int line); #define func_set_callback(callback, expected_num_calls) func_set_callback_aux(callback, expected_num_calls, __FILE__, __LINE__) void func_set_callback_aux(func_Callback callback, int expected_num_calls, const char *file, unsigned int line); int func_num_calls(void); #endif /* MOCK_FOO_H_INCLUDED */
呼び出し側のファイル名と行番号がわかるようにAPIがマクロ化されていること以外は、マニュアルに書いてあるとおりです。
生成されたモックのソースファイルmock_Foo.cを見てみます。(実際の出力ファイルに適宜改行とコメントを追記しています)
#include "mock_Foo.h" #include "PCUnit/PCUnit.h" struct mock_Foo_t { /* func_set_callbackで登録した関数ポインタ */ func_Callback func_funcptr; /* 予期されたfuncの呼び出し回数 */ int func_expected_num_calls; /* 実際にfuncを呼び出した回数 */ int func_actual_num_calls; /* func_expectで登録したエクスペクテーションの配列 */ const func_Expectation *func_expectations; /* func_set_callbackまたはfunc_expectを呼んだファイル名 */ const char *func_file; /* func_set_callbackまたはfunc_expectを呼んだ行番号 */ unsigned int func_line; }; /* モックを管理するオブジェクト */ static struct mock_Foo_t mock_Foo; void mock_Foo_init(void) { char *p = (char *) &mock_Foo; size_t i; /* フリースタンディング環境で実行されることを考慮して、 * あえてmemsetを使用せず0クリア */ for (i = 0; i < sizeof mock_Foo; i++) { *(p++) = 0; } } #ifdef _MSC_VER #define LINE_FORMAT "(%u) " #else #define LINE_FORMAT ":%u" #endif void mock_Foo_verify_aux(const char *file, unsigned int line) { /* verifyはteardownで呼ばれるので、テスト関数でアサーション失敗した場合も呼ばれる。 * 2重に失敗を出力しないように、テスト関数で失敗していた場合は即抜ける。 */ if (PCU_test_has_failed()) { return; } if ((mock_Foo.func_expectations || mock_Foo.func_funcptr) && mock_Foo.func_expected_num_calls >= 0) { /* funcの呼び出し回数が予期された回数より少なかった場合に失敗を出力 */ PCU_ASSERT_EQUAL_MESSAGE(mock_Foo.func_expected_num_calls, mock_Foo.func_actual_num_calls, PCU_format("%s" LINE_FORMAT ": Check the number of calls of func().", file, line)); if (PCU_test_has_failed()) { return; } } } static const char *mock_Foo_ordinal(int num) { switch (num) { case 1: return "st"; case 2: return "nd"; case 3: return "rd"; default: return "th"; } } /* funcのテストダブル関数 */ int func(int a, const char *str) { int ret; if (mock_Foo.func_expectations && mock_Foo.func_expected_num_calls >= 0) { /* func_expectでエクスペクテーションを設定した場合 */ const func_Expectation *e; /* funcの呼び出し回数が予期された回数を超えた場合に失敗を出力 */ PCU_ASSERT_OPERATOR_MESSAGE(mock_Foo.func_expected_num_calls, >, mock_Foo.func_actual_num_calls, PCU_format("%s" LINE_FORMAT ": Check the number of calls of func().", mock_Foo.func_file, mock_Foo.func_line)); e = mock_Foo.func_expectations + mock_Foo.func_actual_num_calls; /* 引数の無視設定がされていなければ、引数の値が予期された値かどうかチェック */ if (!e->ignored.a) { PCU_ASSERT_EQUAL_MESSAGE(e->expected.a, a, PCU_format("%s" LINE_FORMAT ": Check the parameter 'a' of func() called for the %d%s time.", mock_Foo.func_file, mock_Foo.func_line, mock_Foo.func_actual_num_calls, mock_Foo_ordinal(mock_Foo.func_actual_num_calls))); } if (!e->ignored.str) { PCU_ASSERT_STRING_EQUAL_MESSAGE(e->expected.str, str, PCU_format("%s" LINE_FORMAT ": Check the parameter 'str' of func() called for the %d%s time.", mock_Foo.func_file, mock_Foo.func_line, mock_Foo.func_actual_num_calls, mock_Foo_ordinal(mock_Foo.func_actual_num_calls))); } /* 戻り値を設定 */ ret = e->retval; } else if (mock_Foo.func_funcptr) { /* func_set_callbackで関数ポインタを設定した場合 */ if (mock_Foo.func_expected_num_calls >= 0) { /* funcの呼び出し回数が予期された回数を超えた場合に失敗を出力 */ PCU_ASSERT_OPERATOR_MESSAGE(mock_Foo.func_expected_num_calls, >, mock_Foo.func_actual_num_calls, PCU_format("%s" LINE_FORMAT ": Check the number of calls of func().", mock_Foo.func_file, mock_Foo.func_line)); } /* 登録した関数を呼び出す */ ret = mock_Foo.func_funcptr(a, str); } else { /* func_expect/func_set_callbackで設定していないのにfuncが呼ばれた場合は失敗 */ PCU_FAIL("Call func_expect() or func_set_callback()."); } mock_Foo.func_actual_num_calls++; return ret; } void func_expect_aux(const func_Expectation *expectations, int num, const char *file, unsigned int line) { mock_Foo.func_expectations = expectations; mock_Foo.func_expected_num_calls = num; mock_Foo.func_file = file; mock_Foo.func_line = line; } void func_set_callback_aux(func_Callback callback, int expected_num_calls, const char *file, unsigned int line) { mock_Foo.func_funcptr = callback; mock_Foo.func_expected_num_calls = expected_num_calls; mock_Foo.func_file = file; mock_Foo.func_line = line; } int func_num_calls(void) { return mock_Foo.func_actual_num_calls; }
funcのテストダブル関数では、func_expectでエクスペクテーションを設定した場合とfunc_set_callbackでコールバック関数を設定した場合とで処理を分岐します。
エクスペクテーションを設定した場合は、呼び出し回数と引数のチェックをし、設定された戻り値を返します。
コールバック関数が登録されている場合は、呼び出し回数のチェックをし、コールバック関数を呼び出します。
どちらも設定されていなければ失敗を出力します。
テストダブル関数とオリジナル関数の共存
C言語のユニットテストでテストダブル関数(スタブやモック)を使うと、テストダブル関数とオリジナルの関数のシンボル重複のリンクエラーが問題になります。*1
http://hccweb6.bai.ne.jp/~hfk45601/ctdd_html/c_tdd1.htmlのサイトの4の(1)の図のような場合です。
同サイトの5.問題点に対する解決方法 や テスト駆動開発による組み込みプログラミングの9章にあるようにプロダクトコードのインターフェイスを関数ポインタに変更すれば解決できます。が、プロダクトコードに手を加えたくない場合もあります。
pcunit_mock.rbでは、プロダクトコードに#ifdef TESTを埋め込んだりインターフェイスの関数ポインタ化をせず、テストダブル関数とオリジナル関数を共存させることができます。
先ほどの例のFoo.hを使って、func()のテストダブル関数とオリジナル関数(Foo.cに定義されている)を共存させるモックを生成してみます。pcunit_mock.rbコマンドの-sオプションを使います。
$ ruby path/to/pcunit_mock.rb Foo.h -s
すると、mock_Foo.cのコードの一部が以下のように変わります。
#include "mock_Foo.h" #include "PCUnit/PCUnit.h" /* funcをfunc_originalに置換して、オリジナルのソースをインクルードすることで、 * シンボル重複を回避 */ int func_original(int a, const char *str); #define func func_original #include "Foo.c" /* 下でfuncのテストダブル関数を定義するので、funcのマクロ定義を解除 */ #undef func struct mock_Foo_t { func_Callback func_funcptr; int func_expected_num_calls; int func_actual_num_calls; const func_Expectation *func_expectations; const char *func_file; unsigned int func_line; }; static struct mock_Foo_t mock_Foo = { func_original, /* デフォルトでオリジナル関数が設定される */ -1, /* 呼び出し回数は無制限の設定 */ }; void mock_Foo_init(void) { char *p = (char *) &mock_Foo; size_t i; for (i = 0; i < sizeof mock_Foo; i++) { *(p++) = 0; } /* デフォルトでオリジナル関数が設定される */ mock_Foo.func_funcptr = func_original; mock_Foo.func_expected_num_calls = -1; } void mock_Foo_verify_aux(const char *file, unsigned int line) { if (PCU_test_has_failed()) { goto end; } if ((mock_Foo.func_expectations || mock_Foo.func_funcptr) && mock_Foo.func_expected_num_calls >= 0) { PCU_ASSERT_EQUAL_MESSAGE(mock_Foo.func_expected_num_calls, mock_Foo.func_actual_num_calls, PCU_format("%s" LINE_FORMAT ": Check the number of calls of func().", file, line)); if (PCU_test_has_failed()) { goto end; } } end: /* 関数ポインタ設定をオリジナル関数に戻す */ mock_Foo_init(); } /* 以下、変更なし */
ソースファイル(Foo.c)をインクルードするというのは通常のCのコーディングではやらないことですが、シンボル重複回避のためにあえて行っています。
やっていることはhttp://hccweb6.bai.ne.jp/~hfk45601/ctdd_html/c_tdd1.htmlの5.問題点に対する解決方法 の手順1と2を自動化したものです。これによりfunc()の呼び出しはデフォルトでオリジナルの関数が呼ばれ、func_expect()やfunc_set_callback()を設定した時だけモックやスタブとして振る舞うようにすることができます。
注意点としては、Foo.cをmock_Foo.cがインクルードしているので、テストプロジェクトのビルド対象からFoo.cを外す必要があります(外さないとfunc()のシンボル重複エラーになる)。また、mock_Foo.cがFoo.cに依存するようなビルドの設定にしないと、Foo.cを修正してもmock_Foo.cのコンパイルがされずFoo.cの修正が反映されないということが起こります(自動的に依存関係を設定してくれるビルドツールならば問題ない)。
オリジナルの関数は、テストダブル関数を経由してmock_Foo.func_funcptrに登録された関数として呼ばれるので、多少のオーバーヘッドはありますがユニットテストなので問題ないかと思います。
C標準関数やシステムコールのモック化
pcunit_mock.rbを使って、C標準関数やシステムコールなど、ソースがなく処理系に組み込まれている関数のモック化もできますが、少し工夫が必要になります。
モック化したい標準関数はmallocとfreeとします。
まず、標準関数をプリプロセッサで置換するヘッダーファイルを新規作成します。このファイルをMyStdFunc.hとして、置換後の関数名は、my_のプレフィックスを付けることにします。my_malloc/my_freeのプロトタイプ宣言もしておきます。
#ifndef MY_STD_FUNC_H_INCLUDED #define MY_STD_FUNC_H_INCLUDED /* 標準ヘッダ内で置換が発生しないように、先にインクルードしておく */ #include <stdlib.h> /* 標準関数呼び出しを置換するマクロ */ #define malloc my_malloc #define free my_free /* 置換後の標準関数のプロトタイプ宣言。my_のプレフィックスをつけた。 * これらのモックを生成する */ void *my_malloc(size_t size); void my_free(void *ptr); #endif /* MY_STD_FUNC_H_INCLUDED */
このヘッダーファイルをテストプロジェクトのビルド設定で、すべてのソースファイルの先頭でインクルードされるようにします。
例えば、gccなら-includeオプションを指定、Visual StudioならプロジェクトのプロパティでC/C++→詳細設定→必ず使用されるインクルードファイル(/FIオプション)でMyStdFunc.hを指定します。
これで、プロダクトコードを変更することなく、すべてのmallocとfreeの呼び出しがmy_mallocとmy_freeに置換されます。*2
そして、pcunit_mock.rbでMyStdFunc.hのモックのソースmock_MyStdFunc.hとmock_MyStdFunc.cを生成すれば、標準関数をモック化することができます。
テストコードでは、例えば以下のようにmallocを失敗させるテストを書くことができます。
static int setup(void) { mock_MyStdFunc_init(); return 0; } static int teardown(void) { mock_MyStdFunc_verify(); return 0; } static void test_Hoge_new_fail(void) { Hoge *hoge; my_malloc_Expectation e[1] = {{0}}; /* メモリ確保失敗の設定をする */ e[0].retval = NULL; e[0].expected.size = sizeof(Hoge); my_malloc_expect(e, sizeof e / sizeof e[0]); /* Hoge_newの内部で呼び出すmallocが失敗し、NULLを返す */ hoge = Hoge_new(); PCU_ASSERT_PTR_NULL(hoge); }
デフォルトで標準関数が呼ばれるようにする
すべてのmy_malloc/my_freeの呼び出し箇所でモックの設定をするのは面倒で、デフォルトでは標準関数のmalloc/freeを呼び出して欲しいと思います。
そこで、前述したようにpcunit_mock.rbに-sを付けてモックを生成します。すると、MyStdFunc.cをインクルードするソースが生成されます。MyStdFunc.cはまだ存在しないので、MyStdFunc.cを新規作成します。
MyStdFunc.cの内容は以下のようにします。このファイルはmock_MyStdFunc.cからインクルードのみで使用されるので、ビルド対象に追加しません。(マクロ定義だけなので追加しても実害はないですが。)
#undef malloc #undef free #define my_malloc_original malloc #define my_free_original free
これで、テストコードでmy_(malloc|free)_expectかmy_(malloc|free)_set_callbackでモックの設定をしなければ、デフォルトで標準関数のmalloc/freeが呼ばれるようになります。
static void test_Hoge_new_OK(void) { Hoge *hoge; /* my_malloc_expect/my_malloc_set_callbackでモックの設定しなければ、mallocの実体が呼ばれる */ hoge = Hoge_new(); PCU_ASSERT_PTR_NOT_NULL(hoge); /* my_free_expect/my_free_set_callbackでモックの設定しなければ、freeの実体が呼ばれる */ Hoge_delete(hoge); }
ちなみに、モック化したい標準関数が増えた場合は、MyStdFunc.hとMyStdFunc.cにmallocと同じように追加し、pcunit_mock.rb -sでモックを生成し直せばOKです。
*1:Unity/CMockの場合はmain生成自動化を使うと、テストコード毎にmainが生成され、実行ファイルが別々になるので問題になりません。しかし、ターゲット上で動かすときは、実行ファイルをひとつにしたい。
*2:この方法は、テスト駆動開発による組み込みプログラミング の7.4 Cでだますにはどうするか の、プリプロセッサによる置き換えの例でCppUTestのmalloc置換のやり方を参考にしました。