S-JIS[2012-11-18/2013-01-07]

Cutter

Cutterは、C言語(およびC++)用の単体テスト自動実行ツール。(JUnitのC言語版)


概要

Cutterは、JUnitのC言語版のようなツール(フレームワーク)。

テスト結果が正しいかどうかを比較する関数や、テストを実行するアプリケーションを提供している。
テスト実行用関数を自動的に収集してくれるのがすごい。
テスト結果の表示も非常に分かり易い。


インストール

インストール方法はCutter本家のプラットフォーム毎のCutterのインストール方法で分かりやすく説明されている。

CentOSへのインストール

Cutter本家の説明ではCentOS 5と6に分けて書かれているが、ダウンロードするファイルは同じなので、CentOS6でもCentOS5の方法で出来る。

  1. yumリポジトリー登録用のrpmファイルをインストールする。
    # cd /tmp
    # wget http://downloads.sourceforge.net/project/cutter/centos/cutter-release-1.1.0-0.noarch.rpm
    # rpm -Uvh cutter-release-1.1.0-0.noarch.rpm
  2. yumを使ってCutterをインストールする。
    # yum install -y cutter

Cutterを試しに使ってみる為の最小限のサンプル。

examle.c(テスト対象のソース):

int add(int n, int m)
{
	return n + m;
}

test_example.c(テストを実行するCutterのソース):

#include <cutter.h>

extern int add(int n, int m);

void test_add(void)
{
	cut_assert_equal_int(3, add(1, 2)); //add(1, 2)の結果が3であることを確認する
}

テスト対象のソースは、何の変哲も無い普通のC言語のソース。

テスト実行用のソースでは、cutter.hをインクルードする。
テストを実行する関数は、関数名を「test_」で始める。
実行結果の判定には「cut_」で始まる関数(マクロ)が色々用意されているので、それを使用する。
→Cutterリファレンスマニュアルの検証

(テスト実行用の関数は自動的に収集される為、テスト用のmain関数を自分で作ったりする必要は無い)

コンパイル

テスト対象のソースは普通にコンパイルする。
テスト実行用ソースもコンパイルは普通にするが、cutter.hの場所を指定する必要がある。
(ただし、最終的にsoファイルにリンクされるので、gccオプションの-fPICを付ける必要がある)
これらのオブジェクトをまとめて、最終的にsoファイル(UNIXの場合)を作成する。(gccオプションの-sharedを指定する)
テストを実行する際は、このsoファイルが読み込まれる。

$ gcc -fPIC -c example.c -o example.o
$ gcc -fPIC -c test_example.c -o test_example.o $(pkg-config --cflags cutter)
$ gcc -shared example.o test_example.o -o test_example.so $(pkg-config --libs cutter)
$ ls
example.c  example.o  test_example.c  test_example.o  test_example.so

pkg-configは、ヘッダーファイルやライブラリーの場所やファイル名を(コンパイラーの引数に使える形式で)教えてくれるコマンド。
OSによってはヘッダーファイル等が置かれる場所が異なる為、直接指定せずにpkg-configを利用する。

$ pkg-config --cflags cutter
-I/usr/include/cutter
$ pkg-config --libs cutter
-lcutter

テストの実行

$ cutter .
.

Finished in 0.002642 seconds (total: 0.000073 seconds)

1 test(s), 1 assertion(s), 0 failure(s), 0 error(s), 0 pending(s), 0 omission(s), 0 notification(s)
100% passed

テストはcutterコマンドで実行する。
cutterの引数には、soファイルの置いてあるディレクトリーを指定する。(今回はカレントディレクトリーを指定)
すると自動的にsoファイルが読み込まれ、その中にある「test_」で始まっている関数が全て実行される。
テストが全て通れば結果が緑色で表示され、1つでも失敗すると赤で表示される。
(ついでに、(CentOSの場合は)右下に結果を知らせるダイアログも表示される)


テストが失敗した場合は失敗した場所(テスト関数名・ファイル名・行番号)や値も表示されるので、とても便利。

// 失敗するテストを追加
void test_add_fail(void)
{
	cut_assert_equal_int(4, add(1, 2), cut_message("わざと失敗するテスト"));
}
$ cutter .
.F
===============================================================================
Failure: test_add_fail
わざと失敗するテスト
<4 == add(1, 2)>
expected: <4>
  actual: <3>
test_example.c:12: test_add_fail(): cut_assert_equal_int(4, add(1, 2), cut_test_context_set_current_result_user_message( cut_test_context_current_peek(), cut_test_context_take_printf(cut_test_context_current_peek(), "わざと失敗するテスト")))
===============================================================================


Finished in 0.004811 seconds (total: 0.001594 seconds)

2 test(s), 1 assertion(s), 1 failure(s), 0 error(s), 0 pending(s), 0 omission(s), 0 notification(s)
0% passed

cutterコマンドは、テストが成功すると0、失敗すると1を返す。
JenkinsとかのCIツールでの判定はこれで出来そう。


JUnit3とCutterの比較

cut_assert_equalは、型に応じて関数(マクロ)が色々用意されている。
Javaと違って関数のオーバーロード(同じ関数名で引数の型が違う)が出来ないので、関数名で区別している。(intならcut_assert_equal_int、char*ならcut_assert_equal_string
→Cutterリファレンスマニュアルの検証

JUnit3だとassertEquals()の第1引数にエラー時のメッセージを指定できたが、Cutterは可変長引数を使ってメッセージ有無を制御しているので、第3引数の位置になる。
(メッセージはcut_messageというマクロを使って指定する)

JUnit3とCutterの例
  JUnit3 Cutter
intの判定 assertEquals(3, add(1, 2)); cut_assert_equal_int(3, add(1, 2));
文字列の判定 assertEquals("123", Integer.toString(123)); cut_assert_equal_string("123", itoa(123, buf, sizeof buf));
真偽値の判定 assertEquals(true, actual);
assertTrue(actual);
assertFalse(actual);
cut_assert_equal_boolean(CUT_TRUE, actual);
cut_assert_true(actual);
cut_assert_false(actual);
ポインターの判定 assertSame(expected, actual); cut_assert_equal_pointer(expected, actual);
nullの判定 assertNull(actual);
assertNotNull(actual);
cut_assert_null(actual);
cut_assert_not_null(actual);
メッセージ付き assertEquals("message", 3, add(1, 2)); cut_assert_equal_int(3, add(1, 2), cut_message("message"));
assertEquals(String.format("m=%d, n=%d", m, n), 3, add(m, n)); cut_assert_equal_int(3, add(m, n), cut_message("m=%d, n=%d", m, n));
テスト失敗 assertFail("message"); cut_fail("message");
assertFail("i=" + i); cut_fail("i=%d", i);

makefile化

ソースファイルが増えてくると一々コンパイルコマンドを打つのが面倒になってくるので、makeコマンドでコンパイルできるようにしてみる。

ファイル・ディレクトリー構成は以下のようなものを想定する。

ファイル・ディレクトリー 備考
example.out (最終的な成果物としての)実行ファイル
makefile  
src main.c
example.c
example.h
ソースファイルの置き場
test test_example.c テストを記述したソースファイルの置き場
lib main.o
example.o
オブジェクトファイルの置き場
test-lib test_example.o
example.so
テスト用オブジェクトファイルとsoファイルの置き場

src/*.cをコンパイルしてlib/*.oを作り、それらをリンクしてexamle.outを作る。
テスト用には、test/*.cをコンパイルしてtest-lib/*.oを作り、最終的にtest-lib/example.soを作る。

makefile

SRC_DIR=src
TEST_DIR=test
LIB_DIR=lib
TEST_LIB_DIR=test-lib

TARGET=example.out
HEADERS=${wildcard $(SRC_DIR)/*.h}
SRCS=${wildcard $(SRC_DIR)/*.c}
OBJS=$(SRCS:$(SRC_DIR)/%.c=$(LIB_DIR)/%.o)

TEST_TARGET=$(TEST_LIB_DIR)/example.so
TEST_SRCS=${wildcard $(TEST_DIR)/*.c}
TEST_OBJS=$(TEST_SRCS:$(TEST_DIR)/%.c=$(TEST_LIB_DIR)/%.o)

CUTTER_FLAGS=${shell pkg-config --cflags cutter}
CUTTER_LIBS =${shell pkg-config --libs   cutter}

all: $(TARGET) $(TEST_TARGET)

test: $(TEST_TARGET)

run-test: $(TEST_TARGET)
	cutter $(TEST_LIB_DIR)

$(TARGET): $(OBJS)
	gcc $(OBJS) -o $@

$(LIB_DIR)/%.o: $(SRC_DIR)/%.c $(HEADERS)
	gcc -O -fPIC -c $< -o $@

$(TEST_TARGET): $(TEST_OBJS) $(OBJS)
	gcc -shared $(TEST_OBJS) $(OBJS) $(CUTTER_LIBS) -o $@

$(TEST_LIB_DIR)/%.o: $(TEST_DIR)/%.c $(HEADERS)
	gcc -O -fPIC -c $< -I$(SRC_DIR) $(CUTTER_FLAGS) -o $@

このmakefileは、「make」を実行するとexample.outとexample.soを作る。
「make test」だとexample.soだけ作る。
「make run-test」でcutterを実行する。

ソース一式(Gist)


exit()のテスト

関数の引数をチェックして、不正だったらexit()で終了するようなコーディングはよくある。この関数のテストを考えてみる。[2012-11-23]

普通にexit()が実行されると、(Cutterのテスト結果の最終集計も実行されずに)Cutter自身も終了してしまう。
C言語ではマクロが使えるので、exitをlongjmpに置き換えてしまうのが良さそう。

example.c:

#include <stdio.h>
#include "example.h"

int add(int n, int m)
{
	if (m <= 0) {
		fprintf(stderr, "minus: m=%d\n", m);
		exit(1);
	}
	return n + m;
}

example.h:

#ifdef UNIT_TEST

#include <setjmp.h>
extern jmp_buf test_jmpbuf;
#define exit(n) longjmp(test_jmpbuf);

#endif

test_example.c:

#include "example.h"
#include <cutter.h>

#undef exit
jmp_buf test_jmpbuf;
void test_add(void)
{
	int c = setjmp(test_jmpbuf);
	if (c == 0) {
		cut_assert_equal_int(3, add(1, 2));
	} else {
		cut_fail("c=%d", c);
	}
}
void test_add_error(void)
{
	int c = setjmp(test_jmpbuf);
	if (c == 0) {
		add(1, 0);
		cut_fail("error");
	} else {
		cut_assert_equal_int(1, c);
	}
}

ヘッダーファイルで、単体テスト用にjmp_bufを定義する。
テスト以外では使わないので、UNIT_TESTが定義されている時だけに限定する。(UNIT_TESTはコンパイル時に指定する)

テストコードでは、exitマクロを解除しておく。(テストコード側でexit()を使いたい場合に本来のexit()を呼べるようにする為)
exit()を使っている関数を呼び出すときは、setjmp()でバッファーを初期化し、戻り値が0の方で対象関数を呼び出す。
exit()が呼ばれると0以外の値(exit()の引数)が返ってくるので、それをチェックする。

この方法を採る場合、exit()が呼ばれないはずのテストでも、setjmp()しておく方が良い。
テストが正しく通ればこのsetjmp()は不要だが、だからと言って省略していると、いざ失敗したときに
test_jmpbufが初期化されていなくてエラーとなるか、以前の(別の場所で初期化した)setjmp()の場所に飛んでしまって不可思議な結果になってしまう。


makefile:

SRC_DIR=src
TEST_DIR=test
LIB_DIR=lib
TEST_LIB_DIR=test-lib

TARGET=example.out
HEADERS=${wildcard $(SRC_DIR)/*.h}
SRCS=${wildcard $(SRC_DIR)/*.c}
OBJS=$(SRCS:$(SRC_DIR)/%.c=$(LIB_DIR)/%.o)

TEST_TARGET=$(TEST_LIB_DIR)/example.so
TEST_SRCS=${wildcard $(TEST_DIR)/*.c}
TEST_OBJS=$(SRCS:$(SRC_DIR)/%.c=$(TEST_LIB_DIR)/%.o) \
          $(TEST_SRCS:$(TEST_DIR)/%.c=$(TEST_LIB_DIR)/%.o)

CUTTER_FLAGS=${shell pkg-config --cflags cutter}
CUTTER_LIBS =${shell pkg-config --libs   cutter}

all: $(TARGET) $(TEST_TARGET)

test: $(TEST_TARGET)

run-test: $(TEST_TARGET)
	cutter $(TEST_LIB_DIR)

$(TARGET): $(OBJS)
	gcc $(OBJS) -o $@

$(LIB_DIR)/%.o: $(SRC_DIR)/%.c $(HEADERS)
	gcc -O -c $< -o $@

$(TEST_TARGET): $(TEST_OBJS)
	gcc -shared $(TEST_OBJS) $(CUTTER_LIBS) -o $@

$(TEST_LIB_DIR)/%.o: $(SRC_DIR)/%.c $(HEADERS)
	gcc -O -fPIC -DUNIT_TEST -c $< -I$(SRC_DIR) $(CUTTER_FLAGS) -o $@
$(TEST_LIB_DIR)/%.o: $(TEST_DIR)/%.c $(HEADERS)
	gcc -O -fPIC -DUNIT_TEST -c $< -I$(SRC_DIR) $(CUTTER_FLAGS) -o $@

ターゲットのソースは、テスト時はUNIT_TESTを有効にするが そうでない時は無効にする必要があるので、
テスト用・本物用でコンパイルは別々にしないといけない。


子プロセスでのテストは出来ません

至極当然の話だが、fork()を使って子プロセスを起動するようなテストを書いて、子プロセス側で判定関数を使うことは出来ない。

void test_fork(void)
{
	pid_t pid = fork();
	if (pid < 0) {
		perror("fork"); exit(-1);
	} else if (pid == 0) {
		// 子プロセス
		cut_assert_equal_int(0, pid);
	} else {
		// 親プロセス
		cut_assert_not_equal_int(0, pid);
	}
}

試してみると、実行自体は出来るし、判定もちゃんと行われて テストが失敗したらメッセージは出るけれども(2プロセスが同時に動くのでメッセージは入り乱れるが)
テスト結果の集計はあくまで親プロセス側がやっているので、子プロセスのテストは集計されない。
したがって最終的なテスト件数や合否には子プロセスの分は含まれない。(厳密には、子プロセス側の分だけの最終的なメッセージも出力される。つまり全体で結果が2回表示されてしまう)

むしろ子プロセスの分までちゃんと集計されたらすごすぎだろう。びっくりするわw

子プロセス側で何か判定したいんだったら、Cutterの判定関数を使わずに判定し、判定結果を親プロセスに返して
(親プロセスは子プロセスの終了を待ち、)親プロセス側でCutterの判定関数を使って判定する
という形になるかな。


C言語目次へ戻る / 技術メモへ戻る
メールの送信先:ひしだま