ククログ

株式会社クリアコード > ククログ > Cutter導入事例: Senna (2)

Cutter導入事例: Senna (2)

ちょうど1ヶ月前の話の続きです。

前回でCutterでテストを作成するための環境ができたので、実際にテストを作成していきます。と、思ったのですが、もう一点やらなければいけないことが残っていました。テスト対象のライブラリの初期化についてです。

今回はテスト対象ライブラリの初期化について説明してからテスト作成に入ります。

前回同様、コードの断片がでてきます。完全なものはSennaのリポジトリを見てください。

ライブラリの初期化

Sennaのようにライブラリ初期化・終了関数 (sen_init()/sen_fin())を用意している場合は、テストの作成に 入る前に、もう一つ用意しておかなければいけない仕組みがありま す。このような関数を持っているライブラリをテストする場合は、 テスト全体を実行する前に初期化関数を、実行した後に終了関数を 呼び出す必要があります。これを行う仕組みを用意する必要があり ます。

cutterコマンドは指定されたディレクトリ以下の共有ライブラリを かき集めて、その中からテストを検出して実行します。その時に以 下の条件にあう共有ライブラリを見つけると、テスト全体を実行す る前後に特定の関数を実行することができます。これは、今まさに 必要としている機能です。

  • ファイル名が「suite_」からはじまっている

この共有ライブラリが以下の名前のシンボルを公開している場合は、 その関数をテスト全体を実行する前後に実行します。ここでは、共 有ライブラリのファイル名はsuite_senna_test.soとします。

  • senna_test_warmup(): テスト全体を実行する前に実行
  • senna_test_cooldown(): テスト全体を実行した後に実行

「_warmup」と「cooldown」の前の「senna_test」の部分は共有ラ イブラリのファイル名から先頭の「suite」と拡張子を除いた部分 です。

Sennaの場合は以下のようなsuite-senna-test.cを作成します。

test/unit/suite-senna-test.c:

#include <senna.h>

void senna_test_warmup(void);
void senna_test_cooldown(void);

void
senna_test_warmup(void)
{
  sen_init();
}

void
senna_test_cooldown(void)
{
  sen_fin();
}

suite-senna-test.cをビルドするためにMakefile.amに以下を追加 します。

test/unit/Makefile.am:

if WITH_CUTTER
...

noinst_LTLIBRARIES =				\
	suite_senna_test.la
endif

INCLUDES =			\
	-I$(srcdir)		\
	-I$(top_srcdir)		\
	-I$(top_srcdir)/lib	\
	$(SENNA_INCLUDEDIR)

AM_CFLAGS = $(GCUTTER_CFLAGS)
AM_LDFLAGS = -module -rpath $(libdir) -avoid-version

LIBS =						\
	$(top_builddir)/lib/libsenna.la		\
	$(GCUTTER_LIBS)

suite_senna_test_la_SOURCES = suite-senna-test.c

よくあるMakefile.amの書き方です。noinst_LTLIBRARIESをif WITH_CUTTER ... endifの中に入れているのは、Cutterがない環境で はビルド対象からはずし、ビルドエラーにならないようにするため です。

これで、test/unit/.libs/suite_senna_test.soがビルドされるよう になり、テスト全体を実行する前後にSennaの初期化・終了処理を行 うことができます。

テストの作成

テスト実行環境が整ったのでテストを作成します。ここでは検索キー ワードの周辺テキストを取得するsnippet APIのテスト を1つ作成します。

テストの流れは以下の通りです。

  1. sen_snip_open()でsen_snipオブジェクトの生成

  2. sen_snip_add_cond()でキーワードを指定

  3. sen_snip_exec()でsnippetを生成

  4. sen_snip_get_result()で取得した結果が期待していたものか を検証

  5. sen_snip_close()で生成したsen_snipオブジェクトを開放

基本的なCutterのテスト作成方法についてはチュートリアル を参考にしてください。

sen_snip_open()のテスト

まずは、sen_snip_open()でsen_snipオブジェクトを生成する部分 と、sen_snip_close()で生成したsen_snipオブジェクトを開放する 部分を作成します。

今回はGLibサポート付きでCutterを使用するgcutterパッケージを 使っているので、テストは以下のようにgcutter.hを利用します。

test/unit/test-snip.c:

#include <senna.h>
#include <gcutter.h>

void test_simple_exec(void);

static const gchar default_open_tag[] = "[[";
static const gchar default_close_tag[] = "]]";

void
test_simple_exec(void)
{
  sen_snip *snip;

  snip = sen_snip_open(sen_enc_default, 0, 100, 10,
                       default_open_tag, strlen(default_open_tag),
                       default_close_tag, strlen(default_close_tag),
                       NULL);
  cut_assert_not_null(snip);
  sen_snip_close(snip);
}

sen_snip_open()は引数が多いですが、ここでは気にする必要はあ りません。sen_snip_open()によりsen_snip *が生成されることだけ知っ ていれば問題ありません。

cut_assert_not_null(snip) でsen_snip *が正常に生成されているかを確認します。これは、 sen_snip_open()は失敗時にはNULLを返すからです。

最後にsen_snip_close()で生成したsen_snip *を開放します。

sen_snip_add_cond()のテスト

次はsen_snip_add_cond()でキーワードを指定する処理を追加しま す。sen_snip_add_cond()の戻り値はsen_rcです。sen_rcはエラー 番号を示す数値でsen_success(0)以外はエラーになります。よって テストは以下のようになります。sen_snip_open()のときと同じく、 sen_snip_add_cond()の引数は気にしなくても構いません。

test/unit/test-snip.c:

...
void
test_simple_exec(void)
{
  sen_snip *snip;
  const gchar keyword[] = "Senna";

  snip = sen_snip_open(sen_enc_default, 0, 100, 10,
                       default_open_tag, strlen(default_open_tag),
                       default_close_tag, strlen(default_close_tag),
                       NULL);
  cut_assert_not_null(snip);

  cut_assert_equal_int(sen_success,
                       sen_snip_add_cond(snip, keyword, strlen(keyword),
                                         NULL, 0, NULL, 0));

  sen_snip_close(snip);
}

sen_snip_add_cond()の結果は cut_assert_equal_int で検証しています。

ただし、ここで問題があります。cut_assert*()は検証が失敗する とその時点でテスト関数からreturnし、それ以降のコードは実行し ません。つまり、cut_assert_equal_int()が失敗した場合は、 sen_snip_open()で生成したsen_snip *が開放されないことになり ます。この問題を解決するためにsetup()/teardown()という仕組み があります。

setup()はテストが実行される前に必ず実行され、teardown()はテ ストが実行された後に成功・失敗に関わらず必ず実行されます。こ の仕組みを利用することで確実にメモリ開放処理を行うことができ ます。

test/unit/test-snip.c:

...
static sen_snip *snip;
...

void
setup(void)
{
  snip = NULL;
}

void
teardown(void)
{
  if (snip) {
    sen_snip_close(snip);
  }
}

void
test_simple_exec(void)
{
  const gchar keyword[] = "Senna";

  snip = sen_snip_open(sen_enc_default, 0, 100, 10,
                       default_open_tag, strlen(default_open_tag),
                       default_close_tag, strlen(default_close_tag),
                       NULL);
  cut_assert_not_null(snip);

  cut_assert_equal_int(sen_success,
                       sen_snip_add_cond(snip, keyword, strlen(keyword),
                                         NULL, 0, NULL, 0));
}

これでcut_assert_equal_int()が成功しても失敗してもsen_snip * は開放されます。Cutterではメモリ開放処理のためにstatic変数と setup()/teardown()を使うことが定石になっています。

sen_snip_exec()のテスト

次はsen_snip_add_cond()で設定したキーワード用のsnippetを生成 するsen_snip_exec()のテストです。sen_snip_exec()もsen_rcを返 すので、それを検証します。また、引数でsnippet数とsnippet文字 列のバイト数も受けとるのでそれも検証します。特に目立った部分 はありません。

test/unit/test-snip.c:

...
static const gchar text[] =
  "Senna is an embeddable fulltext search engine, which you can use in\n"
  "conjunction with various scripting languages and databases. Senna is\n"
  "an inverted index based engine, & combines the best of n-gram\n"
  "indexing and word indexing to achieve fast, precise searches. While\n"
  "senna codebase is rather compact it is scalable enough to handle large\n"
  "amounts of data and queries.";
...
void
test_simple_exec(void)
{
  ...
  unsigned int n_results;
  unsigned int max_tagged_len;

  ...

  cut_assert_equal_int(sen_success,
                       sen_snip_exec(snip, text, strlen(text),
                                     &n_results, &max_tagged_len));
  cut_assert_equal_uint(2, n_results);
  cut_assert_equal_uint(105, max_tagged_len);
}

sen_snip_get_result()のテスト

最後はsen_snip_exec()で生成したsnippetの内容が正しいかどうか のテストです。snippetはsen_snip_get_result()で取得できるので その結果を検証します。n_resultsが2なので2回 sen_snip_get_result()を呼び出す必要があります。

snippetを格納する場所のサイズは動的に決まります。そのため、 snippetを格納する領域を動的に確保する必要があります。 setup()/teardown()の仕組みを用いてメモリを開放するようにしま す。ここ以外は特に目立った部分はありません。

test/unit/test-snip.c:

...
static gchar *result;

void
setup(void)
{
  ...
  result = NULL;
}

void
teardown(void)
{
  ...
  if (result) {
    g_free(result);
  }
}

void
test_simple_exec(void)
{
  ...
  unsigned int result_len;

  ...
  result = g_new(gchar, max_tagged_len);

  cut_assert_equal_int(sen_success,
                       sen_snip_get_result(snip, 0, result, &result_len));
  cut_assert_equal_string("[[Senna]] is an embeddable fulltext search engine, "
                          "which you can use in\n"
                          "conjunction with various scripti",
                          result);
  cut_assert_equal_uint(104, result_len);

  cut_assert_equal_int(sen_success,
                       sen_snip_get_result(snip, 1, result, &result_len));
  cut_assert_equal_string("ng languages and databases. [[Senna]] is\n"
                          "an inverted index based engine, & combines "
                          "the best of n-gram\ni",
                          result);
  cut_assert_equal_uint(104, result_len);
}

これで単純にsnippet APIを使った場合のテストが1つできました。 同様に、異常な場合や違ったデータを用いた場合などのテストを作 成していきます。

diff

せっかくなのでCutterのテスト結果の出力方法を紹介します。

Cutterは cut_assert_equal_string で文字列の比較が失敗したときには、どの部分が異なったかという 差分情報を表示します。

例えば、今回のテストの最後のcut_assert_equal_string()が失敗 した場合は以下のような差分情報が表示されます。

diff:
  - ng languages and DBes. [[Senna]] is
  ?                  ^^
  + ng languages and databases. [[Senna]] is
  ?                  ^^^^^^^
  - an Inverted Index Based Engine, & combines the best of n-gram
  ?    ^        ^     ^     ^
  + an inverted index based engine, & combines the best of n-gram
  ?    ^        ^     ^     ^
    i

このときの期待した結果は以下の通りです。

ng languages and DBes. [[Senna]] is
an Inverted Index Based Engine, & combines the best of n-gram
i

実際の結果は以下の通りです。

ng languages and databases. [[Senna]] is
an inverted index based engine, & combines the best of n-gram
i

差分を見てもらうと分かる通り、異なっている行を示すだけではな くて、行内で異なっている文字まで示しています。(例えば、DBの 下に^^が付いている。)

広く使われているunified diff形式では行内で異なる文字は表示し ません。テストでは1行のみの比較を行うことも多く、行単位だけ の差分よりも文字単位での差分表示も行った方がデバッグが行いや すいという判断からこのような形式になっています。

この形式はPythonのdifflibにあるndiffの形式と同じものです。

テスト全体

今回作成したテストは以下の通りです。

test/unit/test-snip.c:

#include <senna.h>
#include <gcutter.h>

void test_simple_exec(void);

static sen_snip *snip;
static const gchar default_open_tag[] = "[[";
static const gchar default_close_tag[] = "]]";
static const gchar text[] =
  "Senna is an embeddable fulltext search engine, which you can use in\n"
  "conjunction with various scripting languages and databases. Senna is\n"
  "an inverted index based engine, & combines the best of n-gram\n"
  "indexing and word indexing to achieve fast, precise searches. While\n"
  "senna codebase is rather compact it is scalable enough to handle large\n"
  "amounts of data and queries.";
static gchar *result;

void
setup(void)
{
  snip = NULL;
  result = NULL;
}

void
teardown(void)
{
  if (snip) {
    sen_snip_close(snip);
  }
  if (result) {
    g_free(result);
  }
}

void
test_simple_exec(void)
{
  const gchar keyword[] = "Senna";
  unsigned int n_results;
  unsigned int max_tagged_len;
  unsigned int result_len;

  snip = sen_snip_open(sen_enc_default, 0, 100, 10,
                       default_open_tag, strlen(default_open_tag),
                       default_close_tag, strlen(default_close_tag),
                       NULL);
  cut_assert_not_null(snip);

  cut_assert_equal_int(sen_success,
                       sen_snip_add_cond(snip, keyword, strlen(keyword),
                                         NULL, 0, NULL, 0));

  cut_assert_equal_int(sen_success,
                       sen_snip_exec(snip, text, strlen(text),
                                     &n_results, &max_tagged_len));
  cut_assert_equal_uint(2, n_results);
  cut_assert_equal_uint(105, max_tagged_len);

  result = g_new(gchar, max_tagged_len);

  cut_assert_equal_int(sen_success,
                       sen_snip_get_result(snip, 0, result, &result_len));
  cut_assert_equal_string("[[Senna]] is an embeddable fulltext search engine, "
                          "which you can use in\n"
                          "conjunction with various scripti",
                          result);
  cut_assert_equal_uint(104, result_len);

  cut_assert_equal_int(sen_success,
                       sen_snip_get_result(snip, 1, result, &result_len));
  cut_assert_equal_string("ng languages and databases. [[Senna]] is\n"
                          "an inverted index based engine, & combines "
                          "the best of n-gram\ni",
                          result);
  cut_assert_equal_uint(104, result_len);
}

問題発生時に有用なデバッグ情報を増やしたり、より読みやすいテ ストにするなど、いろいろ改良するべき点は残っていますが、今回 はこれで終了します。実際のコードはSennaのリポジトリを参照 してください。

まとめ

2回に分けて以下のことについて説明しました。

  • GNUビルドシステムを採用した既存のプロジェクトへのCutter の組み込み方法
    • cutter.m4で提供するM4マクロの使用方法
    • Cutterをインストールしていないユーザへの対応
    • Cutterをインストールしていない開発者への対応
  • Cutterを用いたテスト環境の構築方法
    • 便利なテスト起動スクリプトrun-test.shの作成方法
    • 初期化・終了関数があるライブラリのテスト方法
  • Cutterを用いたテストの作成方法
    • setup()/teardown()を用いたメモリ管理の方法
    • diffの出力

Cで書かれたプロジェクトに単体テストフレームワークを導入する 場合はCutterも検討してみてはいかがでしょうか。