«前10日分 最新 次10日分»

ClearCode - ククログ

2008|05|06|07|08|09|10|11|

Ruby-GNOME2 0.17.0リリース

昨日、GTK+を含むGNOME関連ライブラリのRubyバインディング集Ruby-GNOME2のバージョン0.17.0が公開されました。(もう少し細かい変更点が書かれたアナウンス

目玉

このリリースの目玉はRuby 1.8.7対応です。

以前のバージョンである0.16.0をRuby 1.8.7で動かすと、以下のようなメッセージとともに落ちてしまいます。*1

[BUG] object allocation during garbage collection phase

0.17.0では上記の問題を解決し、Ruby 1.8.7でも動作するようになっています。

他にも、フリーなマルチメディアフレームワークであるGStreamer 0.10.xへの対応など新機能が追加されています。

RubyでGUIを作成したいときは、Ruby-GNOME2を試してみてください。

協力のお願い

アナウンスメールにもありますが、Ruby-GNOME2プロジェクトでは協力してくれる方を募集しています。例えば、バインディングを開発してくれる方、ドキュメントを書いてくれる方、英語のドキュメントを日本語化してくれる方、リリース作業をしてくれる方などを募集しています。

興味のある方はruby-gnome2-devel-ja MLまでお願いします。

*1  暫定的な対処法はGC.disableでした。

つづき: 2008-10-01
2008-09-08

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も検討してみてはいかがでしょうか。

2008-08-25

ActiveScaffoldの地域化

現在、Railsに対応した国際化の仕組みがいくつかあります。しかし、それぞれが 独自の方法で実現しているため、それらを組み合わせて使うと混沌 とした状態に陥ることも少なくありません。

ここでは、モデルから動的にきれいな画面とコントローラ部分を生 成するActiveScaffoldを用 いた場合の国際化(i18n)と地域化(l10n)の実現方法のひとつを 紹介します。この方法では、 ActiveScaffoldLocalizeRuby-GetText-Package を組み合わせます。混沌とする部分はそれなりになじませます。

国際化の仕組み

Railsで使用できる国際化の仕組みの比較はRails Wiki (英語)が詳しいです。

Ruby-GetText-Package には、以下のような地域化対象のメンテナン スのことを考慮した機能があるので、地域化対象メッセージが増加 したり更新される場合には有力な候補になるでしょう。

  • 地域化対象のメッセージを抽出する機能
    • テーブルにカラムを追加した場合、画面に表示するメッセージを追加・更新した場合などに利用
  • 抽出したメッセージを既存の翻訳済みメッセージにマージする機能
    • 翻訳者が新しい地域化対象のメッセージを翻訳する場合に利用
  • wikipedia:gettext用の翻訳支援ツールを利用可能(.po のフォーマットがGNU gettextと互換性があるため)
    • Emacs用のpo-modeや.po専用のエディタ

Railsやプラグインなどが提供しているメッセージだけを地域化した いなど、地域対象メッセージが変化しない場合はその他の仕組みも 有力な候補になるでしょう。例えば、ActiveScaffold用の ActiveScaffoldLocalizeがその場合です。

ActiveScaffoldLocalize

ActiveScaffoldは、国際化の仕組みとしてObject#as_を提供してい ます。その仕組みを利用して国際化・地域化を実現しているのが ActiveScaffoldLocalizeです。

ActiveScaffoldLocalizeには日本語用のメッセージも含まれている ので、以下のようにすればActiveScaffoldのメッセージを日本語に することができます。

% rails shelf
% cd shelf
% script/generate resource book title:string
% rake db:migrate
% script/plugin install git://github.com/activescaffold/active_scaffold.git
% script/plugin install git://github.com/edwinmoss/active_scaffold_localize.git

config/routes.rb:

-  map.resources :books
+  map.resources :books, :active_scaffold => true

app/controllers/books_controller.rb:

class BooksController < ApplicationController
  active_scaffold :book
end

app/views/layouts/application.html.erb:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
      "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <meta http-equiv="content-type" content="text/html;charset=UTF-8" />
    <title>ActiveScaffold l10n</title>
    <%= javascript_include_tag(:defaults) %>
    <%= active_scaffold_includes %>
  </head>

  <body>
    <h1>ActiveScaffold l10n</h1>
    <%= yield %>
  </body>
</html>

app/controllers/applications.rb:

class ApplicationController < ActionController::Base
  # ...
  private
  before_filter :localize_active_scaffold
  def localize_active_scaffold
    ActiveScaffold::Localization.lang = "ja-jp"
    true
  end
end

サーバを起動してhttp://localhost:3000/books/にアクセスします。

% script/server
% firefox http://localhost:3000/books/

ActiveScaffold + ActiveScaffoldLocalize

見ての通り、「検索」などのメニューは日本語になりますが、テー ブル名からきている「Books」やカラム名の「Title」などは日本語 になりません。

ActiveScaffoldLocalizeの方針では、これらを日本語にするために以下のような内容の config/initializers/lang/ja-jp.rb*1を作成します。

config/initializers/lang/ja-jp.rb:

# -*- coding: utf-8 -*-

ActiveScaffold::Localization.define('ja-jp') do |lang|
  lang["Books"] = "本一覧"
  lang["Book"] = "本"
  lang["Title"] = "タイトル"
end

config/initializers/以下を変更したので、サーバを再起動してか ら再度アクセスすると、日本語で表示されます。

ActiveScaffold + ActiveScaffoldLocalize + モデルの地域化

(「本を作成」ではなく「本一覧を作成」になっているのはこ のパッチ で直ります。)

ActiveScaffoldLocalizeのこのやり方は手軽ですが、地域化対象の メッセージが変更になった場合(例: 「Title」から「Name」に変更) や、地域化対象のメッセージをtypoした場合(例: 「Title」ではな く「title」としていた)に気づきにくいという問題があります。 このような問題に対してはRuby-GetText-Packageが有効です。

ということで、ActiveScaffoldのメッセージは ActiveScaffoldLocalizeで地域化し、それ以外は Ruby-GetText-Packageで地域化するようにします。

Ruby-GetText-Package

ActiveScaffoldLocalizeとRuby-GetText-Packageのすみわけは上述 の通りですが、エラーメッセージの地域化はRuby-GetText-Package ではなく、ActiveScaffldLocalizeに任せます。これは、 ActiveScaffoldがエラーメッセージ部分を上書きしているため、 Ruby-GetText-Packageが提供するエラーメッセージ国際化処理とな じまないためです。

また、Ruby-GetText-Packageが取得したロケール情報を使って ActiveScaffoldLocalizeのlangを設定していることもコツのひとつ です。

config/environment.rb:

# ...
Rails::Initializer.run do |config|
  # ...
  config.gem "gettext", :lib => "gettext/rails"
  # ...
end

lib/active_scaffold_gettext.rb:

module ActiveScaffoldGetText
  include GetText::Rails

  bindtextdomain(GETTEXT_DOMAIN)
end

class Object
  def as__with_gettext(message, *args)
    return nil if message.nil?
    localized_message = ActiveScaffoldGetText.send(:sgettext, message)
    if localized_message == message
      as__without_gettext(message, *args)
    else
      localized_message % args
    end
  end
  alias_method_chain :as_, :gettext
end

module ActiveScaffold::DataStructures
  class Column
    def initialize_with_gettext(name, active_record_class)
      initialize_without_gettext(name, active_record_class)
      self.label = "#{active_record_class.name.demodulize}|#{@label.humanize}"
    end
    alias_method_chain :initialize, :gettext
  end
end

config/initializers/gettext.rb:

GETTEXT_DOMAIN = "your-rails-application"
require 'active_scaffold_gettext'

class ActiveRecord::Errors
  # restore default error messages overridden by Ruby-GetText-Package.
  @@default_error_messages = {
    :inclusion => "is not included in the list",
    :exclusion => "is reserved",
    :invalid => "is invalid",
    :confirmation => "doesn't match confirmation",
    :accepted  => "must be accepted",
    :empty => "can't be empty",
    :blank => "can't be blank",
    :too_long => "is too long (maximum is %d characters)",
    :too_short => "is too short (minimum is %d characters)",
    :wrong_length => "is the wrong length (should be %d characters)",
    :taken => "has already been taken",
    :not_a_number => "is not a number",
    :greater_than => "must be greater than %d",
    :greater_than_or_equal_to => "must be greater than or equal to %d",
    :equal_to => "must be equal to %d",
    :less_than => "must be less than %d",
    :less_than_or_equal_to => "must be less than or equal to %d",
    :odd => "must be odd",
    :even => "must be even"
  }

  alias_method :on, :on_without_gettext
  alias_method :[], :on
end

lib/tasks/gettext.rb:

namespace :gettext do
  namespace :po do
    desc "Update pot/po files."
    task :update => :environment do
      require 'gettext/utils'

      module GetText::ActiveRecordParser
        class << self
          alias_method :add_target_original, :add_target
          def add_target(targets, file, msgid)
            if /\|/ !~ msgid
              add_target_original(targets, file, msgid.classify)
              add_target_original(targets, file, msgid.classify.pluralize)
            end
            add_target_original(targets, file, msgid)
          end
        end
      end

      targets = Dir.glob("{app,config,components,lib}/**/*.{rb,erb,rjs}")
      GetText.update_pofiles(GETTEXT_DOMAIN, targets, "#{GETTEXT_DOMAIN} 0.0.1")
    end
  end

  namespace :mo do
    desc "Create mo-files"
    task :create do
      require 'gettext/utils'
      GetText.create_mofiles(true, "po", "locale")
    end
  end
end

app/controllers/application.rb:

class ApplicationController < ActionController::Base
  init_gettext GETTEXT_DOMAIN
  # ...
  private
  before_filter :localize_active_scaffold
  def localize_active_scaffold
    posix_locale = GetText.locale.to_posix
    posix_locale = "#{posix_locale}-#{posix_locale}" if /_/ !~ posix_locale
    lang = posix_locale.gsub(/_/, '-').downcase
    ActiveScaffold::Localization.lang = lang
    true
  end
end

翻訳メッセージのファイルpoを作って翻訳します。

% rake gettext:po:update
% mkdir po/ja
% msginit -i po/your-rails-application.pot -o po/ja/your-rails-application.po -l ja_JP
# 途中でメールアドレスを聞かれるので入力する

po/ja/your-rails-application.po:

# ...
#: app/models/book.rb:-
msgid "Book"
msgstr "本"

#: app/models/book.rb:-
msgid "Books"
msgstr "本一覧"
# ...
#: app/models/book.rb:-
msgid "Book|Title"
msgstr "タイトル"
# ...

翻訳メッセージをmoにコンパイルしてアクセスするとテーブル名や カラム名などが日本語になります。

% rake gettext:mo:create

config/initializers/以下を変更したので、サーバを再起動してか ら再度アクセスすると、日本語で表示されます。

ActiveScaffold + Ruby-GetText-Package

まとめ

ActiveScaffoldLocalizeとRuby-GetText-Packageを使って、 ActiveScaffoldを用いたアプリケーションの国際化・地域化を実現する方法 のひとつを紹介しました。

基本的に複数の国際化のしくみを同時に使うと問題が起きますが、 今回は以下のようにそれぞれの長所を活かすようにすみわけて、問 題を回避しています。

  • ActiveScaffoldが利用する固定のメッセージは ActiveScaffoldLocalizeで地域化
  • モデル関連や追加・更新が行われるメッセージについては Ruby-GetText-Packageで地域化

*1  config/initializers/lang/以下にファイルを作るというのはActiveScaffoldLocalizeの方針ではありません。ファイルの場所は特に方針はないようです。

2008-08-12

Rabbit 0.5.7リリース

昨日、 RDるびまの記事 )でスラ イドが書けるプレゼンテーションツール Rabbit がリリースされました。

サンプル

Rabbitではスライドを画像 HTML(+画像) PDF一覧表示 )などで出力することができます。

発表した後に資料を公開する場合や配布資料を作成する場合に利用 するとよいでしょう。

対象ユーザ

RDで書けることがウリというくらいなので、テキストエディタでス ライドを作成したい人が対象になります。おそらく、そのような人 はプログラマであることが多いと思うので、Rabbitはプログラマ向 けのプレゼンテーションツールといえるかもしれません。

スライドをテキストで作成すると以下のような利点があります。

  • バージョンコントロールシステムとの親和性が高い (diffの表示など)
  • 使い慣れたテキストエディタで編集できるため、編集作業の効 率がよい
  • 単なるテキストなので、専用のスライド表示ソフトウェアを用 いなくても内容を確認できる

一方、GUIの編集インターフェイスを備えたプレゼンテーションツー ル(wikipedia:PowerPointwikipedia:Keynoteなど)と比較すると、以下のような欠 点があります。

  • 見た目を微調整しずらい
  • 簡単な図を挿入することが面倒
    • 画像作成ソフトを起動して図を作成し、スライドに挿入

RabbitはRDで書かれたテキストだけではなく、PDFを入力としても 受け付けます。つまり、PDFビューアにもなります。

そこで、上記のような編集時の欠点を解決するために、別途PDF出 力ができるソフトウェアでスライドを作成し、Rabbitで表示すると いうことができます。RabbitをPDFビューアとして使うことにより、 Rabbitのユニークで実用的なユーザインターフェイスを使うことが できます。Rabbitの使い勝手に興味がある場合はこの方法を試して みるとよいかもしれません。

Rabbitのユーザインターフェイスに関してはまた別の機会にしてお きます。

まとめ

Rabbitの外面だけを紹介しました。難易度が高いと言われているイ ンストール方法や特徴的なユーザインターフェイスなどについては 触れませんでした。

2008-08-01

Cutter導入事例: Senna (1)

Sennaの単体テストフレームワー クとしてCutterを導入したときの手順です。自分のプロジェクトに Cutterを導入するときの参考になるかもしれません。全体として そこそこ長くなってしまったので、何回かに分割して紹介することに します。

内容はSennaのリポジトリ でやったことの一部です。リポジトリは公開されているので、試行錯誤の 後などをみたい場合はコミットを追いかけるとよいでしょう。また、ここで は断片としてしか出てこないコードについても、リポジトリの中には完全な 形で入っています。

もし、まだCutterについて知らない場合は、はじめにチュートリ アル を読んでください。

はじめに

まず、Sennaについて簡単に説明します。

Sennaは組み込み型の全文検索エンジンで、その機能をライブラリ として提供します。SennaのAPIはbasic APIやadvanced APIなどい くつかのグループにわかれています。

今回はSennaの単体テストフレームワークとしてCutterを導入し、 utility APIのひとつ、snippet*1のテストを 作成するまでを示します。このためには以下の作業が必要になりま す。

  • SennaのビルドシステムにCutterを組み込む
  • Cutterでsnippet APIのテストを記述する

作業に入る前にSennaのビルドシステムについて確認します。

Sennaのビルドシステム

SennaではGNU AutomakeGNU Libtoolな どGNUビルドシステムを利用したビルドシステムを採用しています。

CutterはGNUビルドシステムサポート用の機能をいくつか提供してい ます。そのため、GNUビルドシステムを用いているプロジェクトへ はCutterを容易に導入することができます。

もし、これからプロジェクトを始める場合でGNUビルドシステムを 採用する場合はCutterのチュートリアル が参考になるでしょう。

ビルドシステムへのCutterの組み込み

Sennaの単体テストフレームワークとしてCutterを採用するにあたっ て、以下のような条件を満たすこととします。

  • Cutterがない場合でもユーザがSennaをビルドできること
  • Cutterがない場合でも開発者がSennaをビルドできること
  • configure時にCutterを使用するかどうかを指定できること
  • Cutterで作成したテストはtest/unit/以下に配置すること
  • テストではテストを簡潔・容易に記述するためにGLibを利用す る

上記の中でのユーザと開発者の違いは、autogen.shを用いて自分で configureを作成するかどうかです。ユーザは開発者が作成した configureを利用するため、自分でconfigureを作成しません。一方、 開発者はSubversionリポジトリ内にはconfigureは入っていないの でautogen.shを使ってconfigure.acからconfigureを作成し、利用 します。つまり、違いは以下の通りになります。

  • ユーザ: configureのみ実行
  • 開発者: autogen.shとconfigureを実行

それでは、まずは、開発者はすべてCutterをインストールしている ものとしてCutter対応のconfigureを生成できるようにします。

cutter.m4

Cutterはconfigure.ac内で利用できるCutter検出用のM4マクロを cutter.m4として提供しています。このファイルは ${PREFIX}/share/aclocal/cutter.m4としてインストールされます。 ${PREFIX}/share/aclocal/以下に他の.m4ファイルがインストールされ ているような環境ではおそらくそのままで大丈夫ですが、そうでな い場合はautogen.shの中でaclocalを呼び出しているところを編集 して${PREFIX}/share/aclocal/以下を.m4ファイルの検索パスに加 える必要があります。

もし、Cutterのconfigureに--prefix=/tmp/localオプションをつけ てビルド・インストールした場合はautogen.shを以下のように変更 する必要があります。

Index: autogen.sh
===================================================================
--- autogen.sh	(リビジョン 820)
+++ autogen.sh	(作業コピー)
@@ -105,7 +105,7 @@
 echo "Running libtoolize ..."
 $LIBTOOLIZE --force --copy
 echo "Running aclocal ..."
-$ACLOCAL ${ACLOCAL_ARGS} -I .
+$ACLOCAL ${ACLOCAL_ARGS} -I . -I /tmp/local/share/aclocal
 echo "Running autoheader..."
 $AUTOHEADER
 echo "Running automake ..."

あるいはautogen.shを実行する時に環境変数ACLOCAL_ARGSを指定し ます。

% ACLOCAL_ARGS="-I /tmp/local/share/aclocal" ./autogen.sh

これでconfigure.ac内でCutterが提供する便利M4マクロを利用する 準備が整いました。

テスト作成用パッケージ

Cutterはパッケージを pkg-configのパッ ケージとしてインストールします。パッケージをpkg-configのパッ ケージとして作成しているのは、pkg-configが広く普及していて、 GNUビルドツールなどpkg-configに対応しているビルドシステムが 多いからです。

Cutterは、テスト作成用に以下の2つのパッケージを用意しています。

  • cutter: Cutterを利用してテストを作成する場合に利用
  • gcutter: cutterパッケージにGLibサポート機能を追加したパッ ケージ。GLibを利用してもっと簡潔・容易にテストを書きたい 場合に利用

今回はGLibを利用してテストを作成するので、cutterパッケージで はなくgcutterパッケージを利用します。

Cutterはconfigure.acで簡単にcutter/gcutterパッケージの設定を 行えるように以下のM4マクロを提供しています。

AC_CHECK_CUTTER

cutterパッケージ検出マクロです。以下の変数をAC_SUBSTしま す。

  • CUTTER: cutterコマンドのパス
  • CUTTER_CFLAGS: cutterパッケージを用いたテストをビルド するためのCFLAGS
  • CUTTER_LIBS: cutterパッケージを用いたテストをビルド するためのLIBS

また、cutterパッケージが利用不可能な場合は ac_cv_use_cutterが"no"になります。

AC_CHECK_GCUTTER
gcutterパッケージ検出マクロです。上述のAC_CHECK_CUTTERマ クロがAC_SUBSTする変数に加えて、以下の変数もAC_SUBSTしま す。
  • GCUTTER_CFLAGS: gcutterパッケージを用いたテストをビル ドするためのCFLAGS
  • GCUTTER_LIBS: gcutterパッケージを用いたテストをビルド するためのLIBS

今回はGLibサポートがついたgcutterパッケージを利用するので、 AC_CHECK_GCUTTERマクロを利用します。よってconfigure.acには以 下を追加することになります。

configure.ac:

AC_CHECK_GCUTTER

AM_CONDITIONAL([WITH_CUTTER], [test "$ac_cv_use_cutter" != "no"])
if test "$ac_cv_use_cutter" != "no"; then
  AC_DEFINE(WITH_CUTTER, 1, [Define to 1 if you use Cutter])
fi

これで、Makefile.amではCutterが利用できるかどうかはif WITH_CUTTER ... endifで判断できます。Makefile.amではCutterが 利用できない場合はテストプログラムをビルドしないようにします。 こうすることにより、ユーザがCutterをインストールしていなくて も、Sennaをビルドできます。

cutter.m4がない場合への対応

cutter.m4がない場合は./autogen.shの実行が失敗します。つまり、 開発者がconfigureを正常に生成できなくなります。

残念ながら、Cutterはそれほど有名なフリーソフトウェアではない ため、開発者がCutterをインストールしていることはほとんどあり ません。そこで、開発者がCutterをインストールしていなくても configureを生成できるようにします。*2

cutter.m4がインストールされているかどうかはAC_CHECK_GCUTTER 関数が定義されているかどうかでわかります。そのため、以下のよ うに書くことにより、Cutterがインストールされてない環境でも configureを生成できます。もちろん、生成されたconfigureには Cutterの検出機能などはありません。

configure.ac:

m4_ifdef([AC_CHECK_GCUTTER], [
AC_CHECK_GCUTTER
],
[ac_cv_use_cutter="no"])

AM_CONDITIONAL([WITH_CUTTER], [test "$ac_cv_use_cutter" != "no"])
if test "$ac_cv_use_cutter" != "no"; then
  AC_DEFINE(WITH_CUTTER, 1, [Define to 1 if you use Cutter])
fi

このようにAC_CHECK_GCUTTERの呼び出し部分をm4_ifdefの中に入れ るだけです。AC_CHECK_GCUTTERが定義されていない場合は ac_cv_use_cutterを"no"にしているのでWITH_CUTTERが真になるこ とはありません。

ビルドシステムへtest/unit/以下を追加

Cutterを用いたテストプログラムはtest/unit/以下に配置します。 このディレクトリは新規に作成するため、以下の作業が必要になり ます。

  • test/Makefile.amのSUBDIRSにunitを追加
  • configureでtest/unit/Makefileを生成する設定を追加
  • test/unit/Makefile.amの作成

まずは、test/Makefile.amのSUBDIRSにunitを追加し、test/unit/ 以下もビルド対象とします。

test/Makefile.am:

SUBDIRS = unit

続いて、configure.acのAC_CONFIG_FILESにtest/unit/Makefileを 追加し、configureがtest/unit/Makefileを生成するようにします。

configure.ac:

AC_CONFIG_FILES([... test/unit/Makefile ...])

最後に、test/unit/Makefile.amを作成し、test/unit/以下のビル ド方法を設定します。とりあえず、今は空っぽでかまいません。

% touch test/unit/Makefile.am

これで、test/unit/以下をSennaのビルドシステムに加えることがで きました。再度./autogen.sh, ./configureを実行してからmakeす れば、test/unit/以下もビルド対象になっていることがわかります。

% ./autogen.sh
% ./configure
% make
...
make[3]: ディレクトリ `.../test/unit' に入ります
...
テスト起動コマンド

test/unit/以下がビルド対象に加わったので、test/unit/以下に作 成するテストプログラムを起動するコマンドを作成します。このテ スト起動コマンドはmake checkから呼び出されることになります。

テスト起動コマンドは伝統的にrun-test.shというシェルスクリプ トになっています。このシェルスクリプトからcutterコマンドを呼 び出してテストを実行します。

cutterを実行するときはいくつかオプションを指定する必要があり ます。例えば、テストプログラムがあるディレクトリなどがそれで す。ここでrun-test.shを作成する理由は、cutterへ渡すオプション などを指定しなくてもよいようにするなど、より簡単にテストを実 行できるようにするためです。

テストが簡単に実行できるということはとても重要なことです。テ ストを実行することが面倒だと、だんだんテストを実行しなくなっ てしまうからです。テストが実行されないと、新しくテストを作成 することも面倒になってくるでしょう。これは悪い循環といえます。 これを防ぐためにも最初のうちから簡単にテストを実行できる仕組 みを用意しておくことが重要です。

また、引数なしでも動くrun-test.shを用意することにはもう一つ理 由があります。それは、GNU Automakeが提供するテスト起動の仕組 みであるmake checkからも利用できるようにすることです。make checkでは指定されたテスト起動スクリプトが引数なしでテストを実 行できる必要があります。*3

前置きが長くなりましたがテストをもっと簡単に走らせるためのス クリプト、run-test.shは以下のようになります。

test/unit/run-test.sh:

#!/bin/sh

export BASE_DIR="`dirname $0`"

if test x"$NO_MAKE" != x"yes"; then
    make -C $BASE_DIR/../../ > /dev/null || exit 1
fi

if test -z "$CUTTER"; then
    CUTTER="`make -s -C $BASE_DIR echo-cutter`"
fi

if test x"$CUTTER_DEBUG" = x"yes"; then
    CUTTER="$BASE_DIR/../../libtool --mode=execute gdb --args $CUTTER"
fi

CUTTER_ARGS="-s $BASE_DIR"
$CUTTER $CUTTER_ARGS "$@" $BASE_DIR

このスクリプトではmake check以外からも便利に利用できるように なっています。make check以外から起動された場合(つまり直接 test/unit/run-test.shを軌道した場合)は必要なビルドを行ってか らテストを起動します。つまり、run-test.shからテストを起動した 場合はビルド忘れがなくなります。

実は、上記のrun-test.shを直接起動できるようにするためには、 test/unit/Makefile.amにも一工夫する必要があります。それは、 configureで検出したcutterコマンドのパスをrun-test.shに伝える ためのターゲットを用意するということです。

test/unit/Makefile.am:

echo-cutter:
	@echo $(CUTTER)

これで、run-test.shを直接起動しても、必要に応じてビルドした り、情報を集めたりしてテストを起動してくれます。

また、make checkではテスト結果とビルド結果が混ざりそこそこの 出力になりますが、run-test.sh経由でビルド・テストを行うと必 要最小限の出力になり、問題の発見が簡単になります。実際の開発 は以下のようなサイクルになります。

  1. ソース変更
  2. test/unit/run-test.shを実行

    テスト失敗→(1)に戻る

  3. コミット→(1)に戻る

手順が少ないため開発のリズムが崩れにくくなります。このサイク ルをより簡単に行うための方法もあるのですが、それはまた別の機 会にします。

run-test.shができたので、make checkでrun-test.shを起動するよ うにMakefile.amを変更します。

test/unit/Makefile.am:

if WITH_CUTTER
TESTS = run-test.sh
TESTS_ENVIRONMENT = NO_MAKE=yes
...
endif

TESTS_ENVIRONMENTにNO_MAKE=yesを指定することにより、make check経由の場合はテスト実行前のmake実行を抑制します。

これでテストを実行するための環境は整いました。きりがよいので 今回はここまでにします。

まとめ

ここまでで、以下のことについて説明しました。

  • GNUビルドシステムを採用した既存のプロジェクトへのCutter の組み込み方法
    • cutter.m4で提供するM4マクロの使用方法
    • Cutterをインストールしていないユーザへの対応
    • Cutterをインストールしていない開発者への対応
  • Cutterを用いたテスト環境の構築方法
    • 便利なテスト起動スクリプトrun-test.shの作成方法

続きではテストを作成します。

*1  検索キーワードの周辺テキストの こと。ここではそれを取得するSennaの機能のこと。

*2  本当は開発者には頻繁に テストを走らせて欲しいのでCutterを必須にしたいところです。

*3  テスト起動スクリプトにオプションを 指定する場合は環境変数を利用します。

つづき: 2008-08-25
2008-07-25

Cutter 1.0.3リリース

昨日、C言語用の単体テストフレームワークである Cutterの1.0.3がリリースされま した。

実は、Cutter-1.0リリースから3回リリースしていま す。1.0.0以降はマイクロバージョンだけを上げていますが、新しく 追加された機能はマイクロとは思えません。例えば、Windows (MinGW)でのビルド に対応、GStreamer のサポートなどといった機能が含まれていました。過去のリリースに ついてはNEWS を見てください。

Cutterとは

Cutterはテストの書きやすさ・テスト結果からのデバッグのしやす さを重視したC言語用の単体テストフレームワークです。今回のリリー スからCutterの機能を説明したページ を用意 しました。

データ駆動テスト対応

同じテストを条件を変えて実行したい時があります。例えば、以下 のような場合です。

  • 複数の入力パターンがあり、それらを網羅的にテストする場合
  • 複数のバックエンドを抽象化し、どのバックエンドを利用して いる場合でも同じインターフェイスで扱えるライブラリをテス トする場合(Cでのcairo、Perl/Ruby/GaucheなどでのDBI、 RubyでのActiveRecordなど)

このような場合、必要な分だけテストコードをコピー&ペーストして テストを作成するよりも、以下のように書けるとテスト記述・管理 のコストを下げることができます。

  • テストは1つだけ用意
  • テスト条件、つまり、入力データを複数用意
  • 各入力データに対してそれぞれテストを実行

このようなテストの方法をデータ駆動テストと呼びます。

データ駆動テストではデータの用意の仕方にはいくつかの方法があ り、それぞれ利点があります。

データベースに保存された入力データを利用
大量のデータを用意したり、データを一括変更できるなどデー タ管理機能が豊富
CSVなど表形式の入力データを利用
Excelなどを利用して入力データを用意することができる
プログラム内で入力データを生成
動的にデータを用意するので、柔軟にデータを生成すること ができる。例えば、文字'a', 'b', 'c'を使って作られる長さ が3の文字列すべて("abc", "acb", ...)、などというデータ を用意できる。

Cutterでは今回のリリースで、最後の「プログラム内で入力データ を生成」する方法をサポートしました。使い方は以下の通りです。

  • data_XXX(void)を定義
  • data_XXX()中でcut_add_data()を使ってデータを登録
  • test_XXX(const void *data)を定義
    • dataにはcut_add_data()で登録したデータの1つが渡る

今までどおり、関数を定義するだけでよく、他のC言語用の単体テ ストフレームワークにあるような「登録処理」のようなことは必要 ありません。Cutterが自動で見つけてくれます。

コードにすると以下のようになります。

void
data_XXX(void)
{
    cut_add_data("データ1の名前", データ1, データ1をfreeする関数,
                 "データ2の名前", データ2, データ2をfreeする関数,
                 "データの例", strdup("test data"), free,
                 ...)
}

void
test_XXX(const void *data)
{
    /* dataはdata_XXX()で登録した「データ1」か「データ2」
       か「strdup("test data")」。test_XXX()はそれぞれに対
       して1回ずつ、計3回呼ばれる。
     */
    cut_assert_equal_string("test data", data);
}

具体例は cut_add_data() を見てください。

まとめ

Cutter 1.0.3ではデータ駆動テストをサポートし、より簡単にテス トがかけるようになりました。

つづき: 2008-10-16
2008-07-16

Pikzie 0.9.2リリース

Python用の単体テストフレームワークである Pikzie 0.9.2がリリースされま した。

以下のようにeasy_installでインストールできます。

% sudo easy_install Pikzie

類似のテスティングフレームワーク

Python用の単体テストフレームワークとしてはPythonに標準添付さ れている unittest や、unittestよりも柔軟にテストが書ける py.testなど があります。

また、unittest自体を拡張してより柔軟にテストが書けるようにし た nose もあります。noseはプラグイン方式をサポートしており、柔軟にテ ストが書ける機能以外にも、プラグインとして以下のような機能を 提供しています。

  • テストを省略するskipの追加
  • カバレッジをレポートする機能
  • doctest のサポート
  • ...

また、BDD用のテスティングフレームワークとしては pyspecがあります。

Pikzieを使う理由

上記に挙げた既存の単体テストフレームワークには共通して以下の ような問題点があります*1

  • 期待値(expected)と実測値(actual)の違いを判断することが困 難な出力結果

上記の問題を解決することがPikzieを使うもっとも大きな理由にな ります。これは、テストの失敗結果を使いながらデバッグすること が多いからです。

テストを書くことの重要性、テストの書き方*2などを解説しているものはよくみますが、 テストが失敗してそれを修正していく過程を書いているものはなか なかみかけません。しかし、テストは一度成功したらそれ以降も成 功し続けるわけではないのです。開発の途中でテストは何度も何度 も失敗します。例えば、以下のような場合に既存のテストが失敗す るかもしれません。

  • 新しい機能を追加するとき
  • 既存の機能を改良せずにパフォーマンスを改良するとき
  • 仕様が変わったとき
  • 仕様を変更せずに実装を修正するとき
  • テスト用データを追加・変更したとき
  • 依存しているライブラリのバージョンがあがったとき

つまり、新しいテスト・機能を開発していくときに既存のテストが 失敗することは当たり前のことです。

テスト結果の表示

以下はunittestで書かれたテストです。

# unittest-test.py
import unittest

class FailTestCase(unittest.TestCase):
    def test_fail(self):
        x = 1110111011110
        self.assertEquals(x + 100000, 1111111011111)

if __name__ == '__main__':
    unittest.main()

このテストの実行結果は以下のようになります。

% python unittest-test.py
F
======================================================================
FAIL: test_fail (__main__.FailTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "unittest-test.py", line 6, in test_fail
    self.assertEquals(x + 100000, 1111111011111)
AssertionError: 1110111111110 != 1111111011111

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)

期待値と実測値は以下のように表示されています。

AssertionError: 1110111111110 != 1111111011111

どこが違うのかがわかりにくいのがわかると思います。

noseを用いた場合もテストの書き方は変わりません。ただし、テス トを実行するためにnosetestsコマンドを使う必要があります。

% nosetests unittest-test.py
F
======================================================================
FAIL: test_fail (unittest-test.FailTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/tmp/unittest-test.py", line 6, in test_fail
    self.assertEquals(x + 100000, 1111111011111)
AssertionError: 1110111111110 != 1111111011111

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

以下はpy.testで書いたテストです。

# pytest-test.py
import py

def test_fail():
    x = 1110111011110
    assert x + 100000 == 1111111011111

このテストの実行結果は以下のようになります。py.testコマンド で起動します。

% /tmp/py-0.9.1/py/bin/py.test pytest-test.py
inserting into sys.path: /tmp/py-0.9.1
============================= test process starts =============================
executable:   /usr/bin/python  (2.5.2-final-0)
using py lib: /tmp/py-0.9.1/py <rev unknown>

pytest-test.py[1] F

_______________________________________________________________________________
____________________________ entrypoint: test_fail ____________________________

    def test_fail():
        x = 1110111011110
E       assert x + 100000 == 1111111011111
>       assert (1110111011110 + 100000) == 1111111011111

[/tmp/pytest-test.py:5]
_______________________________________________________________________________
================== tests finished: 1 failed in 0.08 seconds ===================

unittestと違ってxの値が展開されていますが、 (1110111011110 + 100000)の結果は表示されていません。こ のため、実際の値が期待値とどう違うのかがわかりません。

最後にPikzieで書いたテストです。

# pikzie-test.py
import pikzie

class TestFail(pikzie.TestCase):
    def test_fail(self):
        x = 1110111011110
        self.assert_equal(1111111011111, x + 100000)

実行結果は以下の通りです。特別なテスト起動コマンドは必要あり ません。

% python pikzie-test.py
F

1) Failure: TestFail.test_fail: self.assert_equal(1111111011111, x + 100000)
pikzie-test.py:6: self.assert_equal(1111111011111, x + 100000)
expected: <1111111011111>
 but was: <1110111111110>

Finished in 0.005 seconds

1 test(s), 0 assertion(s), 1 failure(s), 0 error(s), 0 pending(s), 0 notification(s)

以下のように期待値と実測値が並べて表示されるので、違いをみつ けやすくなります。

expected: <1111111011111>
 but was: <1110111111110>

また、場合によってはdiffが表示されます。

# pikzie-test-diff.py
import pikzie

class TestDiff(pikzie.TestCase):
    def test_diff(self):
        self.assert_equal("aaaaaxaaaaaaaaa", "aaaaaoaaaaaaaaa")

実行結果です。

% python pikzie-test-diff.py
F

1) Failure: TestDiff.test_diff: self.assert_equal("aaaaaxaaaaaaaaa", "aaaaaoaaaaaaaaa")
pikzie-test-diff.py:5: self.assert_equal("aaaaaxaaaaaaaaa", "aaaaaoaaaaaaaaa")
expected: <'aaaaaxaaaaaaaaa'>
 but was: <'aaaaaoaaaaaaaaa'>

diff:
- aaaaaxaaaaaaaaa
?      ^
+ aaaaaoaaaaaaaaa
?      ^

Finished in 0.033 seconds

1 test(s), 0 assertion(s), 1 failure(s), 0 error(s), 0 pending(s), 0 notification(s)

また、長い行の場合は折り返してdiffを表示します。

# pikzie-test-diff-long
import pikzie

class TestDiffLong(pikzie.TestCase):
    def test_diff_long(self):
        self.assert_equal("ppppppppppppppppppppppyyyyyyyyyyyyyyy"
                          "ttttttttttttttttttttttttttttttttttttt"
                          "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhon",
                          "ppppppppppppppppppppppyyyyyyyyyyyyyyy"
                          "ttttttttttttttttttttttttttttttttttttt"
                          "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhoon")

実行結果です。

% python pikzie-test-diff-long.py
F

1) Failure: TestDiffLong.test_diff_long: "ppppppppppppppppppppppyyyyyyyyyyyyyyy"
pikzie-test-diff-long.py:8: "ppppppppppppppppppppppyyyyyyyyyyyyyyy"
expected: <'ppppppppppppppppppppppyyyyyyyyyyyyyyyttttttttttttttttttttttttttttttttttttthhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhon'>
 but was: <'ppppppppppppppppppppppyyyyyyyyyyyyyyyttttttttttttttttttttttttttttttttttttthhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhoon'>

diff:
- ppppppppppppppppppppppyyyyyyyyyyyyyyyttttttttttttttttttttttttttttttttttttthhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhon
?                                                                                                             ^
+ ppppppppppppppppppppppyyyyyyyyyyyyyyyttttttttttttttttttttttttttttttttttttthhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhoon
?                                                                                                             ^

folded diff:
  ppppppppppppppppppppppyyyyyyyyyyyyyyyttttttttttttttttttttttttttttttttttttthhhh
- hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhon
? -
+ hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhoon
?                                +

Finished in 0.008 seconds

1 test(s), 0 assertion(s), 1 failure(s), 0 error(s), 0 pending(s), 0 notification(s)

diffの後に、長い行を折り返した結果に対するdiffも表示されてい ます。期待値・実測値が長い文字列で表現される場合は、折り返し た結果のdiffを見た方が異なる部分を見つけやすくなります。

まとめ

テストは頻繁に失敗します。Pikzieはテストの修正に必要な情報を できるだけ多く、簡潔に表示します。これは、テストの修正を迅速 に行うために大事なことです。

ここでは書きませんでしたが、もちろん、Pikzieは他のテスティン グフレームワークと同じように柔軟にテストを書くことができます。

*1  unittestは命名規則がCamelCaseで PEP 8 -- Style Guide for Python Codeから外れ ているという問題もあります。

*2  例えば、テストの粒 度やテスト駆動開発など

2008-06-27

ActiveLdap 1.0.1リリース

LDAPのエントリを ActiveRecord風のAPIでア クセスするためのライブラリ、 ActiveLdap 1.0.1がリリースされました。

ActiveLdapとは

ActiveRecord風のAPIとは1エントリを1オブジェクトとして扱える ということです。例えば、ユーザの説明を変更する場合は以下のよ うになります。

alice = User.find("alice")
alice.description = "New user"
alice.save!

ActiveRecordと同じように、各クラス間の関係を設定して便利にア クセスすることもできます。

class User < ActiveLdap::Base
  belongs_to :groups, :many => "memberUid"
end

class Group < ActiveLdap::Base
  has_many :users, :wrap => "memberUid"
end

alice = User.find("alice")
alice.groups # => [Group("friend"), Group("office"), ...]
alice.groups << Group.find("home")
alice.groups # => [Group("friend"), Group("office"), Group("home"), ...]

friend = Group.find("friend")
friend.users # => [User("alice"), User("bob"), ...]

ActiveRecordと同じように、Ruby on Railsと使用することもでき ます。

% script/plugin install http://ruby-activeldap.googlecode.com/svn/tags/r1.0.1/rails/plugin/active_ldap
% script/generate scaffold_active_ldap
% vim config/ldap.yml

ActiveLdapは以下のライブラリをバックエンドとして利用できます。

  • Ruby/LDAP
    • 拡張ライブラリ(速い、インストールが大変かもしれない)
  • Net::LDAP
    • Rubyのみで実装(遅い、インストールは簡単)
    • 2008/06/17時点の最新版0.0.4では動かない。 Subversion 版を利用する必要がある。
  • JNDIのLDAPサービスプロバイダ (実験的)
    • JRubyでのみ利用可能。

ベンチマーク

以下はActiveLdapに付属するベンチマークの結果です。ベンチマー クでは100エントリを検索しています。「Rehearsal(リハーサル)」 を行って、それぞれ2回ずつ実行しているのは、以前はキャッシュ などで2回目以降の結果がよくなることなどがあったためです。現 在はあまり意味がありませんが、歴史的に残っています。

% ruby benchmark/bench-al.rb --config benchmark/config.yaml
Populating...
Rehearsal -------------------------------------------------------
  1x: AL              0.080000   0.010000   0.090000 (  0.098738)
  1x: AL(No Obj)      0.010000   0.000000   0.010000 (  0.016623)
  1x: LDAP            0.000000   0.000000   0.000000 (  0.008674)
  1x: Net::LDAP       0.030000   0.000000   0.030000 (  0.045199)
---------------------------------------------- total: 0.130000sec

                          user     system      total        real
  1x: AL              0.080000   0.020000   0.100000 (  0.100959)
  1x: AL(No Obj)      0.010000   0.010000   0.020000 (  0.020697)
  1x: LDAP            0.000000   0.000000   0.000000 (  0.010129)
  1x: Net::LDAP       0.030000   0.000000   0.030000 (  0.042075)
Entries processed by Ruby/ActiveLdap: 100
Entries processed by Ruby/ActiveLdap (without object creation): 100
Entries processed by Ruby/LDAP: 100
Entries processed by Net::LDAP: 100
Cleaning...

各項目はそれぞれ以下の通りです。

  • AL: Ruby/LDAPバックエンドのActiveLdapで検索を行い、各エ ントリをオブジェクト化する(ActiveRecord風のAPIを利用す る場合)
  • AL(No Obj): Ruby/LDAPバックエンドのActiveLdapで検索を行 い、各エントリの結果をオブジェクト化しない(エントリを配 列やハッシュなどを使って表現)
  • LDAP: Ruby/LDAPで検索を行う
  • Net::LDAP: Net::LDAPで検索を行う

上記の結果からは以下のことが言えます。

  • 本当に速度が重要な場合にはRuby/LDAPを直接利用する方がよ い。
  • 利用できるならば、Net::LDAPよりもRuby/LDAPバックエンドを 利用した方がよい。
  • Net::LDAPを直接利用するよりも、オブジェクト化しない ActiveLdap + Ruby/LDAPバックエンドの方が速い。

多くの場合、1度に100エントリを処理することは少ないでしょう。 そのため、通常はActiveLdapで各エントリをオブジェクト化しても 問題は少ないといえます。

もし、1度に多くのエントリを扱う場合で、読み込み専用ならば、 オブジェクト化しない方法で利用することでパフォーマンスを改善 することができます。

まとめ

ActiveLdapを利用することでLDAPのエントリをオブジェクト指向的 なAPIで自然に処理することができます。

ActiveLdapは複数のLDAPバックエンドに対応しており、Rubyがイン ストールされている環境さえあれば動かすこともできます。 (Net::LDAPバックエンド使用時。ただしそんなに速くない)また、 JRubyでもほとんどの機能が動きます。

もし、Ruby/LDAPを利用できる環境であれば、Net::LDAPを直接利用 するよりも、ActiveLdap + Ruby/LDAPバックエンドを利用した方が よりオブジェクト指向らしいAPIでLDAPのエントリを操作できます。 また、速度が要求される場合であれば、オブジェクト化を行わない (オブジェクト指向らしいAPIを利用しない)ことにより、より高 速にLDAPのエントリを読み込むことができます。

2008-06-15

UxUで始めるFirefoxアドオンの自動テスト

Firefox用アドオンやXULRunnerアプリケーションなどのいわゆるXULアプリケーションは、ロジック部を主にJavaScriptで記述するため、script.aculo.usのテスト関連機能などJavaScript用のテストツールを使って自動テストを行えます。しかし、一般的なJavaScript用のテストツールはWebアプリケーションをテストすることを主眼において開発されているため、利用できる機能に制限があったり、HTMLではなくXULを使用するXULアプリケーションのテストでは不具合が生じたりする場合があります。

UxU(UnitTest.XUL)は、著名なXULアプリケーション開発支援ツールであるMozLabをベースにクリアコードで開発を行っている自動テスト実行ツールです。FirefoxやThunderbirdなどのXULアプリケーション上での利用を前提としているため、前述のような制限や問題を気にすることなく自動テストを記述できる、便利なヘルパーメソッドを利用できる、などの特長があります。

テストの記述方法やヘルパーメソッドの一覧はUxUの紹介ページに情報がありますが、ここではFirefox用アドオンのXUL/Migemoのテストを実例として示しながら、UxUによる自動テストの方法について簡単にご紹介をしたいと思います。Subversionのリポジトリ内にテストのサンプル用に用意されたタグがありますので、まずはこちらから必要なファイル一式をチェックアウトしておいてください。

クラスのユニットテスト

まずは最も簡単な例として、「tests」→「unit」とフォルダを辿った中にあるdocShellIterator.test.jsを見てみましょう。

XUL/MigemoはFirefoxのページ内検索を拡張するアドオンですので、フレーム内に検索語句が見つからなかったときは、子フレームや親フレームを検索対象として自動的に再検索を行うといった処理が必要になります。docShellIterator.test.jsでは、このための処理を担当するクラス「DocShellIterator」が正しく機能するかどうかをテストしています。

include
utils.include('../../components/pXMigemoFind.js', null, 'Shift_JIS');

冒頭では、ヘルパーメソッドのutils.includeを使って、DocShellIteratorクラスが定義されているファイルpXMigemoFind.jsの内容を取り込んでいます。第3引数で読み込むファイルのエンコーディングを指定していますが、これはファイルの中に含まれる日本語のコメントがShift_JISになっているためです。

なお、何らかの事情でそのままファイルをincludeできない(includeするとまずい)場合には、ファイルの内容をテキストとして読み込んで加工した後に評価するという方法もあります。上の例は、以下のように書き換えても同様に動作します。

var extract;
eval('extract = function() { '+
     utils.readFrom('../../components/pXMigemoFind.js') +
     '; return DocShellIterator }');
var DocShellIterator = extract();

utils.readFromはファイルの内容を文字列として返しますので、replaceなどを使って邪魔な部分を消してやれば、そのままではincludeできないファイルから必要な部分だけを取り出して評価できます。

テストケースの定義

このファイルにはテストケースが一つだけ定義されています。テストの前処理(setUp)と後処理(tearDown)は以下の通りです。

var DocShellIteratorTest = new TestCase('DocShellIteratorのユニットテスト');

DocShellIteratorTest.tests = {

  setUp : function() {
    yield Do(utils.loadURI('../res/frameTest.html'));
  },

  tearDown : function() {
    iterator.destroy();
    yield Do(utils.loadURI());
  },

setUpとtearDownは、それぞれのテストを実行する前と後に毎回実行されます。このテストケースでは、テスト実行前に「テストに使用するフレームにHTMLファイルを読み込む」という処理を行い、テスト終了後に「フレームに空のページを読み込んで内容を破棄する」という処理を行うことで、毎回必ずクリーンなテスト環境を準備するようにしています。

setUpの中では、「フレームに指定したページを読み込み、読み込みが完了するのを待って次に進む」といった処理待ちを行うために、ヘルパーメソッドとyield式を使用しています。yieldは本来はJavaScript 1.7で導入されたジェネレータのための物ですが、UxUではこれを応用して処理待ちを実現しています。JavaScriptで処理待ちというと、タイマーやonloadのようなイベントハンドラを使う方法が真っ先に思い浮かぶと思いますが、yield式を使用すれば、それらの場合に比べて処理の流れをフラットに記述することができます。

テストの内容

個々のテストの定義を見てみましょう。以下のテストでは、DocShellIteratorクラスのインスタンスを生成して、検索対象のフレームを移動する処理を実際に行い、処理結果が期待されたものと等しいかどうかをテストしています。

'前方検索': function() {
  // 1番目のフレームを初期状態としてインスタンス生成。
  iterator = new DocShellIterator(content.frames[0], false);
  // 初期化は成功したか?
  assert.initialized(iterator, content.frames[0]);

  // 次のフレームに移動するメソッドを実行。
  assert.isTrue(iterator.iterateNext());
  // フォーカスは正常に2番目のフレームに移動したか?
  assert.focus(iterator, content.frames[1]);
  assert.isFalse(iterator.isInitial);
  // もう一度フレームを移動。
  // 3番目のフレームは無いので、一巡して最上位のフレームにフォーカスする。
  assert.isTrue(iterator.iterateNext());
  // フォーカスは正常に最上位のフレームに移動したか?
  assert.focus(iterator, content);
  assert.isFalse(iterator.isInitial);
  // もう一度フレームを移動。1番目のフレームに戻る。
  assert.isTrue(iterator.iterateNext());
  // フォーカスは正常に1番目のフレームに移動したか?
  assert.focus(iterator, content.frames[0]);
},

処理が成功したかどうかを確認する手続きを、アサーション(宣言)と呼びます。assert.isTrue、assert.isFalse、assert.equalsなどのアサーション用ヘルパーメソッドは、実行されると渡された値を評価します。実際に渡された値が期待された値と等しければそのまま処理を続行しますが、値が異なっていた場合は「アサーションに失敗した」という内容の例外を発生させてテストの実行が中断されます。これらの例外やメソッド実行時に発生した未知の例外はUxUのインターフェース上に逐次表示され、例外が発生した行のスタックトレースを後で辿ることができるため、どの時点で問題が起こったのか、どこまでは予想通りに正常に動いたのかを詳しく調べてデバッグに役立てられます。

UxUのページのアサーション一覧にないassert.initializedやassert.focusはこのテスト専用に定義したカスタムアサーションです。その実体は、このファイルの冒頭でassertオブジェクトに追加されている新しいメソッドで、以下のように、内部でより単純なアサーションを複数行っています。

assert.focus = function(aIterator, aFrame) {
  assert.equals(aFrame.location.href, aIterator.view.location.href);
  assert.equals(aFrame, aIterator.view);
  assert.equals(aFrame.document, aIterator.document);
  assert.equals(aFrame.document.body, aIterator.body);
  var docShell = getDocShellFromFrame(aFrame);
  assert.docShellEquals(docShell, aIterator.current);
  assert.isTrue(aIterator.isFindable);
}

複数の条件を満たしているかどうかを確認する必要がある場合、そのままテストを記述すると、同じコードがテストケースの中に大量に並んでしまいます。そういった一連の処理はカスタムアサーションとしてまとめておけば、テストケースの内容を綺麗に見やすくできます。

テストの実行

テストの実行は、MozRepl互換のUxUサーバを起動してコンソールから接続して行うか、MozUnit互換のテストランナーを起動して行います。「ツール」メニューから「UnitTest.XUL」→「MozUnitテストランナー」と辿り、テスト実行用のGUIを起動します。

UxUのテスト実行用GUIは、テストケースのファイルのドラッグ&ドロップを受け付けます。実行したいテストのファイル(docShellIterator.test.js)をウィンドウにドラッグ&ドロップすると「作業中のファイル」欄のファイルのパスがドロップされたファイルのものになりますので、後は「実行」ボタンを押すだけで自動テストを実行できます。

テストの現在の実行状況はプログレスメーターで表示されます。アサーションに失敗したり予期しないエラーが起こったりするとプログレスメーターが赤くなりますが、正常にテストが成功すれば緑色のままです。すべてのテストケースの実行結果が緑となることを目指して開発や修正を進めていきましょう。

まとめ

このように、一連の処理と期待される結果をまとめておき、自動的にテストできるようにしておくと、開発した機能がきちんと動作しているかどうかを人手を煩わさず機械的に確かめられます。一通りのテストケースを作成するにはそれなりの手間と時間を要しますが、一度作成しておけばテストは何度でも簡単に実行できますので、うっかりミスによるエンバグを未然に防ぐ*1ことができます。

Firefox用のアドオンはFirefox自身が持っている機能と連携して動作するように作られることが多く、自動テストを作るのはなかなか大変です。UxUには処理待ち機能をはじめとした多くの便利な機能があり、「このような操作を行った時に、こういう結果になる」といった人間の操作をシミュレートする形の複雑なテストでも、処理の流れを追いやすい形で記述できます。Firefox用アドオンの自動テスト作成にはまさにうってつけと言えるでしょう。

*1  エンバグしてしまってもすぐにそれに気がつくことができる

2008-06-11

GaUnit新API

Gauche 用の単体テストフレームワーク GaUnit の0.1.4がリリースされました。

Gaucheには標準でgauche.testという単体テスト用のモジュールが付 属しています。このモジュールはテストスクリプトをはじめから順 に実行していくという素直なテスト実行方式を採用しています。こ の方式では一連のテストがどのように実行されていくかがわかりや すい半面、(場合によっては)以下のような問題があります。

  • 特定のテストのみを実行することができない
  • 各テスト毎にgoshプロセスを起動する必要がある (各テストが他のテストの影響をうけないようにするため)
  • テストを別々に走らせるため、すべてのテスト結果を見るため には、一度ファイル(テストログ)に保存しておいてあとで確 認する、としなければいけない(テストを走らせる前にテスト ログを削除して前のテスト結果を削除する必要がある)

また、gauche.testはテストの成功、失敗にかかわらずテスト結果が 常に冗長であるという問題があります。テストが失敗した項目(修正 の必要がある項目)についてのみ情報を詳細する方が、より合理的で しょう。

ということで、GaUnitです。GaUnitは xUnit系の単体テ ストフレームワークで、上記のgauche.testの問題を解決します。 また、テスト失敗時以外はテスト結果に余計な情報を出力しないため 無駄がありません。そんなGaUnitが今回のリリース(正確には1つ前の 0.1.3)からより書きやすいAPIを提供して、以下の2点を行うだけで すむようになりました。

  • テスト用のモジュールを作成
  • その中でtest-始まりの手続きを定義

実際のコードは以下のようになります。

(define-module test-your-library
  (extend test.unit.test-case)
  (use your-library))
(select-module test-your-module)

(define (test-your-module-procedure1)
  (assert-equal "Good!" (your-module-procedure1))
  ...
  #f)

(define (test-your-module-procedure2)
  (assert-equal 29 (your-module-procedure2))
  ...
  #f)

(provide "test-your-module")

最後にtest-*