昨日、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でした。
ちょうど1ヶ月前の話の続きです。
前回でCutterでテストを作成するための環境ができたので、実際にテストを作成していきます。と、思ったのですが、もう一点やらなければいけないことが残っていました。テスト対象のライブラリの初期化についてです。
今回はテスト対象ライブラリの初期化について説明してからテスト作成に入ります。
前回同様、コードの断片がでてきます。完全なものはSennaのリポジトリを見てください。
Sennaのようにライブラリ初期化・終了関数 (sen_init()/sen_fin())を用意している場合は、テストの作成に 入る前に、もう一つ用意しておかなければいけない仕組みがありま す。このような関数を持っているライブラリをテストする場合は、 テスト全体を実行する前に初期化関数を、実行した後に終了関数を 呼び出す必要があります。これを行う仕組みを用意する必要があり ます。
cutterコマンドは指定されたディレクトリ以下の共有ライブラリを かき集めて、その中からテストを検出して実行します。その時に以 下の条件にあう共有ライブラリを見つけると、テスト全体を実行す る前後に特定の関数を実行することができます。これは、今まさに 必要としている機能です。
この共有ライブラリが以下の名前のシンボルを公開している場合は、 その関数をテスト全体を実行する前後に実行します。ここでは、共 有ライブラリのファイル名はsuite_senna_test.soとします。
「_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つ作成します。
テストの流れは以下の通りです。
基本的なCutterのテスト作成方法についてはチュートリアル を参考にしてください。
まずは、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_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_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_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つできました。 同様に、異常な場合や違ったデータを用いた場合などのテストを作 成していきます。
せっかくなので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回に分けて以下のことについて説明しました。
Cで書かれたプロジェクトに単体テストフレームワークを導入する 場合はCutterも検討してみてはいかがでしょうか。
現在、Railsに対応した国際化の仕組みがいくつかあります。しかし、それぞれが 独自の方法で実現しているため、それらを組み合わせて使うと混沌 とした状態に陥ることも少なくありません。
ここでは、モデルから動的にきれいな画面とコントローラ部分を生 成するActiveScaffoldを用 いた場合の国際化(i18n)と地域化(l10n)の実現方法のひとつを 紹介します。この方法では、 ActiveScaffoldLocalize と Ruby-GetText-Package を組み合わせます。混沌とする部分はそれなりになじませます。
Railsで使用できる国際化の仕組みの比較はRails Wiki (英語)が詳しいです。
Ruby-GetText-Package には、以下のような地域化対象のメンテナン スのことを考慮した機能があるので、地域化対象メッセージが増加 したり更新される場合には有力な候補になるでしょう。
Railsやプラグインなどが提供しているメッセージだけを地域化した いなど、地域対象メッセージが変化しない場合はその他の仕組みも 有力な候補になるでしょう。例えば、ActiveScaffold用の 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/
見ての通り、「検索」などのメニューは日本語になりますが、テー ブル名からきている「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/以下を変更したので、サーバを再起動してか ら再度アクセスすると、日本語で表示されます。
(「本を作成」ではなく「本一覧を作成」になっているのはこ のパッチ で直ります。)
ActiveScaffoldLocalizeのこのやり方は手軽ですが、地域化対象の メッセージが変更になった場合(例: 「Title」から「Name」に変更) や、地域化対象のメッセージをtypoした場合(例: 「Title」ではな く「title」としていた)に気づきにくいという問題があります。 このような問題に対してはRuby-GetText-Packageが有効です。
ということで、ActiveScaffoldのメッセージは ActiveScaffoldLocalizeで地域化し、それ以外は 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/以下を変更したので、サーバを再起動してか ら再度アクセスすると、日本語で表示されます。
ActiveScaffoldLocalizeとRuby-GetText-Packageを使って、 ActiveScaffoldを用いたアプリケーションの国際化・地域化を実現する方法 のひとつを紹介しました。
基本的に複数の国際化のしくみを同時に使うと問題が起きますが、 今回は以下のようにそれぞれの長所を活かすようにすみわけて、問 題を回避しています。
*1 config/initializers/lang/以下にファイルを作るというのはActiveScaffoldLocalizeの方針ではありません。ファイルの場所は特に方針はないようです。
昨日、 RD (るびまの記事 )でスラ イドが書けるプレゼンテーションツール Rabbit がリリースされました。
Rabbitではスライドを画像 やHTML(+画像) 、 PDF (一覧表示 )などで出力することができます。
発表した後に資料を公開する場合や配布資料を作成する場合に利用 するとよいでしょう。
RDで書けることがウリというくらいなので、テキストエディタでス ライドを作成したい人が対象になります。おそらく、そのような人 はプログラマであることが多いと思うので、Rabbitはプログラマ向 けのプレゼンテーションツールといえるかもしれません。
スライドをテキストで作成すると以下のような利点があります。
一方、GUIの編集インターフェイスを備えたプレゼンテーションツー ル(wikipedia:PowerPointや wikipedia:Keynoteなど)と比較すると、以下のような欠 点があります。
RabbitはRDで書かれたテキストだけではなく、PDFを入力としても 受け付けます。つまり、PDFビューアにもなります。
そこで、上記のような編集時の欠点を解決するために、別途PDF出 力ができるソフトウェアでスライドを作成し、Rabbitで表示すると いうことができます。RabbitをPDFビューアとして使うことにより、 Rabbitのユニークで実用的なユーザインターフェイスを使うことが できます。Rabbitの使い勝手に興味がある場合はこの方法を試して みるとよいかもしれません。
Rabbitのユーザインターフェイスに関してはまた別の機会にしてお きます。
Rabbitの外面だけを紹介しました。難易度が高いと言われているイ ンストール方法や特徴的なユーザインターフェイスなどについては 触れませんでした。
Sennaの単体テストフレームワー クとしてCutterを導入したときの手順です。自分のプロジェクトに Cutterを導入するときの参考になるかもしれません。全体として そこそこ長くなってしまったので、何回かに分割して紹介することに します。
内容はSennaのリポジトリ でやったことの一部です。リポジトリは公開されているので、試行錯誤の 後などをみたい場合はコミットを追いかけるとよいでしょう。また、ここで は断片としてしか出てこないコードについても、リポジトリの中には完全な 形で入っています。
もし、まだCutterについて知らない場合は、はじめにチュートリ アル を読んでください。
まず、Sennaについて簡単に説明します。
Sennaは組み込み型の全文検索エンジンで、その機能をライブラリ として提供します。SennaのAPIはbasic APIやadvanced APIなどい くつかのグループにわかれています。
今回はSennaの単体テストフレームワークとしてCutterを導入し、 utility APIのひとつ、snippet*1のテストを 作成するまでを示します。このためには以下の作業が必要になりま す。
作業に入る前にSennaのビルドシステムについて確認します。
SennaではGNU Automakeや GNU Libtoolな どGNUビルドシステムを利用したビルドシステムを採用しています。
CutterはGNUビルドシステムサポート用の機能をいくつか提供してい ます。そのため、GNUビルドシステムを用いているプロジェクトへ はCutterを容易に導入することができます。
もし、これからプロジェクトを始める場合でGNUビルドシステムを 採用する場合はCutterのチュートリアル が参考になるでしょう。
Sennaの単体テストフレームワークとしてCutterを採用するにあたっ て、以下のような条件を満たすこととします。
上記の中でのユーザと開発者の違いは、autogen.shを用いて自分で configureを作成するかどうかです。ユーザは開発者が作成した configureを利用するため、自分でconfigureを作成しません。一方、 開発者はSubversionリポジトリ内にはconfigureは入っていないの でautogen.shを使ってconfigure.acからconfigureを作成し、利用 します。つまり、違いは以下の通りになります。
それでは、まずは、開発者はすべてCutterをインストールしている ものとしてCutter対応のconfigureを生成できるようにします。
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つのパッケージを用意しています。
今回はGLibを利用してテストを作成するので、cutterパッケージで はなくgcutterパッケージを利用します。
Cutterはconfigure.acで簡単にcutter/gcutterパッケージの設定を 行えるように以下のM4マクロを提供しています。
cutterパッケージ検出マクロです。以下の変数をAC_SUBSTしま す。
また、cutterパッケージが利用不可能な場合は ac_cv_use_cutterが"no"になります。
今回は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がない場合は./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が真になるこ とはありません。
Cutterを用いたテストプログラムはtest/unit/以下に配置します。 このディレクトリは新規に作成するため、以下の作業が必要になり ます。
まずは、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経由でビルド・テストを行うと必 要最小限の出力になり、問題の発見が簡単になります。実際の開発 は以下のようなサイクルになります。
test/unit/run-test.shを実行
テスト失敗→(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実行を抑制します。
これでテストを実行するための環境は整いました。きりがよいので 今回はここまでにします。
ここまでで、以下のことについて説明しました。
続きではテストを作成します。
*1 検索キーワードの周辺テキストの こと。ここではそれを取得するSennaの機能のこと。
*2 本当は開発者には頻繁に テストを走らせて欲しいのでCutterを必須にしたいところです。
*3 テスト起動スクリプトにオプションを 指定する場合は環境変数を利用します。
昨日、C言語用の単体テストフレームワークである Cutterの1.0.3がリリースされま した。
実は、Cutter-1.0リリースから3回リリースしていま す。1.0.0以降はマイクロバージョンだけを上げていますが、新しく 追加された機能はマイクロとは思えません。例えば、Windows (MinGW)でのビルド に対応、GStreamer のサポートなどといった機能が含まれていました。過去のリリースに ついてはNEWS を見てください。
Cutterはテストの書きやすさ・テスト結果からのデバッグのしやす さを重視したC言語用の単体テストフレームワークです。今回のリリー スからCutterの機能を説明したページ を用意 しました。
同じテストを条件を変えて実行したい時があります。例えば、以下 のような場合です。
このような場合、必要な分だけテストコードをコピー&ペーストして テストを作成するよりも、以下のように書けるとテスト記述・管理 のコストを下げることができます。
このようなテストの方法をデータ駆動テストと呼びます。
データ駆動テストではデータの用意の仕方にはいくつかの方法があ り、それぞれ利点があります。
Cutterでは今回のリリースで、最後の「プログラム内で入力データ を生成」する方法をサポートしました。使い方は以下の通りです。
今までどおり、関数を定義するだけでよく、他の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ではデータ駆動テストをサポートし、より簡単にテス トがかけるようになりました。
Python用の単体テストフレームワークである Pikzie 0.9.2がリリースされま した。
以下のようにeasy_installでインストールできます。
% sudo easy_install Pikzie
Python用の単体テストフレームワークとしてはPythonに標準添付さ れている unittest や、unittestよりも柔軟にテストが書ける py.testなど があります。
また、unittest自体を拡張してより柔軟にテストが書けるようにし た nose もあります。noseはプラグイン方式をサポートしており、柔軟にテ ストが書ける機能以外にも、プラグインとして以下のような機能を 提供しています。
また、BDD用のテスティングフレームワークとしては pyspecがあります。
上記に挙げた既存の単体テストフレームワークには共通して以下の ような問題点があります*1。
上記の問題を解決することが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 例えば、テストの粒 度やテスト駆動開発など
LDAPのエントリを ActiveRecord風のAPIでア クセスするためのライブラリ、 ActiveLdap 1.0.1がリリースされました。
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は以下のライブラリをバックエンドとして利用できます。
以下は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...
各項目はそれぞれ以下の通りです。
上記の結果からは以下のことが言えます。
多くの場合、1度に100エントリを処理することは少ないでしょう。 そのため、通常はActiveLdapで各エントリをオブジェクト化しても 問題は少ないといえます。
もし、1度に多くのエントリを扱う場合で、読み込み専用ならば、 オブジェクト化しない方法で利用することでパフォーマンスを改善 することができます。
ActiveLdapを利用することでLDAPのエントリをオブジェクト指向的 なAPIで自然に処理することができます。
ActiveLdapは複数のLDAPバックエンドに対応しており、Rubyがイン ストールされている環境さえあれば動かすこともできます。 (Net::LDAPバックエンド使用時。ただしそんなに速くない)また、 JRubyでもほとんどの機能が動きます。
もし、Ruby/LDAPを利用できる環境であれば、Net::LDAPを直接利用 するよりも、ActiveLdap + Ruby/LDAPバックエンドを利用した方が よりオブジェクト指向らしいAPIでLDAPのエントリを操作できます。 また、速度が要求される場合であれば、オブジェクト化を行わない (オブジェクト指向らしいAPIを利用しない)ことにより、より高 速にLDAPのエントリを読み込むことができます。
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」が正しく機能するかどうかをテストしています。
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 エンバグしてしまってもすぐにそれに気がつくことができる
Gauche 用の単体テストフレームワーク GaUnit の0.1.4がリリースされました。
Gaucheには標準でgauche.testという単体テスト用のモジュールが付 属しています。このモジュールはテストスクリプトをはじめから順 に実行していくという素直なテスト実行方式を採用しています。こ の方式では一連のテストがどのように実行されていくかがわかりや すい半面、(場合によっては)以下のような問題があります。
また、gauche.testはテストの成功、失敗にかかわらずテスト結果が 常に冗長であるという問題があります。テストが失敗した項目(修正 の必要がある項目)についてのみ情報を詳細する方が、より合理的で しょう。
ということで、GaUnitです。GaUnitは xUnit系の単体テ ストフレームワークで、上記のgauche.testの問題を解決します。 また、テスト失敗時以外はテスト結果に余計な情報を出力しないため 無駄がありません。そんなGaUnitが今回のリリース(正確には1つ前の 0.1.3)からより書きやすいAPIを提供して、以下の2点を行うだけで すむようになりました。
実際のコードは以下のようになります。
(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-*