背景
C言語は古くから使われていますが、ユニットテストが導入されていないプロジェクトが多いと思っています(体感8割?)。また、途中からテスト用フレームワーク(cppunitやGoogle Testなど)を導入することは、最初から考えて作っていないと難しいかと思われますし、C言語よりC++をターゲットにしている雰囲気があります。
そのため、独自のユニットテストを構築したほうが「早い」こともあります。
キモは "assert"
要するに assert さえできればよいのです。通常は assert.h をインクルードして、assert 関数を使えばよいです。
ただ、自前でassertを作ったほうがよい場合もあります。その場合、以下のような内容を定義したヘッダファイルを用意します。
#include <stdio.h>
#include <stdlib.h>
#define MY_ASSERT(expr) \
if (!(expr)) { \
fflush(stdout); \
fflush(stderr); \
fprintf(stderr, "[ASSERTION FAILED] %s:%s():%d: '%s'\n", __FILE__, __func__, __LINE__, #expr); \
fflush(stderr); \
exit(1); \
}
やたらfflushがあるのは、エラーメッセージを表示する前にコンソール出力があれば、それを表示しきってしまうことと、エラーメッセージを表示した後にすぐ表示しきってしまうためです。__func__ についてはもしかしたら対応していないコンパイラがあるかもしれません。
使い方は
MY_ASSERT(関数(引数) == 期待値);
のような感じで、条件式が正しければok、誤っていればエラーメッセージを表示して終了する仕組みです。そのため、通常の assert 関数と「表面上は」動きが同じです(メッセージの内容は違いますが)。違うのは、assert 関数は最終的に abort() を使っていると思われますが、これがどうも悪さをすることがあるようで、自前のマクロでは exit(1) で終了させるようにしています。
他の言語だと assertEqual みたいなのもありますが、それらを独自に追加してみると便利かもしれません。今回は、浮動小数点数のアサートのために、以下のマクロも定義してみます。
#include <math.h> // fabsで必要
/*
* 「ほぼ」等しいかどうか(浮動小数点値では一致比較が正確にはできない)
* MY_ASSERT_NEARLY_EQUAL(期待値, 実際の値, 許容誤差);
*/
#define MY_ASSERT_NEARLY_EQUAL(expected,actual,tolerance) \
if (fabs((actual) - (expected)) >= (tolerance)) { \
fflush(stdout); \
fflush(stderr); \
fprintf(stderr, "[ASSERTION FAILED] %s:%s():%d: abs('%s' - '%s') >= '%s'\n", __FILE__, __func__, __LINE__, #actual, #expected, #tolerance); \
fflush(stderr); \
exit(1); \
} \
想定するファイルの配置
src/
+- Makefile <= src以下のMakefile(今回は触れません)
+- app.h <= 共通的なヘッダ
+- main.c <= main関数を含む
+- calc_bmi.c <= main.cから呼ばれる
test/
+- Makefile <= テストモジュール用のMakefile
+- util_test.h <= テスト用のヘッダ(上記の MY_ASSERT などを定義)
+- test_app_h.c <= app.h のテスト
+- test_main.c <= main.c のテスト
+- test_main_stub.c <= main.c から呼ばれる関数のスタブ
+- test_calc_bmi.c <= calc_bmi.c のテスト
1個のソースファイルにつき、1個のテストファイル(それに付随するスタブ)、および、1個の実行モジュールが対応するようにします。複数個のソースに対して1個のテストモジュールにすることもできますが、かなりやばいことになります(経験済)。また、個別モジュールにすることで、同時実行によって時間短縮することもできます。
テストコードの書き方(main関数を含まない場合)
calc_bmi.c に対する test_calc_bmi.c の書き方です。
calc_bmi.c は名前の通りBMIを計算する処理を書いたものです。
#include "app.h"
static double calc_bmi_sub(double height_m, double weight_kg);
/// @brief BMI計算
/// @param height_cm 身長[cm]
/// @param weight_kg 体重[kg]
/// @return (色々丸めている)BMI
int calc_bmi(int height_cm, int weight_kg)
{
/* 身長が0以下の場合、とりあえず0を返す仕様 */
if (height_cm <= 0) {
return 0;
}
return calc_bmi_sub(height_cm / 100.0, weight_kg);
}
/// @brief BMI計算(サブ)
/// @param height_m 身長[m]
/// @param weight_kg 体重[kg]
/// @return BMI
static double calc_bmi_sub(double height_m, double weight_kg)
{
return weight_kg / (height_m * height_m);
}
続いて test_calc_bmi.c の内容です。
#include "util_test.h"
#include "calc_bmi.c" // ★テスト対象ソース
// テスト関数のプロトタイプ宣言
static void test_calc_bmi(void);
static void test_calc_bmi_sub(void);
// テストモジュールのエントリ(ここから実行)
int main(int argc, char **argv)
{
// テスト関数
test_calc_bmi();
test_calc_bmi_sub();
return 0;
}
// calc_bmi関数テスト
static void test_calc_bmi(void)
{
// イレギュラー(身長が0以下)のケース
MY_ASSERT(calc_bmi(0, 50) == 0);
MY_ASSERT(calc_bmi(-1, 50) == 0);
// 通常のケース
MY_ASSERT(calc_bmi(160, 50) == 19);
}
// calc_bmi_sub関数テスト
static void test_calc_bmi_sub(void)
{
// 浮動小数点の比較なので誤差を意識したassertが必要
MY_ASSERT_NEARLY_EQUAL(19.53125, calc_bmi_sub(1.6, 50), 0.00001);
}
なんと、テスト対象ソース(calc_bmi.c)をインクルードしています。それはなぜかというと、staticがついた関数をテストしたいからです。この場合はcalc_bmi_sub 関数が該当します。まあ、かなりお行儀が悪い方法で、Google Testのドキュメントでもこのやり方は「お勧めしない」と書かれています。
ただ、最初からうまく設計されていて、公開した関数からstaticの関数のすみずみまで通るようにできていればいいのですが、「途中から」導入した場合などはそうなっていないことが多いので、こうせざるを得ません。
ビルドは以下のようにすればよいです。
gcc -o test_calc_bmi test_calc_bmi.c -I../src
テストコードの書き方(main関数を含む場合)
main.c に対する test_main.c の書き方です。
main.c は以下のようになっています。
#include <stdio.h>
#include "app.h"
/// @brief メイン
/// @param [in] argc コマンドライン引数個数
/// @param [in] argv コマンドライン引数配列
/// @return 実行結果
int main(int argc, char **argv)
{
// ここで calc_bmi を呼んでいる
printf("bmi = %d\n", calc_bmi(170, 70));
return 0;
}
続いて test_main.c は以下のようになります。
#include "util_test.h"
#include "main.c" // ★テスト対象ソース
#include "test_main_stub.c" // ★テスト対象ソースから呼び出している関数のスタブ
#include <stdlib.h>
// テスト関数
static void test_main(void);
// エントリポイント(ここから実行)
int ut_main(int argc, char **argv)
{
// テスト関数
test_main();
// ★エントリを変更した場合、returnではなくexit関数で終了させる
exit(0);
}
// main関数のテスト
static void test_main(void)
{
// 実際はargcに0、argvにNULLが入ることはないが、関数内で引数を見ていないので
// とりあえず引数はこうしておく
MY_ASSERT(main(0, NULL) == 0);
}
ここでの注目点はいくつかあります。
- test_main_stub.c って何だ
- main関数ではなくut_main関数から実行が始まりそう
- ut_main関数の終わりがreturnではなくてexit
1.スタブを用意する
【注】スタブの話は、テスト対象ソースにmain関数を含むかどうかは関係ありません。
main.c からは calc_bmi.c にある calc_bmi 関数を呼んでいます。つまりビルドするには、calc_bmi 関数の実体をリンクしなければなりません。今回くらいのファイル数ならすべての実体をリンクする手間はそんなかかりませんが、通常は main 関数から先は大量の関数が呼ばれますし、それらをすべてビルドしリンクすることと、それ以上に、テスト実行といいつつ、結局全部動かすことになってしまいます。
実際は main 関数自体をテストしたいということは無いかもしれませんが、main 関数を「含む」ソースファイル内の関数のテストはしたいでしょう。そのためにも、テスト対象のソース以外の関心事はなくさなければなりません。
今回のケースでは、calc_bmi関数をダミーに置き換えます。それを test_main_stub.c に書いています。
int calc_bmi(int height_cm, int weight_kg)
{
return 20;
}
2.エントリポイントを変える
普通は main 関数から実行が始まるようになっています(環境によって異なる)が、それを違う関数から始まるようにすげ替えることができます。gccのオプションで "-e エントリ関数名" とすればできます。
gcc -o test_main test_main.c -I../src -e ut_main
3.exit関数で終了する
エントリポイントをすげ替えた場合、return ではなく exit で終わる必要があります。そうしないと、Segmentation Faultが発生します。
そして、独自のアサートを定義した理由がまさにこのことに関連してまして、通常の assert 関数を使った場合、Segmentation Fault で終了する事自体はいいのですが、assert のメッセージが出てこないのです。理由を調査中ですが、わかっていません。
ヘッダファイルのテストコード
例えば、
- マクロの式、関数、定数の妥当性チェック
- inline関数のテスト
- 構造体のオフセットのチェック
などのテストが書けそうですね。
上記のテスト用Makefile
各テストモジュールを個別ターゲットとして書いています。ターゲットをまとめて書くこともできるかもしれませんが、場合によりけりかと思います。
# すべてのテストモジュール
ALL_TESTS := test_main test_calc_bmi test_app_h
# オブジェクト
ALL_OBJS := $(addsuffix .o,$(ALL_TESTS))
CC = gcc
CFLAGS = -I../src
all: $(ALL_TESTS)
test_main: test_main.c
$(CC) -o $@ $< $(CFLAGS) -e ut_main
test_calc_bmi: test_calc_bmi.c
$(CC) -o $@ $< $(CFLAGS)
test_app_h: test_app_h.c
$(CC) -o $@ $< $(CFLAGS)
clean:
rm -f $(ALL_OBJS) $(ALL_TESTS)
スタブの応用
この投稿の構成とは関係ないですが、戻してほしい値を制御することによって、テストしやすくしたりできます。
// テスト対象コード(func1.cとします)
extern int func2(void);
int func1()
{
if (func2() != 0) {
printf("error\n");
return -1;
}
return 0;
}
// テストコード(test_func1.cとします)
#include "test_func1_stub.c" // スタブ
static void test_func1(void)
{
// func2の戻り値が0でないケース
ret_func2 = 1; // ★戻り値を0以外にセットする
assert(func1() != 0);
// func2の戻り値が0のケース
ret_func2 = 1; // ★戻り値を0にセットする
assert(func1() == 0);
}
// テスト用スタブ(test_func1_stub.cとします)
// func2の戻り値を保管
static int ret_func2 = 0;
int func2(void)
{
return ret_func2;
}