注意: 長いです。
一言まとめ: withinとtest-unit-capybaraを使ってHTMLのテストを書くと問題を見つけやすくなる。あわせて読みたい: デバッグしやすいassert_equalの書き方
HTMLに対するテストに限らず、開発を進めていく中でテストが失敗する状況になることは日常的にあることです。HTMLの場合は、入力フォームのラベルを変更したり、項目を追加したら既存のテストが失敗するようになるでしょう。そのとき、どのようにテストを書いていれば原因を素早く見つけられるのかを説明します。ポイントは「注目しているノードを明示すること」です。
さて、Rubyで処理結果のHTMLをテストするときにはどんなライブラリを使っていますか?The Ruby ToolboxにあるBrowser testingカテゴリを見てみると、Capybaraが最も使われていて、次にWebratが広く使われているようです。どちらも同様の機能が揃っているため、特別な使い方をしないならどちらを使っても困ることはないでしょう。もし、今から使いはじめるならCapybaraの方がよいでしょう。これは、Capybaraの方がより活発に開発されているためです。
ここでは、CapybaraでHTMLのテストを書いたときを例にして、どのようにテストを書けば失敗した時に原因をすぐに見つけられるようになるかを説明します。考え方は他のツールでも応用できますし、test-unitやRSpecなどのテスティングフレームワークにも依存しません。そのため、ここでは、もっともRubyらしく書けるテスティングフレームワークであるtest-unit 2を使います。
まずは一般的なCapybaraでのHTMLのテストがどのように書けるのかを確認します。
まず、以下のようなRackアプリケーションがあるとします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class MyRackApplication def call(env) html = <<-HTML <html> <head> <title>Welcome! - my site</title> </head> <body> <h1>Welcome!</h1> <div class="header"> <p>No navigation.</p> </div> </body> </html> HTML [200, {"Content-Type" => "text/html"}, [html]] end end |
はじめにCapybaraでテストを書ける状態にします。include Capybara::DSLとCapybara.app=がポイントです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class MyRackApplication # ... end gem "test-unit" require "test/unit" require "capybara/dsl" class TestMyRackApplication < Test::Unit::TestCase include Capybara::DSL def setup Capybara.app = MyRackApplication.new end end |
それでは、まずは期待した見出しが返ってきているかを確認してみましょう。
1 2 3 4 5 6 7 |
class TestMyRackApplication < Test::Unit::TestCase # ... def test_heading visit("/") assert_equal("Welcome!", find("h1").text) end end |
実行するとテストがパスするので正しい値が返ってきていることを確認できます。
% ruby test-capybara.rb Loaded suite test-capybara Started . Finished in 0.427469388 seconds. 1 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications 100% passed 2.34 tests/s, 2.34 assertions/s
ヘッダー部分も確認しましょう。テスト対象のHTMLの一部も再掲します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
class MyRackApplication def call(env) html = <<-HTML <html> <!-- ... --> <body> <h1>Welcome!</h1> <div class="header"> <p>No navigation.</p> </div> </body> </html> HTML [200, {"Content-Type" => "text/html"}, [html]] end end # ... class TestMyRackApplication < Test::Unit::TestCase # ... def test_header visit("/") assert_equal("No navigation.", find(".header p").text) end end |
これもパスします。
% ruby test-capybara.rb Loaded suite test-capybara Started .. Finished in 0.419134417 seconds. 2 tests, 2 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications 100% passed 4.77 tests/s, 4.77 assertions/s
雰囲気はわかりましたね。
それでは、HTMLを少し変更してナビゲーション用のリンクをいれましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class MyRackApplication def call(env) html = <<-HTML <html> <!-- ... --> <body> <h1>Welcome!</h1> <div class="header"> <ul> <li><a href="/">Top</a></li> </ul> </div> </body> </html> HTML [200, {"Content-Type" => "text/html"}, [html]] end end |
HTMLの構造が変わったのでテストは失敗します。
% ruby test-capybara.rb
Loaded suite test-capybara
Started
E
===============================================================================
Error:
test_header(TestMyRackApplication):
Capybara::ElementNotFound: Unable to find css ".header p"
/var/lib/gems/1.9.1/gems/capybara-1.1.2/lib/capybara/node/finders.rb:154:in `raise_find_error'
/var/lib/gems/1.9.1/gems/capybara-1.1.2/lib/capybara/node/finders.rb:27:in `block in find'
/var/lib/gems/1.9.1/gems/capybara-1.1.2/lib/capybara/node/base.rb:46:in `wait_until'
/var/lib/gems/1.9.1/gems/capybara-1.1.2/lib/capybara/node/finders.rb:27:in `find'
(eval):2:in `find'
/var/lib/gems/1.9.1/gems/capybara-1.1.2/lib/capybara/dsl.rb:161:in `find'
test-capybara.rb:40:in `test_header'
===============================================================================
.
Finished in 0.431328598 seconds.
2 tests, 1 assertions, 0 failures, 1 errors, 0 pendings, 0 omissions, 0 notifications
50% passed
4.64 tests/s, 2.32 assertions/s
さて、ここでどうしますか?よく使われる方法は以下のような方法ではないでしょうか。
puts sourceを埋め込んでHTMLを確認する。assert_equalのメッセージにsourceを指定する。save_and_open_browserでブラウザで確認する。それでは、それぞれの方法についてみていきましょう。
puts sourceを埋め込むinclude Capybara::DSLするとsourceメソッドが追加され、アプリケーションが返した生のHTMLを確認することができます。
1 2 3 4 5 6 7 8 |
class TestMyRackApplication < Test::Unit::TestCase # ... def test_header visit("/") puts source assert_equal("No navigation.", find(".header p").text) end end |
テストを実行するとHTMLが出力されます。
% ruby test-capybara.rb
Loaded suite test-capybara
Started
<html>
<head>
<title>Welcome! - my site</title>
</head>
<body>
<h1>Welcome!</h1>
<div class="header">
<ul>
<li><a href="/">Top</a></li>
</ul>
</div>
</body>
</html>
E
===============================================================================
Error:
test_header(TestMyRackApplication):
Capybara::ElementNotFound: Unable to find css ".header p"
...
出力されたHTMLと「Unable to find css ".header p"」というエラーメッセージと目CSSセレクタを活用して、どうしてCSSセレクタ".header p"がマッチしなくなったのかを考えます。
この方法はテスト自体を書き換える必要があるため、テストが失敗したらテストを修正してもう一度実行し直さなければいけません。テストがパスするようになったら変更を元に戻すことも忘れてはいけません。
assert_equalのメッセージにsourceを指定assert_equalに限らずアサーションには失敗時に表示する追加のメッセージを指定できます。この方法では、失敗したときのみHTMLが表示されるため「失敗したときだけテストを変更する」といったことをする必要はありません。
1 2 3 4 5 6 7 |
class TestMyRackApplication < Test::Unit::TestCase # ... def test_header visit("/") assert_equal("No navigation.", find(".header p").text, source) end end |
実行してみます。
% ruby test-capybara.rb
Loaded suite test-capybara
Started
E
===============================================================================
Error:
test_header(TestMyRackApplication):
Capybara::ElementNotFound: Unable to find css ".header p"
/var/lib/gems/1.9.1/gems/capybara-1.1.2/lib/capybara/node/finders.rb:154:in `raise_find_error'
...
===============================================================================
...
おや、エラーメッセージの中にHTMLが出力されていませんね。もう一度assert_equalを確認してみましょう。
1 |
assert_equal("No navigation.", find(".header p").text, source) |
問題なさそうに見えますが、どうして出力されていないのでしょうか。
それは、assert_equalが呼び出される前にfindの中で例外が発生しているからです。Capybaraでは要素が見つけられなかったときはCapybara::ElementNotFound例外を投げるのでassert_equalは呼び出されなかったのです。
これを回避するためには以下のようにfindではなくhas_selector?で事前にCSSセレクタがマッチするかを確認する必要があります。
1 2 3 4 5 6 7 8 |
class TestMyRackApplication < Test::Unit::TestCase # ... def test_header visit("/") assert_true(has_selector?(".header p"), source) assert_equal("No navigation.", find(".header p").text) end end |
これを実行するとHTMLが出力されます。
% ruby test-capybara.rb
Loaded suite test-capybara
Started
F
===============================================================================
Failure:
test_header(TestMyRackApplication) [test-capybara.rb:40]:
<html>
<head>
<title>Welcome! - my site</title>
</head>
<body>
<h1>Welcome!</h1>
<div class="header">
<ul>
<li><a href="/">Top</a></li>
</ul>
</div>
</body>
</html>
.
<true> expected but was
<false>
===============================================================================
...
しかし、今度は指定したCSSセレクタが出力されません。
そもそも、findする前に事前に存在を確認するようなテストの書き方になってしまうのでは、せっかくのCapybaraのすっきりした記法を活かせていないと言えるでしょう。
ちなみに、RSpecでは以下のように書くことになります。こちらもHTMLとCSSセレクタを同時に出力してくれないのであまりうれしくありません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
require "capybara/rspec" class MyRackApplication def call(env) html = <<-HTML <html> <head> <title>Welcome! - my site</title> </head> <body> <h1>Welcome!</h1> <div class="header"> <ul> <li><a href="/">Top</a></li> </ul> </div> </body> </html> HTML [200, {"Content-Type" => "text/html"}, [html]] end end Capybara.app = MyRackApplication.new describe MyRackApplication, :type => :request do it "should have header content" do visit("/") page.should have_selector(".header p") find(".header p").text.should == "No navigation." end end |
実行すると以下のようにCSSセレクタのみが出力されます。
% rspec capybara_spec.rb
F
Failures:
1) MyRackApplication should have header content
Failure/Error: page.should have_selector(".header p")
expected css ".header p" to return something
# ./capybara_spec.rb:29:in `block (2 levels) in <top (required)>'
Finished in 0.60582 seconds
1 example, 1 failure
Failed examples:
rspec ./capybara_spec.rb:27 # MyRackApplication should have header content
ということで、この方法は「失敗したときだけ必要な情報を出力して原因を素早く見つけたい」という期待する結果を実現できません。メッセージの中にsourceとCSSセレクタを指定すれば実現できなくもありませんが、テストが書きづらくなってしまうため割にあいません。
save_and_open_browserでブラウザで確認include Capybara::DSLするとsave_and_open_browserメソッドが追加され、アプリケーションが返した生のHTMLをブラウザで開いて確認することができます*1。
1 2 3 4 5 6 7 8 |
class TestMyRackApplication < Test::Unit::TestCase # ... def test_header visit("/") save_and_open_page assert_equal("No navigation.", find(".header p").text) end end |
ブラウザでHTMLを確認することによりFirebugなどHTMLの構造を視覚的に確認することができます。確認してどうしてCSSセレクタ".header p"がマッチしなくなったのかを考えます。
この方法もテスト自体を書き換える必要があるため、テストが失敗したらテストを修正してもう一度実行し直さなければいけません。テストがパスするようになったら変更を元に戻すことも忘れてはいけません。元に戻さないままコミットしてしまうと、他の開発者の環境でもテストを実行するたびにブラウザが起動してしまいます。
ブラウザで確認する方法が視覚的で一番わかりやすいですが、ページの内容が増えてくるとページ全体から問題の箇所を素早く探すのは大変です。一方、コンソールにHTMLを出力する方法は視覚的ではありませんが、HTMLをテキストとして検索することができるところは便利です。しかし、やはりページ全体から問題の箇所を素早く探すのは大変です。
小規模のWebアプリケーションでもそこそこのHTMLになり、どの方法でもページ全体から問題の箇所を素早く探すのは大変です。ということで、どの方法も今一歩と言えます。
どうして問題の箇所を素早く探すのが大変かというと、探索範囲が広いからです。探索範囲が狭くなれば素早く見つけやすくなります。ついでに言うと、テストの書きやすさを損なわずにできるだけ自然に狭くしたいという希望もあります。
ところで、Capybaraにはwithinというメソッドがあることを知っていますか?これを使うと検索範囲を限定できます。例えば、以下は同じ意味になります。
1 2 3 4 5 6 |
# これまでの書き方 find(".header p").text # withinを使った書き方 within(".header") do find("p").text end |
withinを使ってテストを書きなおしてみましょう。
1 2 3 4 5 6 7 8 9 |
class TestMyRackApplication < Test::Unit::TestCase # ... def test_header visit("/") within(".header") do assert_equal("No navigation.", find("p").text) end end end |
テストを実行するとfindのところで失敗するのですが、この時点では".header"はマッチすることがわかっています。つまり、findはHTML全体ではなく、以下のHTML断片内で"p"がマッチすることを期待しています。
1 2 3 4 5 |
<div class="header"> <ul> <li><a href="/">Top</a></li> </ul> </div> |
このくらいの量であればコンソールに出力されても解析しやすいでしょう。少なくとも「<p>はないが<ul>はある」ということはすぐにわかります。このヒントがあれば原因を特定するのにだいぶ役立つはずです。
では、withinのブロック内でマッチしなかった場合に現在マッチしたノードのHTMLを出力するにはどうしたらよいでしょうか。これを実現できると、失敗した時だけ必要最低限の情報を得られて素早く原因を見つけられそうです。しかもCapybaraの自然な使い方です。
test-unit 2でCapybaraを便利に使うためのtest-unit-capybaraというライブラリがあります。これを使えば、これまで通りCapybaraの作法で書くだけで必要な情報を過不足なく表示してくれます。以下のようにrequire "test/unit/capybara"とするだけでtest-unit-capybaraを使えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
gem "test-unit" require "test/unit/capybara" # require "test/unit" # require "capybara/dsl" # ... class TestMyRackApplication < Test::Unit::TestCase include Capybara::DSL def setup Capybara.app = MyRackApplication.new end # ... def test_header visit("/") within(".header") do assert_equal("No navigation.", find("p").text) end end end |
実行すると以下のようになります。
% ruby test-capybara.rb
Loaded suite test-capybara
Started
F
===============================================================================
Failure:
test_header(TestMyRackApplication)
[/var/lib/gems/1.9.1/gems/test-unit-capybara-1.0.1/lib/test/unit/capybara.rb:77:in `raise_find_error_for_test_unit'
...
test-capybara.rb:40:in `test_header']:
<"p">(:css) expected to find a element in
<<div class="header">
<ul>
<li><a href="/">Top</a></li>
</ul>
</div>>
===============================================================================
...
探索範囲であるHTML断片<div class="header">...</div>とCSSセレクタ"p"が表示されています。これがわかれば素早く原因を見つけられますね。
最近のCapybaraはfindメソッドでノードを見つけられなかったときの挙動をカスタマイズできます*2。test-unit-capybaraはその機能を使って必要な情報を収集して表示しています。同様のことは他のテスティングフレームワークでも実現できるでしょう。
現状のテストの書き方・ツールではHTMLに対するテストが失敗したときの原因を素早く見つけることが困難であることを示しました。また、解決方法としてwithinを使って「注目しているノードを明示」し、テストツールとしてtest-unit-capybaraを使う方法を紹介しました。この方法は他のテスティングフレームワークでも実現できる一般的な方法です。
インターフェイスの改良や文言の変更などでHTMLのテストが失敗することはよくあることです。そんなときもすぐにどこが変わったかに気付けるようなテストだと変更を嫌がらずによいものを作ることに専念できますよね
あわせて読みたい: デバッグしやすいassert_equalの書き方
ここで取り上げた問題は「問題があるだろう範囲が広すぎて問題を発見することが困難になる」ことが原因です。同様の問題はtest-unitのassert_match、RSpecのshould matchにもあります。
以下はUser-Agentが任意のバージョンのbingbotであることをテストしています。このテストは失敗するのですが、どうして失敗するかすぐにわかるでしょうか?
1 2 3 4 5 6 7 |
def assert_bingbot(user_agent) assert_match(/;\s+bingbot\/[\d]+;/, user_agent) end def test_bot assert_bingbot("Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)") end |
正規表現は少し間違えて書いてしまうとどこが間違えているのか見つけることが大変です。これを防ぐために、assert_matchではなく、以下のようにマッチ対象を正規化してからassert_equalで比較することをオススメします。
1 2 3 4 5 6 7 8 |
def assert_bingbot(user_agent) assert_equal("Mozilla/5.0 (compatible; bingbot/XXX; +http://www.bing.com/bingbot.htm)", user_agent.gsub(/bingbot\/[\d]+/, "bingbot/XXX")) end def test_bot assert_bingbot("Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)") end |
assert_equalだと以下のようにdiffがでるので何が悪かったのかがすぐにわかります。
=============================================================================== Failure: test_bot(TestMyRackApplication) [test-capybara.rb:50:in `assert_bingbot' test-capybara.rb:55:in `test_bot']: <"Mozilla/5.0 (compatible; bingbot/XXX; +http://www.bing.com/bingbot.htm)"> expected but was <"Mozilla/5.0 (compatible; bingbot/XXX.0; +http://www.bing.com/bingbot.htm)"> diff: ? Mozilla/5.0 (compatible; bingbot/XXX.0; +http://www.bing.com/bingbot.htm) ===============================================================================
バージョン番号を検出しようとしていた[\d]+が「.」を考慮していないことが原因ですね。
デバッグしやすいassert_equalの書き方とデバッグしにくいassert_equalの書き方があるのは知っていますか?*1
デバッグしやすいassert_equalの書き方を2パターン紹介します。
まず、1つ目のよくみるデバッグしにくいassert_equalの書き方です。
1 2 3 4 5 |
def test_parse assert_equal(29, parse_integer("29")) # (1) assert_equal(29, parse_integer("+29")) # (2) assert_equal(-29, parse_integer("-29")) # (3) end |
これがデバッグしにくいのは、(1)が失敗したら(2)、(3)が実行されないからです。すべてのassert_equalが実行されて、どのassert_equalが失敗したかを確認することでバグの傾向を見つけやすくなります。
例えば…(1)だけが失敗するなら符号が無い場合の処理が怪しいでしょう。(3)だけが失敗するなら、マイナス記号に対応していないのかもしれません。全部失敗するなら根本的におかしいのでしょう。
このように、どのassert_equalが失敗したかがデバッグのヒントになります。よって、↑のような書き方はデバッグがしにくいassert_equalの書き方といえます。
デバッグしやすく書くと、例えば、こうなります。
1 2 3 4 5 6 7 8 9 10 11 |
def test_parse_no_sign assert_equal(29, parse_integer("29")) end def test_parse_plus_sign assert_equal(29, parse_integer("+29")) end def test_parse_mius_sign assert_equal(-29, parse_integer("-29")) end |
続いて、1つ目のよくみるデバッグしにくいassert_equalの書き方です。
1 2 3 4 5 |
def test_parse bob = MyJSONParser.parse('{"name": "bob", "age": 29}') assert_equal("bob", bob["name"]) assert_equal(29, bob["age"]) end |
1つ目の書き方のように複数のassert_equalが書いてあります。1つ目の書き方と同じように直すならこうなります。
1 2 3 4 5 6 7 8 9 |
def test_parse_string_value bob = MyJSONParser.parse('{"name": "bob", "age": 29}') assert_equal("bob", bob["name"]) end def test_parse_numeric_value bob = MyJSONParser.parse('{"name": "bob", "age": 29}') assert_equal(29, bob["age"]) end |
でも、本当にこれでよいでしょうか。この書き方では値の型だけを注目しています。値の型に注目するならば以下のように書いた方がよいでしょう。こうした方が余計なものがなくなり、より注目できています。
1 2 3 4 5 6 7 8 9 |
def test_parse_string_value bob = MyJSONParser.parse('{"name": "bob"}') assert_equal("bob", bob["name"]) end def test_parse_numeric_value anonymous = MyJSONParser.parse('{"age": 29}') assert_equal(29, anonymous["age"]) end |
もし、最初のコードが「複数のキーと値をパースできること」を確認したい場合はassert_equalを複数のテストに分割するのではなく、複数のassert_equalを1つのassert_equalにまとめるべきです。
1 2 3 4 5 |
def test_parse bob = MyJSONParser.parse('{"name": "bob", "age": 29}') assert_equal({"name" => "bob", "age" => 29}, bob) end |
1つのassert_equalにまとめると、失敗する場合でも、一部の比較だけが実行されるのではなく、すべての比較が実行されます。そのため、"name"はパースに成功しているけど"age"は失敗している、というように、失敗の傾向がわかります。失敗の傾向がわかるとデバッグしやすくなるというのは前述の通りです。
ところで、Hashのassert_equalの結果は見づらいですよね。これは、RSpecでも一緒です。
1 |
{"age" => 30, "name" => "bob"}.should == {"name" => "bob", "age" => 29}
|
この結果は以下のようになります。
expected: {"name"=>"bob", "age"=>29}
got: {"age"=>30, "name"=>"bob"} (using ==)
Diff:
@@ -1,2 +1,2 @@
-{"name"=>"bob", "age"=>29}
+{"age"=>30, "name"=>"bob"}
一見しただけではどこが違うのかわかりません。
しかし、最新のtest-unit 2は一味違います。
1 2 |
assert_equal({"name" => "bob", "age" => 29},
{"age" => 30, "name" => "bob"})
|
この結果は以下のようになります。
<{"age"=>29, "name"=>"bob"}> expected but was
<{"age"=>30, "name"=>"bob"}>
diff:
? {"age"=>29, "name"=>"bob"}
? 30
違いがわかりやすいですね。
デバッグしやすいassert_equalの書き方を2パターン紹介しました。今度から、assert_equalを書くときは、単に数を増やしたりするのではなく、どうすれば有用なassert_equalになるかを考えてみてください。assert_equalが失敗したときにいかにデバッグしやすいかがヒントになるはずです。
*1 assert_equalではなくて、should ==と読み替えてもよい。
今日は年に一度の肉の日だからか、いろいろなソフトウェアがリリースされていますね。
このうち、Cutter 1.1.6について紹介します。
CutterはC/C++用の単体テストフレームワークです。スクリプト言語の単体テストフレームワークのように簡単にテストを書けること、テストが失敗した時にデバッグしやすいことを重視しています。どちらも「テストが苦痛」にならないために大事なことです。
Cutter 1.1.6ではテストをより頻繁に実行しやすくするための機能を強化しました。それがTDDきのたんのサポートです。
TDDきのたんとはMayu & Co.さんが描いた色違いのかさのきのこたちです。Cutter 1.1.6を使うとテストを実行するたびに愛らしいTDDきのたんに会うことができます。頻繁にテストを実行したくなりますね。

TDD(テスト駆動開発)ではテストの結果を色で表すことが多いです。グリーンならパスで、レッドなら失敗です。グリーンになったら気持ちいいよね、レッドは落ち着かないね、早くグリーンにしたいね、そんな風に色を使っています。色を使うことで、テストをすべてパスしている状態をキープしたくなる力を推進しています。
RSpecやCutterなど最近も開発が続いている単体テストフレームワークは結果を表示する時にグリーンやレッドなどの色を使っています。でも、それだけで十分でしょうか。
テストを頻繁に実行するようになると、開発の中で自然にテストを実行するようになります。それはもうlsコマンドを実行するように*1テストを実行します。そうなると、テストの実行中に違う作業をして、合間にテスト結果を見るようになります。そのため、テストの失敗に気付くのが遅れる場合があります。
それを解決するためには、これまで通り端末に色付きの結果を表示させているだけでは不十分ではないでしょうか。
デスクトップ環境では多くのアプリケーションが動いていて、常にすべてのアプリケーションを見ていることはできません。そこで必要なときにアプリケーションから「通知」してきます*2。Linuxや*BSDではDesktop Notification、Mac OS XではGrowlなどを利用します。
テスト結果も「通知」した方がよいのではないでしょうか。
そういうわけで、Cutter 1.1.6では「notify-send」コマンドを使ってTDDきのたん付きの通知を行うようになりました。test-unit 2では2ヶ月くらい前からTDDきのたん付きの通知をサポートしています。test-unit 2で利用する場合はtest-unit-notifyを使います。こちらもTDDきのたんを使っています。
まわりくどかったですね。
まわりくどくCutter 1.1.6の新機能である「テスト結果通知」を紹介しました。単体テストフレームワークに限らず、うるさくない程度に「通知」に対応するとより使いやすくなるかもしれません。
「通知」を使う場合は画像も入れることをおすすめします。画像を入れるとよりピンときます。例えば、Mayu & Co.さんのTDDきのたんは愛らしくて何度も会いたくなりますよね。それでは、テストを実行して会いにいきましょう。
先日、Ruby用のxUnit系テスティングフレームワークtest-unit 2.0.7とC・C++用のxUnit系テスティングフレームワークCutter 1.1.1がリリースされました。
どちらも、テストの書きやすさ(テストをキレイに書けるとテストを保守しやすい)だけではなく、テストが失敗した時に「できるだけ素早く問題の原因にたどり着ける」ことも重視しています。
Rails/Rack界隈ではCucumberやWebrat、capybaraなどを使って、「"ログイン"ボタンをクリックする」とか「click_link("ログイン")」などと、直感的にテストを書けるようになっています。では、テストが失敗したときの結果はどのように表示されるでしょうか。HTML全体やテキスト全体が表示されて、「"ログイン"というボタン(リンク)はなかったよ」と言われたらどうでしょう。あなたのコードはどこが悪かったのでしょう。
そういうときに、失敗結果を見て、すぐに「あぁ、ここが悪いかも!」と作業を進めていけるようなテスティングフレームワークにしたいものです。開発はデバッグの連続なのですから、よりスムーズにデバッグ作業を進める手助けとなるツールを使って開発したいですよね。
test-unitやCutterはWebアプリケーション用に特別なサポート機能は提供していないので上記のようなことをうまい具合に解決できるわけではないのですが、ライブラリのテストでは上記のようなことをうまい具合に解決する機能を提供しています。
一応、リリースで変わったことを少し書いておきます。
機能面でもよくなっているのですが、インストールまわりだけにしておきます。
Cutterプロジェクトでは、これまでDebian, Ubuntu用のapt-lineとMacPortsのPortfileを提供していましたが、今回のリリースから、FedoraのYumリポジトリも提供するようにしました。以下でインストールできます。yumの管理下に入るのでアップデートも簡単ですね。
% sudo rpm -Uvh http://cutter.sourceforge.net/fedora/cutter-repository-1.0.0-0.noarch.rpm % sudo yum install cutter
test-unitの方は変わらずgemをサポートしているので、こちらもインストール・アップデートが簡単ですね。
書きやすさだけではなくデバッグしやすさも重視したテスティングフレームワークに興味のある方は使ってみてはいかがでしょうか。
2010年1月29日付で、テスティングフレームワークUxUのバージョン0.7.6をリリースしました。
utils.wait()について今回のアップデートでの目玉となる新機能は、非同期な機能のテストをより簡単に記述できるようにするヘルパーメソッドであるutils.wait()です。これは、以下のように利用します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function testSendRequest() { myFeature.sendRequest(); utils.wait(1000); // 1000ミリ秒=1秒待つ assert.equals('OK', myFeature.response); } function testLoad() { var loaded = { value : false }; content.addEventListener('load', function() { content.removeEventListener('load', arguments.callee, false); loaded.valeu = true; }, false); myFeature.load(); utils.wait(loaded); // valueがtrueになるまで待つ assert.equals('OK', content.document.body.textContent); } |
また、utils.wait()は関数の中でも利用できます。
1 2 3 4 5 6 7 8 9 10 |
function testSendRequest() { function assertSend(aExpected, aURI) { myFeature.sendRequest(aURI); utils.wait(1000); assert.equals(aExpected, myFeature.response); } assertSend('OK', '...'); assertSend('NG', '...'); assertSend('?', '...'); } |
utils.wait()が受け取れる値は、これまでの処理待ち機能で yieldに渡されていた値と同じです。詳しくは処理待ち機能の利用方法をご覧下さい。大抵の場合、yield Do(...);と書かれていた箇所は、utils.wait(...);へ書き換えることができます。
ただし、このヘルパーメソッドはFirefox 3以降やThunderbird 3以降など、Gecko 1.9系の環境でしか利用できません。Thunderbird 2などのGecko 1.8系の環境ではエラーとなりますのでご注意下さい。(それらの環境でもテストの中で処理待ちを行いたい場合は、従来通りyieldを使用して下さい。)
これまでUxUでは、「機能を実行した後、N秒間待ってから、機能が期待通りに働いたかどうかを検証する」「初期化処理で、ページの読み込みの完了を待ってから次に進む」といった処理待ちを実現する際は、JavaScript 1.7以降で導入されたyieldを使う仕様となっていました。
yieldを含む関数はジェネレータとなり、関数の戻り値をイテレータとして利用できるようになります。この時ジェネレータの内側から見ると、「yieldが出現する度に処理が一時停止する」という風に考えることができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function gen() { alert('step1'); yield 'step1 done'; alert('step2'); yield 'step2 done'; alert('step3'); } var iterator = gen(); // この時点ではまだ関数の内容は評価されない var state; state = iterator.next(); // 'step1' が表示される alert(state); // 'step1 done' が表示される state = iterator.next(); // 'step2' が表示される alert(state); // 'step2 done' が表示される try { state = iterator.next(); // alert('step3'); が実行される } catch(e if e instanceof StopIteration) { // 次のyieldが見つからないので、StopIteration例外が投げられる } |
UxUに従来からある処理待ち機能は、この考え方を推し進めて作られています。テスト関数の中にyieldがある場合(つまり、関数の戻り値がイテレータとなる場合)は、フレームワーク側で自動的にイテレーションを行い、yieldに渡された値をその都度受け取って、次にイテレーションを行うまでの待ち条件として利用しています。例えば、数値が渡された場合はその値の分の時間だけ待った後で次にイテレーションを行う、といった具合です。
このやり方の欠点は、yieldを含む関数から任意の戻り値を返すことができないという点です。
1 2 3 4 5 6 7 8 9 10 11 |
function gen() { alert('step1'); yield 'step1 done'; alert('step2'); return 'complete'; } try { var iterator = gen(); } catch(e) { alert(e); // TypeError: generator function gen returns a value } |
returnを書くと、関数の実行時にエラーになってしまいます。どうしても何らかの値を取り出したい場合は、値を取り出すためのスロットとなるオブジェクトを引数として渡すなどの工夫が必要になります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function gen(aResult) { alert('step1'); yield 'step1 done'; alert('step2'); aResult.value = 'complete'; } var result = {}; var iterator = gen(result); var state; state = iterator.next(); // 'step1' が表示される alert(state); // 'step1 done' が表示される try { state = iterator.next(); // 'step2' が表示される } catch(e if e instanceof StopIteration) { alert(result.value); // 'complete' が表示される } |
また、ジェネレータは実行してもその段階では関数の内容が評価されないという点にも注意が必要です。例えば以下のようなテストは、期待通りには動いてくれません。
1 2 3 4 5 6 7 8 9 10 |
function testSendRequest() { function assertSend(aExpected, aURI) { myFeature.sendRequest(aURI); yield 1000; assert.equals(aExpected, myFeature.response); } assertSend('OK', '...'); assertSend('NG', '...'); assertSend('?', '...'); } |
この例では、assertSend()を実行したことで戻り値としてイテレータが返されているものの、そのイテレータに対するイテレーションが一切行われていないため、リクエストも行われなければアサーションも行われないということになってしまっています。これは以下のように、返されたイテレータをそのままフレームワークに引き渡して、フレームワーク側でイテレーションを行わせる必要があります。
1 2 3 4 5 6 7 8 9 10 |
function testSendRequest() { function assertSend(aExpected, aURI) { myFeature.sendRequest(aURI); yield 1000; assert.equals(aExpected, myFeature.response); } yield assertSend('OK', '...'); yield assertSend('NG', '...'); yield assertSend('?', '...'); } |
また、このままではジェネレータの中で発生した例外のスタックトレースを辿れないという問題もあります。スタックを繋げるためには、ヘルパーメソッドのDo()を使ってyield Do( assertSend('OK', '...') )のように書かなければなりません。
utils.wait()を使う場合、これらのことを考慮する必要はありません。冒頭のサンプルコードのように、素直に書けば素直に動作してくれます。
utils.wait()がどのように実装されているかについても解説しておきます。
このメソッドの内部では、Gecko 1.9から実装されたスレッド関連の機能を利用しています。
1 2 3 4 |
window.setTimeout(function() { alert('before'); }, 0); alert('after'); |
JavaScriptは基本的にシングルスレッドで動作するため、このようにタイマーを設定すると、その処理はキューに溜められた状態となります。その上で、メインの処理が最後まで終わった後でやっとキューの内容が処理され始めるため、この例であれば「after」「before」の順でメッセージが表示されることになります。
1 2 3 4 5 6 7 8 9 10 11 12 |
var finished = false; window.setTimeout(function() { alert('before'); finished = true; }, 0); var thread = Cc['@mozilla.org/thread-manager;1'] .getService() .mainThread; while (!finished) { thread.processNextEvent(true); } alert('after'); |
Gecko 1.9以降のスレッド機能を使うと、メインの処理を一旦中断して先にキューに溜められた処理の方を実行し、その後改めてメインの処理に戻るということができます。実際に、こちらの例では「before」「after」の順でメッセージが表示されます。UxU 0.7.6ではこれを応用して、任意の条件が満たされるまでthread.processNextEvent(true);でメインの処理を停止し続けることによって、処理待ちを実現しています。
なお、HTML5にもWeb Workersというスレッド関係の機能がありますが、こちらは別スレッドでスクリプトを動作させる機能しか持っていないため、上記のようなことは残念ながらできません。
UxU 0.7.6からは、utils.wait()を使ってより簡単に処理待ちを行えるようになりました。Firefox 3以降やThunderbird 3以降専用にアドオンを開発する際には、是非利用してみて下さい。
先日開催されたFSIJ月例会でテストとテスティングフレームワークについて話しました。声をかけてくれた上野さん、ありがとうございます。
これまでの経験から感じていることをまとめたものになっています。どうにかしたいけど、まだよいアイディアがなくて悩んでいる、といったものも含まれています。例えば、違いをわかりやすく示すことはいろいろ工夫できるけど、なかったことをわかりやすく示すことは難しいといったことです。
最後まで話しつづけるのではなく、随時ディスカッションをはさむ形で進みました。参加者のみなさんからもたくさんの意見がでてとても有意義でした。
GUIやDTP、ネットワーク・ハードウェア周辺などテストを自動化することが難しい領域のテストはみなさん悩んでいるところでした。今回の会で決定的な解決策がでたわけではないのですが、悩んでいる部分、他の人が持っているよいアイディアを共有できたことは成果と言えます。明日から劇的に変わるというものではありませんが、確実によくなっていけるというものです。
話の中に何度かでていますが、「完璧を求めない」ことが重要です。完璧を求めてテストがストレスになってしまうことよりも、自分たちのペースでよい状態を継続し、よいソフトウェアの開発を続けられるようにすることの方が重要です。このあたりの力の入れ具合、テストが目的ではなくよいソフトウェアを開発することが目的であることを忘れないことで「壁」を越えることができるでしょう。
今月末、FSIJ月例会でテスティングフレームワークについて話します。月例会の概要は以下の通りです。都合があう方はぜひお越しください。
(tokyo-emacs #x02)で話したときにお会いした上野さんに声をかけてもらいました。声をかけていただきありがとうございます。
事前に打ち合わせなどはないようなので、自由な形式でやる予定です。
2時間ありますが、ずっと話し続けるわけではありません*1。こちらから考えていることや技術的なことなどを紹介し、それを話題としてディスカッションを挟みながら進めていきます。テスティングフレームワークやテストについて思っていることがある方はぜひ参加してその考えを聞かせてください。
以下の3部構成にする予定です。ここでいう「テスト」とは実装をする開発者自身が書く「自動化されたテスト」のことです。
今では、「テストは書いた方がよい」というのはみんなわかっていることです。しかし、「テストのないコードは怖くてさわれない」という感覚はそうではありません。それがテストが当たり前の人とそうでない人の差です*2。では、どうすればテストが当たり前になるのか。まずは、それについて話し、ディスカッションします。
次に、テストが当たり前の人が価値のあるテストを継続して書くために、テスティングフレームワークが提供するべきものはなにかを考えます。
最後に、現在のテスティングフレームワークはどうなっているかを扱います。先日、C++用xUnitでのテストの書き方を紹介しましたが、他の言語用のテスティングフレームワークでの書き方や、もしかしたら、どのような実装で実現しているかまで紹介できるかもしれません。
随時、参加者からの意見を受け入れますが、それぞれの話題毎および最後にもまとまったディスカッションの時間をとる予定です。
今月のFSIJ月例会でテスティングフレームワークについて話すことになったので、それの告知をしました。参加費は無料なので興味のある方はお気軽に参加してみてください。
注: 長いです。
スクリプト言語でのxUnit実装を使ったことがある方なら、テストを定義するだけでテストが実行されることが当たり前ではないでしょうか。c2.comのWikiによると、これはTest Collectorというそうです。定義したテストを自動的に集めてくる機能のことです。
一般的にTest Collectorの機能は言語が提供するリフレクション機能やメタプログラミング機能を使って実現されます。
例えば、Rubyのtest-unit 2.xでは、リフレクションを使う方法とメタプログラミングを使う方法の両方をサポートしています。リフレクションを使う方法ではObjectSpace.each_object(Class)ですべてのクラスを取得し、その中のTest::Unit::TestCaseのサブクラスを集めます。メタプログラミングを使う方法ではTest::Unit::TestCase.inheritedを定義して、サブクラスが定義された時のフックでそのサブクラスを集めます。
Pythonでもモジュールオブジェクトからモジュール内のオブジェクトにアクセスすることができるので、同様の方法でテストを集めることができます。JavaScriptでも、オブジェクトに定義されているプロパティを列挙することができるので、同様の方法でテストを集めることができます。(JavaScriptとXULで実装されているUxUも同様のことをしています。)
一方、CやC++ではリフレクション機能やクラスをファーストクラスオブジェクトとして扱う機能がないため、自動でテストを集めるためには一工夫必要になります。一昔前のxUnitでは、定義したテストを手動で登録していました。
それでは、C/C++での一工夫の方法として以下の4つをxUnit実装と一緒に紹介します。C++用のxUnitを選択する時の参考にしてください。
最初はCppUnitのケースです。CppUnitではテストを定義するだけでは、自動でテストを集めてはくれません。しかし、便利マクロを用意して、手動でテストを登録する面倒さを減らしています。
以下はCppUnit Cookbookにあるソースコードをベースにしています。public内のテスト定義とは別にCPPUNIT_TEST_SUITEからCPPUNIT_TEST_SUITE_ENDの間でテストを登録しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
// complex-number-test.cpp #include <cppunit/extensions/HelperMacros.h> class ComplexNumberTest : public CppUnit::TestFixture { CPPUNIT_TEST_SUITE( ComplexNumberTest ); CPPUNIT_TEST( testEquality ); CPPUNIT_TEST( testAddition ); CPPUNIT_TEST_SUITE_END(); private: Complex *m_10_1, *m_1_1, *m_11_2; public: void setUp() { m_10_1 = new Complex( 10, 1 ); m_1_1 = new Complex( 1, 1 ); m_11_2 = new Complex( 11, 2 ); } void tearDown() { delete m_10_1; delete m_1_1; delete m_11_2; } void testEquality() { CPPUNIT_ASSERT( *m_10_1 == *m_10_1 ); CPPUNIT_ASSERT( !(*m_10_1 == *m_11_2) ); } void testAddition() { CPPUNIT_ASSERT( *m_10_1 + *m_1_1 == *m_11_2 ); } }; CPPUNIT_TEST_SUITE_REGISTRATION( ComplexNumberTest ); |
実行する場合は以下のようなmain関数を定義する必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// main.cpp #include <cppunit/extensions/TestFactoryRegistry.h> #include <cppunit/ui/text/TestRunner.h> int main( int argc, char **argv) { CppUnit::TextUi::TestRunner runner; CppUnit::TestFactoryRegistry ®istry = CppUnit::TestFactoryRegistry::getRegistry(); runner.addTest( registry.makeTest() ); bool wasSuccessful = runner.run( "", false ); return wasSuccessful; } |
上記の二つのファイル(とComplexNumberの実装)を使ってビルドします。
% g++ -o complex-number-test complex-number-test.cpp main.cpp \
-lcomplex-number -lcppunit
テストを実行するにはビルドしたバイナリを実行します。
% ./complex-number-test .. OK (2 tests)
マクロを使ってテスト登録を簡単にしている(自動化まではしていない)例としてCppUnitを紹介しました。
CPPUNIT_TEST_SUITEなどの便利マクロを使わない場合は定義したテスト名(上の例ではtestEqualityとtestAddition)以外のことも気にしなければいけなくなります。便利マクロを使うと、テスト名だけわかっていればよいので、それに比べるとだいぶテスト作成が楽になっています。
しかし、テストを定義だけして登録し忘れたということを回避することができません。また、テストケース定義とは別にmain関数も定義する必要があり、テスト以外のことにも気を配る必要があることにも注意が必要です。
C++で書かれたテストコードを直接C++コンパイラでコンパイルするのではなく、テストコードに必要なコードを追加したC++ソースコードを生成して、それをコンパイルする方法です。C++コンパイラでのビルドする前に一度変換処理を行えるので、テストコードへの記述が減ることが利点ですが、変換処理を行うのがやや面倒です。自動化されれば気にならなくなるでしょう。
まずはCxxTestのケースです。CxxTestではソースコードを直接ビルドするのではなく、C++のソースコードからテスト登録処理などを付加したC++ソースコードを生成し、それをビルドします。
以下はCxxTest User Guideにあるソースコードをベースにしています。テストの定義だけでテスト登録処理は含まれていません。
1 2 3 4 5 6 7 8 9 10 11 12 |
// MyTestSuite.h #include <cxxtest/TestSuite.h> class MyTestSuite : public CxxTest::TestSuite { public: void testAddition( void ) { TS_ASSERT( 1 + 1 > 1 ); TS_ASSERT_EQUALS( 1 + 1, 2 ); } }; |
以下のようにビルドします。
% cxxtestgen --error-printer -o cxxunit-tests.cpp MyTestSuite.h % g++ -o cxxunit-tests cxxunit-tests.cpp
cxxtestgenでMyTestSuite.hにあるテスト定義にテスト登録処理などを加えてcxxunit-tests.cppを生成します。余談ですが、cxxtestgenはPythonスクリプトです。また、CxxUnitはライブラリを提供せず、ヘッダーファイルのみを提供します。
バイナリを実行するとテストが走ります。
% ./cxxunit-tests Running 1 test.OK!
テスト登録が完全に自動化されているのでCppUnitよりも新規テストの追加が容易です。テストの登録しわすれもありません。ただ、cxxtestgenとC++コンパイラで2回コンパイルする必要があることが少し手間だと言えます。
続いてQtが提供するQTestLibのケースです。QTestではQtが提供するスロットの仕組みを使って、定義されているテストを集めます。スロットがどのように定義されているかをプログラム中から扱うために、QtはC++のソースコードをプリコンパイルしますが、QTestでも同様にプリコンパイルする必要があります*1。
以下はQTestLibのチュートリアルにあるソースコードををベースにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// test-qstring.cpp #include <QtTest/QTest> class TestQString: public QObject { Q_OBJECT private slots: void toUpper() { QString str = "Hello"; QCOMPARE(str.toUpper(), QString("HELLO")); } }; QTEST_MAIN(TestQString) #include "test-qstring.moc" |
QTestLibでもmain関数を定義しなければいけませんが、QTEST_MAINという便利マクロが用意されています。
以下のようにビルドします。
% mkdir test-qstring % mv test-qstring.cpp test-qstring % cd test-qstring % qmake -project "QT += testlib" % qmake % make
バイナリを実行するとテストが走ります。
% ./test-qstring ********* Start testing of TestQString ********* Config: Using QTest library 4.5.3, Qt 4.5.3 PASS : TestQString::initTestCase() PASS : TestQString::toUpper() PASS : TestQString::cleanupTestCase() Totals: 3 passed, 0 failed, 0 skipped ********* Finished testing of TestQString *********
このようにQTestLibではテスト登録のために必要なコードはQTEST_MAINでクラスを指定している部分だけです。個々のテストは指定する必要がありません。
メタオブジェクト情報を生成すること、また、それを読み込んでいる#include "test-qstring.moc"のところはQTestLib独自のことではなく、Qt全般のことです。そのため、Qtを利用している場合は追加で必要な作業とはいえないでしょう。つまり、QTestLibのテストを集める方法は完全には自動化されていませんが、Qt開発者にはそれほど負担もかからず自然に書けるようになっている使いやすいAPIといえます。一方、Qt開発者でない場合は、面倒に見えるでしょう。
CppUnitでもマクロでテストを登録していますが、それをもう一歩進めたのがこの方法です。CppUnitでは、テスト定義は通常の関数定義でしたが、この方法ではそこでマクロを使い、テスト定義と同時にテストを登録します。
まずは、Google Testです。Google Testではテスト定義時にTESTマクロを使います。以下はGoogleTestSamplesにあるソースコードをベースにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// test-factorial.cpp #include <gtest/gtest.h> int Factorial(int n) { int result = 1; for (int i = 1; i <= n; i++) { result *= i; } return result; } TEST(FactorialTest, Negative) { EXPECT_EQ(1, Factorial(-5)); EXPECT_EQ(1, Factorial(-1)); EXPECT_TRUE(Factorial(-10) > 0); } int main(int argc, char **argv) { testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); } |
プリコンパイル方式でもテストの登録忘れはありませんが、この方法でも登録を忘れることがありません。テスト定義の方法が通常の関数定義とは異なる書式になることに慣れることができるのであれば、この方式で負担が少なくテストを書けるようになるでしょう。
以下のようにビルドします。
% g++ -o test-factorial test-factorial.cpp -lgtest
バイナリを実行するとテストが走ります。
% ./test-factorial [==========] Running 1 test from 1 test case. [----------] Global test environment set-up. [----------] 1 test from FactorialTest [ RUN ] FactorialTest.1 [ OK ] FactorialTest.1 (0 ms) [----------] 1 test from FactorialTest (0 ms total) [----------] Global test environment tear-down [==========] 1 test from 1 test case ran. (0 ms total) [ PASSED ] 1 test.
上記の例では触れていませんが、フィクスチャ(setup/teardown)を使う場合は、テストクラス名を揃える必要があるなど、同じグループのテストを作る場合は重複する部分がでてしまいます。例えば、QTestLibのようにクラス内にテストを定義する方法では以下のようになります。
1 2 3 4 5 6 7 8 9 |
class MyTest { void setup() {...} void teardown() {...} void test1() {...} void test2() {...} void test3() {...} } |
一方、Google Testの場合は、スコープが使えず、以下のようにクラス名を複数回書く必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class FooTest : public testing::Test { protected: virtual void SetUp() { b_.AddElement(3); } Foo a_; Foo b_; }; TEST_F(FooTest, InitializesCorrectly) { EXPECT_TRUE(a_.StatusIsOK()); } TEST_F(FooTest, ReturnsElementCountCorrectly) { EXPECT_EQ(0, a_.size()); EXPECT_EQ(1, b_.size()); } |
マクロを使っている場合は、間違ったテストクラス名を指定するなどコンパイルエラーになったときに意味の分からないエラーメッセージを目にすることがあるというのも注意しなければいけないポイントです。エラーメッセージを使えないと問題を発見することが難しくなります。
次に、Boost Test Libraryです。やり方はGoogle Testとだいたい同じで、Boost Test LibraryではBOOST_AUTO_TEST_CASEを使います。
1 2 3 4 5 6 7 8 9 10 11 |
// test-add.cpp #define BOOST_TEST_DYN_LINK #define BOOST_TEST_MODULE AddTest #include <boost/test/unit_test.hpp> int add( int i, int j ) { return i+j; } BOOST_AUTO_TEST_CASE( add_test ) { BOOST_CHECK_EQUAL( add( 2,3 ), 5); } |
最初にBOOST_TEST_DYN_LINKとBOOST_TEST_MODULEを定義しておくと、Boost Test Libraryではmain関数を定義する必要はありません。
以下のようにビルドします。
% g++ -o test-add test-add.cpp -lboost_unit_test_framework
バイナリを実行するとテストが走ります。
% ./test-add Running 1 test case... *** No errors detected
Google Testと同じくマクロが気にならない場合やBoostに慣れている場合はテストが書きやすいでしょう。
マクロを利用する方法は言語の構文を工夫してテストを集めています。プリコンパイル方式では言語の構文はそのままで、コンパイル前に付加情報を加えることでテストを集めています。
一方、最後の共有ライブラリから探す方法ではコンパイル後の共有ライブラリから情報を取得してテストを集めます。この方式では、テストを共有ライブラリとして作成し、テスト実行用のコマンドからその共有ライブラリを読み込み、テストを実行します。こうすることにより、テスト側にテスト登録処理を埋め込む必要がなくなります*2。共有ライブラリの中からテストを見つける処理はテスト実行コマンドが頑張るからです。
最初はWinUnitです。やり方は共有ライブラリからテストを集める方式なのですが、書き方はマクロを使う方式です。テストを定義するときはBEGIN_TESTとEND_TESTで囲みます。
1 2 3 4 5 6 7 |
#include "WinUnit.h" BEGIN_TEST(AddTest) { WIN_ASSERT_TRUE(3 == add(1, 2)); } END_TEST |
すでにGoogle TestやBoost Test Libraryで見たように、この使い方であれば、共有ライブラリにする必要はありません。マクロの中で一工夫することでテストの自動登録を実現できるからです。
WinUnitの利点はVisual C++で使いやすいことでしょう。マクロを使ったAPIが気にならないVisual C++開発者には有力な選択肢です。
最後はCutterです。CutterはC言語用の単体テストフレームワークとして開発されていましたが、先日リリースされた1.1.0で大きくC++対応を強化しています。
CutterではWinUnitとは違いマクロを利用しません。通常通り関数を定義するとテストとして認識されます。ただし、すべての関数がテストとして認識されるのではなく、test_からはじまる名前の関数だけをテストとして認識します。
1 2 3 4 5 6 7 8 9 |
#include <cppcutter.h> namespace calc { void test_add() { cppcut_assert_equal(5, add(2, 3)); } } |
マクロを利用してテストを自動登録する方式では、フィクスチャ定義時に名前を揃える必要がありましたが、Cutterでは以下のようにnamespace内にsetup()/teardown()を定義するだけです。namespaceでグループ化されたテスト全体でフィクスチャを共有します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <cppcutter.h> namespace calc { void setup() {...} void teardown() {...} void test_add() // calc::setup()/calc::teardown()が呼び出される { cppcut_assert_equal(5, add(2, 3)); } void test_sub() // calc::setup()/calc::teardown()が呼び出される { cppcut_assert_equal(5, add(8, 3)); } } |
この方式では通常のC++プログラムと同様にテストを書くことができるため、新しくテストを書くことの敷居が低くなります。しかし、tes_などタイプミスをしてしまった場合に、どうしてテストが実行されないのかに気づきにくいという問題点があります。
テストも通常のプログラムと同様に開発したい場合はマクロを使わないこの方式がオススメです。
C++用の各種xUnitでのテストの書き方を、方式毎に分類して紹介しました。どんなバックグラウンドを持っているかにより、選びやすいxUnitは変わるでしょう。Visual C++開発者であればWinUnitを選ぶことが多いでしょうし、Qt開発者であればQtTestLibを選ぶことが多いでしょう。しかし、バックグラウンドから選ぶだけではなく、テストの書きやすさも判断材料に加えてみてはいかがでしょうか。
継続して開発すればそれに伴ってテストも増えていきます。しかし、テストは面倒くさがって飛ばしてしまいがちです。新しくテストを書く敷居が下がれば、テストを面倒くさがることが少なくなり、安心して開発を続けていくための土台を固めることができます。新しくテストを書く敷居を下げることは開発を継続するのであれば割に合うということです。
今回は「新しいテストの書きやすさ」を軸に様々なxUnitのやり方を紹介しました。C++用xUnitを選択する時の参考にしてみてください。
念のため書きますが、オススメはCutterです。
2009年10月30日付で、テスティングフレームワークUxUのバージョン0.7.5をリリースしました。
UxUはこれまで「Firefoxアドオン開発用テスティングフレームワーク」と銘打っていましたが、Thunderbird用アドオンの開発にも利用されていることと、バージョン0.7.0以降からXULRunnerベースのアプリケーション一般に対してインストール可能なようになったことから、現在のプロジェクトページ上では「Firefox/Thunderbird用アドオン・XULRunnerアプリケーション開発用テスティングフレームワーク」と表記しています。
バージョン0.7.0以降で、UxUはデータ駆動テストの記述に対応しました。今回はUxUでのデータ駆動テストの記述方法の解説を通じて、データ駆動テストの利便性についてご紹介したいと思います。
このエントリ内の目次:
データ駆動テストとは、簡単に言えば、「テストのロジックとデータを分離した自動テスト」の事です。データ駆動テストの利点を理解していただくために、データ駆動テストではないテストの例もいくつか挙げながら、それぞれの利点と欠点を見ていきましょう。
以下は、XUL/Migemoという「ローマ字入力で普通の日本語の検索を行う」アドオンの中の、半角英数字によるローマ字入力をひらがなに変換するモジュールのテストの一部です。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function test_roman2kana() { assert.equals('あいうえお', transform.roman2kana('aiueo')); assert.equals('aiueo', transform.roman2kana('aiueo')); assert.equals('がぎぐげご', transform.roman2kana('gagigugego')); assert.equals('にほんご', transform.roman2kana('nihongo')); assert.equals('ぽーと', transform.roman2kana('po-to')); assert.equals('きゃっきゃ', transform.roman2kana('kyakkya')); assert.equals('うっうー', transform.roman2kana('uwwu-')); assert.equals('\\(\\)\\[\\]\\|', transform.roman2kana('\\(\\)\\[\\]\\|')); assert.equals('\\(\\[', transform.roman2kana('\\(\\[')); assert.equals('\\)\\]', transform.roman2kana('\\)\\]')); assert.equals('\\|', transform.roman2kana('\\|')); } |
このモジュールのroman2kana()メソッドは、半角英数字のローマ字入力をひらがなに変換し、それ以外の入力はそのまま返すという仕様になっています。この仕様通りに動作するかどうかを検証するため、ここでは11種類の引数を渡し、戻り値をassert.equals()で検証しています。検証しないといけないパターンが増えた時には、行をコピーして引数と期待値の部分を書き換えることになります。
パッと見て分かるかと思いますが、このテスト用コードには以下のような問題があります。
「テストのロジック」と「テストしなければいけないデータ」が一緒に記述されているため、メンテナンス性が低いコードになってしまっていると言えます。
このようなテストのメンテナンス性を高めるための方法の1つとしては、アサーションを行う部分を関数としてまとめておくというやり方が考えられます。例えば以下の要領です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function test_roman2kana() { function oneTest(aExpected, aInput) { assert.equals(aExpected, transform.roman2kana(aInput)); } oneTest('あいうえお', 'aiueo'); oneTest('aiueo', 'aiueo'); oneTest('がぎぐげご', 'gagigugego'); oneTest('にほんご', 'nihongo'); oneTest('ぽーと', 'po-to'); oneTest('きゃっきゃ', 'kyakkya'); oneTest('うっうー', 'uwwu-'); oneTest('\\(\\)\\[\\]\\|', '\\(\\)\\[\\]\\|'); oneTest('\\(\\[', '\\(\\['); oneTest('\\)\\]', '\\)\\]'); oneTest('\\|', '\\|'); } |
コードの冗長さが減りました。また、メソッド名や引数の取り方が変わった時も、変更が必要な箇所は1箇所だけになりました。
これでも悪くはないのですが、実際にテストを繰り返し走らせていると、以下のような問題が浮き彫りになってきます。
例えば以下のようなシナリオが考えられます。
「3の段階で行った修正で5や8のバグが発生した」という可能性もありますが、最初から2や5や8のバグがあったのであれば、まとめて直せていたかもしれません。これでは、バグを直しても直してもきりがないという、モグラ叩きのような感覚に陥ってしまいます。
メンテナンス性を高める別の手法として、アサーションに使う期待値とメソッドに渡す引数だけを配列で別途定義しておくというやり方も考えられます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function test_roman2kana() { var patterns = [ ['あいうえお', 'aiueo'], ['aiueo', 'aiueo'], ['がぎぐげご', 'gagigugego'], ['にほんご', 'nihongo'], ['ぽーと', 'po-to'], ['きゃっきゃ', 'kyakkya'], ['うっうー', 'uwwu-'], ['\\(\\)\\[\\]\\|', '\\(\\)\\[\\]\\|'], ['\\(\\[', '\\(\\['], ['\\)\\]', '\\)\\]'], ['\\|', '\\|'] ]; for each (var pattern in patterns) { assert.equals(pattern[0], transform.roman2kana(pattern[1])); } } |
これにもやはり問題があります。
1つ目の問題点については前述したとおりです。
2つ目の問題は、この例のように2次元配列を使った場合に現れます。上記の例では配列の0番目の要素が期待値、1番目の要素が入力となっていますが、これではどっちがどっちなのかを常に意識する必要があります。
3つ目は、ループに特有の問題です。UxUではテストにfailした時にスタックトレースが表示され、べた書きした場合や関数を使った書き方の場合であれば、スタックトレースを辿れば「どのパターンで失敗したのか」の情報に辿り着くことができます。しかし、ループを使っていると、スタックトレースの行き着く先はループの中になってしまうため、どのパターンに対して失敗したのかが一目では分からなくなってしまいます。
この対策として、UxUのアサーションでは最後の引数として任意のメッセージを渡せるので、以下のようにして「どのパターンで失敗したのか」を表示させることは可能です。
1 2 3 |
assert.equals(pattern.expected,
transform.roman2kana(pattern.input),
pattern.input+'に対するテスト');
|
しかし、実際にたくさんテストを書くようになってくると、これが地味に面倒です。これが4つ目の問題点です。
べた書きした場合や関数を使った書き方の場合であれば、このような配慮なしに淡々とテストを書いていても、テスト実行時にはスタックトレースを辿ればデバッグに必要な情報を得られます。それなのに、ループに対してはこのような配慮をしなければいけないわけです。この面倒さによって、テストを新しく書いたり過去に書いたテストをメンテナンスしたりする意欲がじわじわと削がれてしまう、というのが一番の問題点だと言えます。
UxU 0.7.0以降で導入されたデータ駆動テストの仕組みを使うと、上記のテストはこのように書くことができます。
1 2 3 4 5 |
test_roman2kana.parameters = utils.readParametersFromCSV('patterns.csv'); function test_roman2kana(aParameter) { assert.equals(aParameter.expected, transform.roman2kana(aParameter.input)); } |
テスト用のコードにはロジックだけを書き、データは外部ファイルで定義します(後述しますが、テストケース内にデータを埋め込むこともできます)。データを定義しているファイルの形式はCSVです。
| input | expected | |
| 半角英数 | aiueo | あいうえお |
| 全角英数 | aiueo | aiueo |
| 濁音のみ | gagigugego | がぎぐげご |
| 濁音混じり | nihongo | にほんご |
| 音引き | po-to | ぽーと |
| 拗音 | kyakkya | きゃっきゃ |
| 撥音 | uwwu- | うっうー |
| paren | \\(\\)\\[\\]\\| | \\(\\)\\[\\]\\| |
| parenOpen | \\(\\[ | \\(\\[ |
| parenClose | \\)\\] | \\)\\] |
| pipe | \\| | \\| |
この時、UxUは「test_roman2kana」という1つのテストではなく、「test_roman2kana (半角英数)」「test_roman2kana (全角英数)」……という名前の11個のテストを実行するようになります。
このように、データ駆動テストには多くのメリットがあります。単純な入出力のパターンを数多く検証しなければいけない場面で、データ駆動テストは威力を発揮します。
UxUでは、テスト関数のparametersプロパティに配列またはハッシュを代入すると、そのテストをデータ駆動テストとして実行するようになります。この時テスト関数には引数として、parametersプロパティの配列またはハッシュの要素が1つずつ渡されます。
以下は、配列を指定した場合の例です。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
test_someFunc.parameters = [
{ expected : '29', input : 'niku' },
{ expected : '2929', input : 'nikuniku' },
{ expected : '029', input : 'oniku' }
];
function test_someFunc(aParameter) {
/*
1回目: aParameter = { expected : '29', input : 'niku' }
2回目: aParameter = { expected : '2929', input : 'nikuniku' }
3回目: aParameter = { expected : '029', input : 'oniku' }
*/
...
}
|
ハッシュを指定した場合は、以下のようになります。ハッシュのキーはテスト関数には渡されず、結果を表示する時のテスト名として表示されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
test_someFunc.parameters = {
simgle: { expected : '29', input : 'niku' },
double: { expected : '2929', input : 'nikuniku' },
o: { expected : '029', input : 'oniku' }
};
function test_someFunc(aParameter) {
/*
1回目: aParameter = { expected : '29', input : 'niku' }
2回目: aParameter = { expected : '2929', input : 'nikuniku' }
3回目: aParameter = { expected : '029', input : 'oniku' }
*/
...
}
|
データ駆動テストのサポートに併せて、いくつかの新しいヘルパーメソッドが追加されています。これらを使うことで、データ駆動テストをより簡単に作成・メンテナンスすることができます。
前述の例で使用しているutils.readParametersFromCSV()は、CSVファイルの内容を読み込み、最初の行のカラム名をキーとしたハッシュとして返します。例えば前述の例のCSVは、以下のようなハッシュとして解釈されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// test_roman2kana.parameters = utils.readParametersFromCSV('patterns.csv'); // これは、以下のように書くのと同じ test_roman2kana.parameters = { '半角英数': { input: 'aiueo', expected: 'あいうえお' }, '全角英数': { input: 'aiueo', expected: 'aiueo' }, '濁音のみ': { input: 'gagigugego', expected: 'がぎぐげご' }, '濁音混じり': { input: 'nihongo', expected: 'にほんご' }, '音引き': { input: 'po-to', expected: 'ぽーと' }, '拗音': { input: 'kyakkya', expected: 'きゃっきゃ' }, '撥音': { input: 'uwwu-', expected: 'うっうー' }, paren: { input: '\\(\\)\\[\\]\\|', expected: '\\(\\)\\[\\]\\|' }, parenOpen: { input: '\\(\\[', expected: '\\(\\[' }, parenClose: { input: '\\)\\]', expected: '\\)\\]' }, pipe: { input: '\\|', expected: '\\|' } }; |
CSVファイルはRFC4180準拠の形式の読み込みに対応しています。カンマ区切りではなくタブ区切りのファイルを使用したい場合は、utils.readParametersFromTSV()を使用して下さい。どちらも、読み込みたいファイルのパス(相対パスも利用できます)を第1引数に、ファイルのエンコーディングを第2引数に指定します。エンコーディング指定を省略した場合はUTF-8として読み込みます。
以下のリンク先に、各メソッドの詳しい説明があります。
また、JSON形式で保存した外部ファイルを読み込むためのutils.readJSON()というメソッドもあります。
1 |
test_roman2kana.parameters = utils.readJSON('patterns.json');
|
こちらも、読み込みたいファイルのパス(相対パスも利用できます)を第1引数に、ファイルのエンコーディングを第2引数に指定します。エンコーディング指定を省略した場合はUTF-8として読み込みます。詳しい説明は以下のリンク先をご覧下さい。
データ駆動テストの仕組みを利用すると、テストのロジックとデータを分離できるため、メンテナンス性が高まることが期待できます。様々なパターンの入力を受け付ける機能を開発する時は、データ駆動テストをぜひ一度試してみて下さい。
クリアコードのインターンシップに参加したはやみずさんが主催する(tokyo-emacs #x02)で使った資料を公開しました。
現在のところ、まだ、上記の一覧ページには発表者全員の資料が揃っていませんが、おいおい揃っていくでしょう。参加できなかった方は公開された資料で内容を感じてみてください。ただ、当日はデモまじりの発表も多かったため、資料だけでは伝わらない部分もあるでしょう。
(tokyo-emacs #x02) レポまとめから辿れるレポートを読めば、資料からだけではわからないことも垣間見れるでしょう。資料だけ、レポートだけで物足りない場合は、次回は参加してみてはいかがでしょうか。
発表ではテスト起動を支援するrun-test.elのデモをするという名目でRabbitのデモをしました。
会場にRabbitユーザはほとんどいませんでしたが、Rabbitユーザにとってはよだれものだったはずです。
簡単なデモであればスライドに穴を開けてデモをしますが、今回のようにEmacsでコードを書きながらのデモではスペースが小さくなってしまいます。また、穴を開けるとスライドを参照することができないという問題もあります。
今回のデモではウィンドウのサイズを変えて、縦は最大化・横は200px程度とし、画面左にスライド、右にEmacsという並びにしました。Rabbitは縦横比が変わってもそれっぽくレンダリングするので、今回のような使い方ができます。
Rabbitでデモをするときは、穴を開けるだけではなく、ウィンドウサイズを変えることも検討してみてください。
RabbitにはdRubyなどでRabbitを遠隔操作するrabbit-commandというコマンドが付属しています。また、Emacs上でRabbit用スライドを編集するためのrabbit-modeにはrabbit-commandを利用してスライドを進めるコマンドが用意されています。
今回はスライドに書かれている内容を実際に動かしながらデモを行ったため、スライドの内容を取得できるとタイピングしなくてもよいためとても便利です。そこで、trunkにrabbit-command経由で現在のスライドの内容を取得するコマンドを追加しました。デモでは、EmacsからC-uM-!でrabbit-commandを呼び出すことにより、スライドの内容をEmacsに貼り付けてタイピングの手間を省きました。
もちろん、次のスライドへの移動はrabbit-modeのコマンドを利用しました。
現在のスライドの内容を取得するコマンドは以下の通りです。Rabbitを使った発表でコードを書くようなデモをするときに役立つでしょう。
% rabbit-command --current-slide-rd
はやみずさんが、tokyo-emacsはみんなで作るものだ、ということを強調していたのが印象的でした。11月にあるらしい(tokyo-emacs #x03)に参加して、tokyo-emacsを作ってみてください。
Emacsに少しでも関連していれば発表側で参加することもできそうな雰囲気でした。発表側でtokyo-emacsを作るのも面白いかもしれませんよ。
本日、C言語用単体テストフレームワークであるCutterの新バージョン1.0.8が肉リリースされました。
先日の2つの記事(その1、その2)でも紹介しましたが、1.0.8の重要な新機能はHTTPインターフェースのテスト機能 SoupCutter です。SoupCutter では GNOMEプロジェクトで開発されている HTTPサーバー・クライアントライブラリの libsoup をバックエンドに利用しており、HTTPサーバーやクライアントプログラムのテストを簡単に記述できるようになっています。
SoupCutter の使い方は、前回の紹介記事に詳しく書いてあるので是非これを参考に使ってみてください。この記事では SoupCutterの使用例として groonga のHTTPインターフェースのテストを作成しているのですが、SoupCutter を使った HTTP インターフェースのテストは実際に groonga 本体にも取り込まれています。
また、1.0.8からFedoraのrpmパッケージや、Mac OS Xのportsパッケージ(MacPorts)、Debian、Ubuntuのdebパッケージもサポートするようになったので、これまでよりも手軽に Cutter を導入できるようになりました。
汎用的なHTTPのライブラリを使ったとしても、C言語でHTTPインターフェースのテストを開発しようと思うと一手間かかってしまうのではないかと思います。しかし、HTTPのテストに特化したSoupCutterを利用すれば簡潔にテストを記述できる上に、その気になれば libsoup の豊富な機能をフル活用することもできるようになります。
ますます便利になった Cutter を使って、皆さんが関わっているプロジェクトのテストを作成してみませんか?
前回に引き続き、クリアコードインターン記事の2回目です。前回の記事で紹介したCutterのHTTPテストモジュールであるSoupCutterを使って、全文検索エンジンgroongaのHTTPインターフェースのテストを作成したので、今回はその紹介をしたいと思います。SoupCutterが実際どのように使えるかという実例として、よい題材なのではないかと思います。
全文検索エンジンgroongaはHTTPサーバー機能を備えており、Webブラウザからアクセスすることでテーブルを作成したり、データベースの中身を調べたりすることができます。このようにブラウザからデータベースを管理するために、groongaではデータベースを操作するための基本的なAPIをHTTPリクエストによって呼び出せるようにしています。例えば、localhost:10041 で groonga のサーバーを実行しているときに、http://localhost:10041/table_list を GET すると、テーブルの一覧を取得することができます。
それでは、Cutter でどのようにしてテストを開発していくかを見ていきましょう。
今回は groonga のHTTPサーバー機能のテストを行うため、まずは groonga でHTTPサーバーを走らせなければなりません。Cutterには外部コマンドを簡単に扱うことができる GCutEgg というオブジェクトがあります。groonga でポート 4545 を listen する HTTPサーバーを起動するには、以下のようなコマンドを実行します。
groonga -s -p 4545 -n /path/to/dbfile
このコマンドをテストのプログラムから実行しなければなりません。GCutEgg を使うと、以下のように簡単にコマンドを実行することができます。
1 2 3 |
GCutEgg *egg = gcut_egg_new("groonga", "-s", "-p", "4545", "-n", "/path/to/dbfile", NULL); gcut_egg_hatch(egg, NULL); |
たったこれだけで、簡単に groonga のHTTPサーバーを準備することができました。このサーバーはテストの間は実行していて、テストが終わるごとに終了してほしいので、setup で実行を始めて、tear down で終了してあげればよいでしょう。
また、前回の記事で紹介した Cutter のHTTPクライアント SoupCutClient も setup で準備しておくとよいでしょう。
1 2 |
client = soupcut_client_new();
soupcut_client_set_base(client, "http://localhost:4545/");
|
soupcut_client_set_base で SoupCutClient にベースURIを設定しておくことで、実際にGETリクエストを送信するときのURI指定で楽をすることができます。SoupCutter では、soupcut_client_get(client, "http://localhost:4545/path/to/something") のようにGETリクエストを送ることができるのですが、ベースURIを設定ておけば soupcut_client_get(client, "/path/to/something") と書くだけで、 http://localhost:4545/path/to/something にGETリクエストを送ることができるようになります。
さらにもうひとつ。GCutEgg と SoupCutClient はどちらも GLib のオブジェクトとして実装されており、解放するときは g_object_unref を呼ぶだけでデストラクタが呼ばれ、適切にオブジェクトを解放してくれます。Cutter では、オブジェクトを破棄する関数と共にオブジェクトを登録しておくと、テストの tear down 時に自動でオブジェクトを解放してくれるという便利機能があります。どうやるかというと、下記のようにするだけです。
1 2 |
cut_take(client, g_object_unref); cut_take(egg, g_object_unref); |
またこれらは、GLibをサポートしたGCutterの関数を使うと、
1 2 |
gcut_take_object(G_OBJECT(client)); gcut_take_object(G_OBJECT(egg)); |
と書くこともできます。 これで client と egg は自動的に tear down 時に解放されるようになります。Cutterでは適切な下準備をしておくと、tear down 用の関数でわざわざ後片付けをしなくてもOKです。便利ですね。
ここまでをまとめると、テストのセットアップは次のように書くことができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
static GCutEgg *egg; static SoupCutClient *client; void cut_setup(void) { client = soupcut_client_new(); soupcut_client_set_base(client, "http://localhost:4545/"); gcut_take_object(G_OBJECT(client)); egg = gcut_egg_new("groonga", "-s", "-p", "4545", "-n", "/tmp/http.db", NULL); gcut_egg_hatch(egg, NULL); gcut_take_object(G_OBJECT(egg)); g_usleep(G_USEC_PER_SEC); /* groonga の listen が完了するまで適当な時間待つ */ } |
まずは簡単なところからテストしていきましょう。groongaのHTTPサーバーはルートにGETリクエストを送ると、本文無しで 200 STATUS OK を返してくるのでこれをテストしてみます。
1 2 3 4 5 6 7 8 9 |
void test_get_root(void) { soupcut_client_get(client, "/", NULL); /* http://localhost:4545/ を GET */ soupcut_client_assert_response(client); /* status code は 2XX かチェック */ soupcut_client_assert_equal_content_type("text/javascript", client); /* Content-Type をチェック */ soupcut_client_assert_equal_body("", client); /* 本文が空かをチェック */ } |
このように、非常に簡潔にテストを書くことができます*1。
もう1つテストを書いてみましょう。groongaのHTTPサーバーは、/status にリクエストを送ると、{"starttime":1251190614,"uptime":39} というようにサーバーが開始した時刻とuptime をJSON形式でレスポンスとして応答します。starttimeもuptimeも開始した時刻や現在時刻によって刻々と変化するため、単純に assert_equal_body で期待した文字列と一致するかどうかを調べるには無理があります。このような要求に答えるために、SoupCutterでは正規表現に本文がマッチするかをテストできる soupcut_client_assert_match_body という関数を提供しています。
1 2 3 4 5 6 7 8 9 10 |
void test_get_status(void) { soupcut_client_get(client, "/status", NULL); soupcut_client_assert_response(client); soupcut_client_assert_equal_content_type("text/javascript", client); soupcut_client_assert_match_body("{\"starttime\":\\d+,\"uptime\":\\d+}", client); } |
soupcut_client_assert_match_body を利用すると、このようにして /status をGETしたときのテストを実装することができます。
このように、柔軟なテストも簡単に作成できるのが Cutter の特徴であり、開発方針でもあります。
その他のHTTPインターフェースのテストも、groongaの側でテーブルを作っておいたりカラムを作っておいたりというコードを書かなければならないことを除けば、ほとんど上記の2つのテストと同様に開発してゆくことができます。テーブルを作成する API は、/table_create にクエリーパラメータとしてテーブル作成に必要な情報を渡すことで呼び出すことができますが、これも SoupCutter では次のように簡潔に書くことができます。
1 2 3 4 5 6 7 8 |
soupcut_client_get(client,
"/table_create",
"name", "newtable1",
"flags", flags,
"key_type", "Int8",
"value_type", "Object",
"default_tokenizer", "",
NULL);
|
今回はテストを開発する実例を通して、SoupCutter の使い方について紹介しました。SoupCutter を使って開発された groonga のテストは、実際に groonga のレポジトリにも取り込まれています。
SoupCutter を含めた Cutter は今週中にリリース予定なので、是非みなさん使ってみてください。
*1 関数名がやや長いのは御愛嬌ということで ;-)
現在クリアコードでインターン中のはやみずです。クリアコードのインターンシップ制度は今年度から始まり、最初のインターン生として2週間クリアコードで働かせていただくことになりました。今回と次回の2つの記事で、現在インターンシップで取り組んでいる内容について紹介したいと思います。今回の記事では、C言語用単体テストフレームワークCutterへのHTTPテスト機能追加について紹介します。
ククログでも度々紹介されているCutterですが、このテストフレームワークを利用することでC言語での単体テストを非常に効率良く開発することができます。
Cutterはできるだけ簡潔にテストを記述できるように、基本的な assert 系関数以外にも様々なユーティリティ関数を提供しています。また、GLibを利用したプログラムのテストを開発するためのGCutterや、gdk-pixbuf(C言語で画像を扱うためのライブラリ)用の GdkCutter Pixbufなどのモジュールが含まれています。これらを利用することで、GLib や Gdk Pixbuf を使ったプログラムはよりこれらのライブラリに特化したテストを簡単に書くことが可能となっています。これからも対応ライブラリは増えてゆくのかも?
Cutterの強みの1つに、C言語でありながらメモリ管理の手間が非常に少ないということが挙げられます。ほとんどのテストフレームワークは set up(準備)→test実行→tear down(後片付け) という処理の流れを基本としているので、テスト中に利用するオブジェクトは tear down のときに解放してやればよいことがわかっています。Cutter は「このオブジェクトは tear down 時に解放しといてね」ということを Cutter に教えるための API を提供しているため、この API を利用することで解放忘れによるメモリリークを防ぐことができます。例えば文字列であれば、cut_take_string(const gchar *string) を利用することで文字列を動的にアロケートした領域にコピーしてそのポインタを得ることができ、tear down時にはこの文字列が自動的に解放されます。
今回開発しているCutterのHTTPテスト機能も、このパターンを使ってオブジェクトを簡単に生成して、しかも勝手に解放してくれるようになっています。
さて、本題のHTTPテスト機能を実装した SoupCutter に話を移しましょう。今回の開発では、HTTPの機能を簡単に実装するために libsoup というライブラリを利用しました。というよりも、GLibをサポートするのが GCutter、Gdk Pixbuf をサポートする GdkCutter Pixbuf などのように、libsoup をサポートする SoupCutter という位置付けのほうが正確です。しかし、簡単なテストであれば libsoup 自体には一切触れることなく作成することができるので、Cutter で HTTP サーバーや HTTP クライアントのテストを簡単にできるようにするためのモジュールだと思っていただいても大丈夫です。
SoupCutter を使って HTTP サーバープログラムが正しくレスポンスを返しているかをテストするには、次のように書くことができます。
1 2 3 4 5 6 7 8 9 |
SoupCutClient *client = soupcut_client_new(); /* http://localhost:8080/?key=value に HTTP Request を送信 */ soupcut_client_get(client, "http://localhost:8080/", "key", "value", NULL); soupcut_client_assert_response(client); soupcut_client_assert_equal_content_type("text/plain", client); soupcut_client_assert_equal_body("Hello, world", client); |
SoupCutClient というのは、サーバーとやりとりしたHTTPリクエスト/レスポンスを内包しているオブジェクトです。..._assert_response では、最後に受け取ったレスポンスが 2XX (200 OK など) であるかをチェックしています。同様に、..._assert_content_type では Content-Type が text/plain であることを、..._assert_equal_body ではレスポンスの本文が Hello, world であることをチェックしています。このようにして、SoupCutter を使うと非常に簡潔な記述で HTTP サーバーが思った通りに動いているかを調べることができるようになっています。
また、SoupCutter の最初の機能としては HTTP サーバーのテスト、つまりHTTPクライアントとしての機能を実装しているのですが、このHTTPクライアント機能が正しく動作しているかをテストしなければなりません。HTTPクライアント機能をテストするためには、HTTPサーバが必要です。HTTPサーバーをテストするためにHTTPクライアントを実装し、そのHTTPクライアントをテストするためにHTTPサーバーを実装する。ややこしいですね。
というわけで、SoupCutter は簡単にHTTPサーバーを作ることもできます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
static void server_callback (SoupServer *server, SoupMessage *msg, const gchar *path, GHashTable *query, SoupClientContext *client, gpointer user_data) { .... /* リクエストを処理して結果を返す */ } SoupServer *server; server = soupcut_server_take_new(NULL); soup_server_add_handler(server, "/", server_callback, NULL, NULL); soup_server_run(server); |
HTTPサーバーの作成自体はたった3行でできてしまいました。server_callback は実際にリクエストを処理してレスポンスを生成するコールバック関数です。これを SoupServer のリクエストハンドラに追加して、soup_server_run でメインループに入り、サーバーが動き始めます。
ここで注目してほしいのは、サーバーを生成するときの soupcut_server_take_new です。Cutterでは take と名前のつく関数で生成したオブジェクトは、tear down時に自動で解放されます。HTTPサーバーの場合は、ちゃんとソケットの後処理まで行い、オブジェクトを解放してくれます。つまり、HTTPサーバーを簡単に作れるだけではなく、勝手に後片付けまでしてくれます。
今回は、現在開発中である Cutter の HTTPテストモジュール SoupCutter について簡単に紹介しました。SoupCutterを使うと簡単便利にHTTPサーバー/クライアントのテストを作成することができるようになります。SoupCutter は現在クリアコードのインターンシップで開発していて、来週末に SoupCutter を含めた Cutter をリリースすることを目標に頑張っています。もし HTTP のテストを作る必要に迫られた場合には、SoupCutter を検討してみてください。
Python用の書きやすくデバッグしやすい単体テスティングフレームワークPikzie 0.9.5をリリースしました。
easy_installでもpipでもインストールできます。
% sudo easy_install Pikzie
または
% sudo pip install Pikzie
今回は、Windowsでもある程度動くようになったのでリリースしました。
新機能はテストを省略するomitの追加です。
肉の日なのでPikzie 0.9.4をリリースしました。
今回のリリースではとくに新機能はなく、 hexacosa.net::Pikzie (unittesting extention module)で教えてもらったバグが修正されている程度です。
Pythonでコードを書く機会があるとPikzieの機能も増えると思いますが、最近はなかなかPythonを使う機会がないため新機能が増えていません。新機能追加案やそのような機能を実装したパッチはWelcomeなので、使ってみて足りていない便利そうな機能があれば教えてください。
PikzieはPython Package Indexにも登録しているため、easy_installやpipを使って簡単にインストールすることができます。
easy_installを使う場合:
% sudo easy_install pikzie
pipを使う場合:
% sudo pip install pikzie
簡単に試すことができますね。
新機能はWelcomeなのですが、「テストを書きつづけること」に邪魔になりそうな機能は受け入れないかもしれません。邪魔になりそうな機能とは「デバッグしづらくなる機能」や「テストが読みづらくなる機能」などです。
Pythonで広く使われているであろうnoseはプラグイン機能があり、たくさんの機能を備えています。例えば、assert*だけではなく、できるだけタイピング数を減らすためにokやeqといった機能も提供されています。
1 2 |
ok(a == b) # == assert(a == b) eq(a, b) # == assert(a == b, "%r != %r" % (a, b)) |
しかし、これらは「テストを書きつづけること」の邪魔になりそうな機能だと思います。そのため、もし、Pikzieにeqやokを追加してほしいという要望があった場合は断るでしょう。
okは「デバッグしづらくなる機能」だと思います。上記の例では、okが失敗したとき、aとbの値がなんだったのかを示してもらえません。問題を解決するためには、何が問題かを把握する必要があり、そのためには問題解決につながるエラーメッセージが非常に重要です。その情報を提供しないokを簡単に使えるようにすると、デバッグしづらいテストを書いてしまいます。
eqは「テストが読みづらくなる機能」だと思います。簡潔に書いてあるプログラムは読みやすいですが、省略した名前を使って短く書かれたプログラムは読みづらいものです。何を意図しているかが明確ではないからです。1つ省略した名前を使うと他でも省略した名前を使いたくなります。そのため、eqを簡単に使えるようにすると読みづらいテストを書いてしまいます。
注意: 名前は長ければよいというものではありません。最小限の量で必要な情報が過不足なくこめられている名前がよい名前です。そのためには、その名前が使われている文脈を意識することが重要です。いつか、名前の話も書きたいものです。
noseもPikzieもテストを書きやすくすることに重点がおかれています。 そのため、noseもPikzieもたくさんの機能を提供しています。
noseとPikzieの違いは「テストを書きつづけること」にも重点がおかれているかどうかです。Pikzieは「テストを書きつづけること」にも重点がおかれているため、それを阻害するような機能を提供していません。
機能が多いことを重視する場合はnoseを選択するのがよいでしょう。しかし、テストを書きつづけることを重視する場合はPikzieもよい選択肢になると思います。
Pikzie以外にも高橋メソッドなプレゼンツール in XUL リターンズがリリースされています。
高橋メソッドなプレゼンツール in XUL リターンズの新機能は明日のMozilla Japan JP 10.0でわかるでしょう。
みなさん、テストしてますか?(挨拶)
UxUでは、テスト失敗時に表示されるスタックトレースからテキストエディタを起動することができます。この時、利用するテキストエディタがコマンドライン引数による行指定に対応していれば、エラーが発生した行を直接開いて編集できます。きちんと設定しておけば、テストを実行して、編集して、またテストして、といったサイクルで開発を進められるので非常に便利です。
以下に、有名なテキストエディタ向けの設定の例をいくつか挙げてみました。UxUの設定ダイアログの「MozUnitテストランナー」タブでエディタ起動用のコマンドとして入力してください。(エディタの実行ファイルのパスは必要に応じて読み替えてください)
なお、%Lは行番号、%Cは列番号、%Fはファイルのパスへと、それぞれ自動的に置換されます。
先日、C言語用単体テストフレームワークであるCutterの新バージョン1.0.6がリリースされました。(アナウンス)
NEWSにも書いてあるように、今回のリリースでも多くの新機能がありますが、ここではその中でも特におすすめの構造体定義なしで複雑なテストデータを使えるAPIを紹介します。
この新機能によりもっと簡単にデータ駆動テストが書けるようになります。実際、milter managerのテストが簡単に書けるようになりました。
データ駆動テストとは同じテストケースに対して複数のテストパターンを適用するテスト手法です。これにより多くのテストパターンを簡単に書くことができるという利点があります。
例えば、入力された文字列の小文字をすべて大文字に変換するto_upper()関数のテストをするとします。テストデータは以下の3つを考えます*1。
これはこのように書けます。
1 2 3 |
cut_assert_equal_string("HELLO", to_upper("hello")); cut_assert_equal_string("HELLO", to_upper("HelLo")); cut_assert_equal_string("HELLO", to_upper("HELLO")); |
データ駆動テストでは、テストケースはこのようになります。
1 |
cut_assert_equal_string(expected, to_upper(input)); |
このうち、expectedとinputを外部から与えることになります。Cutterではこのように書きます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
void data_to_upper (void) { #define ADD_DATUM(label, expected, input) \ gcut_add_datum(label, \ "expected", G_TYPE_STRING, expected, \ "input", G_TYPE_STRING, input, \ NULL) ADD_DATUM("all lower", "HELLO", "hello"); ADD_DATUM("mixed", "HELLO", "HelLo"); ADD_DATUM("all upper", "HELLO", "HELLO"); #undef ADD_DATUM } void test_to_upper (gconstpointer data) { const gchar *expected, *input; expected = gcut_data_get_string(data, "expected"); input = gcut_data_get_string(data, "input"); cut_assert_equal_string(expected, to_upper(input)); } |
QtのQtTestLibではこのようになります。 Chapter 2: Data Driven Testingより:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
void TestQString::toUpper_data(QtTestTable &t) { t.defineElement("QString", "string"); t.defineElement("QString", "result"); *t.newData("all lower") << "hello" << "HELLO"; *t.newData("mixed") << "Hello" << "HELLO"; *t.newData("all upper") << "HELLO" << "HELLO"; } void TestQString::toUpper() { FETCH(QString, string); FETCH(QString, result); COMPARE(string.toUpper(), result); } |
ほとんど同じくらいの手間で書けているのではないでしょうか。
テストプログラムは重複があってもいいからわかりやすい方がよい、とよく言われます。しかし、そのために、重複がたくさんある中から、興味がある重複していない部分が見つけづらくなるのは問題かもしれません。データ駆動テストでは、どのようにテストしたいのかがわかりやすいテストケースになる傾向がある気がしています。
多くのテスティングフレームワークはデータ駆動テストをサポートしています。PerlのTest::Base、RubyのRSpec*2、C++のQtのQtTestLibやGoogle Testでもサポートされています。他にもまだまだたくさんあります。
テストデータの入力方法も様々で、テストプログラム中でテストデータを生成するものから、データベースからテストデータを取り出すもの、CSV、Excelなどから取り出すものもあります。
動的に複雑なことができるスクリプト言語、アノテーションなどでメタデータを指定できる最近の言語、トリッキーなC++などでは便利にデータ駆動テストを実行できるテスティングフレームワークは多くあります。しかし、C言語用のフレームワークではそんなになかったのではないかと思います。*3
Cutterに興味がある方は使ってみたり、メーリングリストなどで提案、質問などしてみてください。
Firefoxアドオン開発者向け自動テストツールのUxUは、新たに発見したバグの修正にも活用することができます。本日リリースされたXUL/Migemo バージョン0.11.7で行われた修正の場合を例に、実際のデバッグ作業の流れを解説します。
XUL/Migemoは、Firefoxで表示しているページ内のテキストを検索する機能を提供するアドオンですが、検索を開始する際に、「現在のスクロール位置から検索を開始する」という処理を含んでいます。0.11.6以前では、この機能を使用している時に、ページ先頭から検索が始まるべき場面で、先頭以外の場所から検索が始まってしまうことがあるという問題が起こっていました。
いくつか条件を変えて調査した結果、スクロールが発生しているページでは期待通りの結果になっているのに対して、スクロールが全く発生していないページ(ページ全体がウィンドウの現在の大きさの中に収まっているページ)では期待と異なる結果になっていることが判明しました。
前述の処理の肝となっているのは、pXMigemoFindクラスのfindFirstVisibleNode()というメソッドです。このメソッドは、渡されたフレーム(DOMWindow)において現在のスクロール位置で見えている最初の要素を検索して返す物です。このメソッドの戻り値を確認した所、前述の条件下では戻り値が期待と異なっている事が判明しました。
このことから、今回主な修正対象になるのはこのfindFirstVisibleNode()というメソッドであるということになります。
上記メソッドの実装を見直す前に、UxU用のテストケースを作成します。これにより、これから行う修正で目指すべきゴールが明確になります。つまり、このテストが成功する状況まで持って行くことが、今回の修正のゴールとなります。
テストケースはJavaScriptのコードだけで完結する場合もありますが、今回のような場合は実際のWebページを使ってテストを行う必要があります。そこで、問題が発生する条件と発生しない条件の両方の事例としてHTMLドキュメントを用意します。
これらのドキュメントを使い、shortPage.htmlではスクロールが発生せずlongPage.htmlではスクロールが発生する、という条件の下でテストを行うテストケースをこれから作成することになります。
ところで、現在の実装で問題が起こっている場合だけでなく、すでに正常に動いている場合の事例も同時に作成していることに気がついたでしょうか? 両方の場合を常にテストすることで、「ある問題を修正したら、今度は、今までは正常に動いていた物が動かなくなった」という状況、いわゆる後退バグを未然に防ぐことができます。 *1
それではテストケースを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
utils.include('pXMigemoClasses.inc.js'); var findModule; function setUp() { yield utils.setUpTestWindow(); findModule = new pXMigemoFind(); findModule.target = utils.getTestWindow().gBrowser; } function tearDown() { findModule.destroy(); findModule = null; utils.tearDownTestWindow(); } function testFindFirstVisibleNode() { // ここに実際のテスト内容を記述する } |
pXMigemoFindクラスの単体テストはまだ作成されていなかったので、今回はsetUpとtearDownから作成します。すでに作成済みのテストケースがあり、それにテスト項目を追加する場合、この作業は不要となります。
pXMigemoFindクラスはtabbrowser要素を用いて初期化する必要があるため、setUpでテスト用のFirefoxウィンドウを開き、そのウィンドウのtabbrowser要素で初期化します。また、tearDownではsetUpで開いたテスト用のFirefoxウィンドウを閉じて、pXMigemoFindクラスのインスタンスを破棄します。UxUは関数名を見て自動的にその種類を判別するため、これだけで、これらの関数はテストの初期化処理と終了処理として認識されるようになります。
なお、インクルードしているpXMigemoClasses.inc.jsというファイルは、pXMigemoFindクラスやそのクラスが依存しているすべての関連クラスの定義を読み込む物です。
次に、テストの内容を作成していきます。
1 2 3 4 5 6 7 8 |
function testFindFirstVisibleNode() { var win = utils.getTestWindow(); win.resizeTo(500, 500); assert.compare(200, '<', utils.contentWindow.innerHeight); // ここに実際のテスト内容を記述する } |
関数名を「test」で始めると、その関数はテストの内容として自動的に認識されます。
最初に、ウィンドウの大きさを調整して、「shortPage.htmlではスクロールが発生せずlongPage.htmlではスクロールが発生する」という条件を整えておきます。ここでは、テスト自体が期待通りの条件下で実行されていることを確認するために、assert.compare()でテスト用フレームの大きさを調べています。
1 2 3 4 5 6 7 8 9 |
yield utils.addTab(baseURL+'../res/shortPage.html', { selected : true }); var frame = utils.contentWindow; var node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame); assert.equals(utils.contentDocument.documentElement, node); item = frame.document.getElementById('p3'); node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame); assert.equals(item, node); |
テスト用のドキュメントを新しいタブで開き、findFirstVisibleNode()メソッドの返り値が期待通りかどうかを検証します。1つ目の検証は前方検索、2つ目は後方検索です。
同様にして、スクロールが発生する場合のテストも作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
yield utils.addTab(baseURL+'../res/longPage.html', { selected : true }); frame = utils.contentWindow; node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame); assert.equals(utils.contentDocument.documentElement, node); item = frame.document.getElementById('p10'); frame.scrollTo(0, item.offsetTop); node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame); assert.equals(item, node); frame.scrollTo(0, item.offsetTop - frame.innerHeight + item.offsetHeight); node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame); assert.equals(item, node); item = frame.document.getElementById('p21'); frame.scrollTo(0, item.offsetTop - frame.innerHeight + item.offsetHeight); node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame); assert.equals(item, node); |
スクロールされていない時、ページ途中までスクロールされている時、ページの最後までスクロールされている時の各ケースで前方検索と後方検索を行い、結果を検証します。
ここで、かなりの部分のコードが重複していることに気がついたでしょうか。このような場合、それぞれの検証の前で重複しているコードと検証とをひとまとめにして実行する関数(カスタムアサーション)を定義しておくと、テスト項目の追加が簡単になります。以下は、カスタムアサーションを使ってここまでのテスト内容を書き直した物です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
function testFindFirstVisibleNode() { var win = utils.getTestWindow(); win.resizeTo(500, 500); assert.compare(200, '<', utils.contentWindow.innerHeight); function assertScrollAndFind(aIdOrNode, aFindFlag) { var frame = utils.contentWindow; var item = typeof aIdOrNode == 'string' ? frame.document.getElementById(aIdOrNode) : aIdOrNode ; frame.scrollTo( 0, (aFindFlag & findModule.FIND_BACK ? item.offsetTop - frame.innerHeight + item.offsetHeight : item.offsetTop ) ); var node = findModule.findFirstVisibleNode(aFindFlag, frame); assert.equals(item, node); } yield utils.addTab(baseURL+'../res/shortPage.html', { selected : true }); assertScrollAndFind(utils.contentDocument.documentElement, findModule.FIND_DEFAULT); assertScrollAndFind('p3', findModule.FIND_BACK); yield utils.addTab(baseURL+'../res/longPage.html', { selected : true }); assertScrollAndFind(utils.contentDocument.documentElement, findModule.FIND_DEFAULT); assertScrollAndFind('p10', findModule.FIND_DEFAULT); assertScrollAndFind('p10', findModule.FIND_BACK); assertScrollAndFind('p21', findModule.FIND_BACK); } |
テストケースが完成したら、テストを実行してみましょう。実装の修正前なので、当然、このテストは「失敗」という結果が出ます。ですが、この段階では問題ありません。これから、このテストの結果が「成功」になることを目指して実装を修正していきます。
実装の修正内容については省略します。良いアイディアを思いついたら、それを実装に反映して、再度テストを実行してみましょう。テストに成功しないようであれば、まだ修正が必要です。
何度テストを実行しても結果が「成功」になるようになれば、実装の修正はひとまず完了です。修正内容をリポジトリにコミットするなり、修正済みの新しいバージョンとしてリリースするなりしましょう。
以上で、今回発見された問題の修正は完了しました。
しかし、上記のテストだけではテストしきれないような、より複雑な条件でだけ発生するバグが新たに見つかるかもしれません。そのような場合は、テストを新たに追加して、それらがすべて「成功」するようになるまで修正してやりましょう。その時はもちろん、他のテストも同時に実行することを忘れないようにしましょう。
また、開発を進める中で、他の部分に加えた変更の影響を受けて上記のテストが失敗するようになることがあるかもしれません。そのような場合、再びテストが通るようになるように実装を修正する必要があります。
実装の仕様を変更した時にも、ここで作成したテストケースは「成功」しなくなる場合があります。このような場合は「ゴール」自体が変わったということになりますので、実装ではなくテストケースの側を修正しなくてはなりません。
自動テストを使った開発では、メンテナンスする必要があるコードが「実装」と「テストケース」の2つになるため、一見すると、手間だけが倍増するように思えるかもしれません。
しかし、一連のテスト手順を自動化しておくことで、人の手によるテストでは見落としてしまいかねない思わぬ後退バグの発生に迅速に気づけるようになります。後退バグの発生に日々頭を悩ませている人は、是非、自動テストを開発に取り入れてみてください。
*1 もちろん、後退バグの発生自体は未然には防ぎきれません。しかし、後退バグの発生にすぐ気がつくことができれば、コミットやリリースの前にその後退バグを修正できるため、他の共同開発者やユーザには後退バグの影響を与えずに済むようになります。
Test::Unit 2.0.1が RubyForge上でリリースされました。RubyGemsも提供されているの で以下のようにインストールできます。
% sudo gem install test-unit
Test::UnitはRuby 1.8.xに標準添付されている単体テストフレーム ワークです。しかし、Ruby 1.9.1からは miniunitが標準 添付され、Test::UnitはRubyForgeで開発が継続されることになりま した。これからもTest::Unitを使うときはRubyGemsでインストール することになるでしょう。
Ruby 1.8.xに標準添付されているTest::Unitは互換性のために、 Test::Unit 1.2.3としてリリースされています。Ruby 1.9.1でも Ruby 1.8.xに標準添付されているTest::Unitと同じTest::Unitを使 用したい場合は以下のようにします。
% sudo gem install test-unit --version '= 1.2.3'
テストファイル内(変更前):
1 2 |
require 'test/unit'
...
|
テストファイル内(変更後):
1 2 3 4 |
require 'rubygems' gem 'test-unit', '= 1.2.3' require 'test/unit' ... |
余談ですが、Ruby 1.9.1でTest::Unitが標準添付から外れ、 miniunitが標準添付になったのはTest::Unitのソースがメンテナン スしづらくなっていたのが主な理由です。
Ruby 1.8.xに標準添付されているTest::Unitは長い間メンテナンス はされていましたが、特に機能拡張などは行われていませんでした。 しかし、その間にもテスト環境を便利にするライブラリが公開され てきました。例えば、RSpecのような BDD用のフレームワークや、 expectations のような軽量の単体テストフレームワーク、 Shoulda/ test/spec/ Mochaのように Test::Unitを拡張するライブラリなどです。Test::Unitは少し時代 遅れになってしまったのです。
最近のテスト用のフレームワークは DSL化の方向に向かっているようにも見えます。 これはRSpecの影響が大きいのでしょう。expectationsやShouldaもテスト用の DSLを提供します。
しかし、Test::UnitはDSLを提供しません。テストを「英語らしく」 ではなく「Rubyプログラムらしく」書きます。好みにもよりますが、 これはTest::Unitのメリットの1つと言えます。
Test::Unitに他のフレームワークやライブラリの機能を、 Test::Unitの「Rubyプログラムらしく」テストを書ける特性を活か したまま追加すれば、Test::Unitはもっと便利で使いやすいテスト フレームワークになるでしょう。Test::Unit 2.x系列はRuby 1.8.x に標準添付されていた頃とは違い、そのような方針の元で活発に開 発されていく系列になります。
例えば、以下のような機能が他のフレームワークやライブラリから 移植されています。
ここでは「差分表示」と「ネストしたテスト定義」だけ紹介します。 *1
RSpecでは比較結果が異なった場合に差分を表示して違いをわかり やすく表示してくれます。
diff_spec.rb:
1 2 3 4 5 6 7 8 |
require 'rubygems' require 'spec' describe String do it do ["I", "am", "a", "boy"].join("\n").should == ["I", "was", "a", "boy"].join("\n") end end |
実行結果(差分表示部分のみ):
% ruby diff_spec.rb -D ... Diff: @@ -1,5 +1,5 @@ I -was +am a boy ...
同様の機能がTest::Unit 2.0.1にもあります。
test_diff.rb:
1 2 3 4 5 6 7 8 9 10 |
require 'rubygems' gem 'test-unit' require 'test/unit' class TestDiff < Test::Unit::TestCase def test_diff assert_equal(["I", "am", "a", "boy"].join("\n"), ["I", "was", "a", "boy"].join("\n")) end end |
実行結果(差分表示部分のみ):
% ruby test_diff.rb ... diff: I - am + was a boy ...
この例では、ほとんど同じ差分表示ですが、Test::Unit 2.0.1の 差分表示がRSpecの差分表示よりも便利なこともあります。
今度は"\n"ではなく" "でjoinして1行の文字列として比較します。
test_diff.rb:
1 2 3 4 5 6 7 8 9 10 |
require 'rubygems' gem 'test-unit' require 'test/unit' class TestDiff < Test::Unit::TestCase def test_diff assert_equal(["I", "am", "a", "boy"].join(" "), ["I", "was", "a", "boy"].join(" ")) end end |
実行結果(差分表示部分のみ):
% ruby test_diff.rb ... diff: - I am a boy ? ^ + I was a boy ? + ^ ...
Test::Unit 2.0.1では必要なら同じ行のうち、どの列が異なってい るのかも表示します。RSpecでは行単位の差分までで列単位までの 差分は表示しません。
余談ですが、この差分表示形式はPython の difflib ライブラリで使われている形式です。
Shouldaではcontextをネストさせることにより、便利にテストを書 くことができます。以下はShouldaのページからの引用です。 *2
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class UserTest < Test::Unit::TestCase context "A User instance" do setup do @user = User.find(:first) end should "return its full name" do assert_equal 'John Doe', @user.full_name end context "with a profile" do setup do @user.profile = Profile.find(:first) end should "return true when sent #has_profile?" do assert @user.has_profile? end end end end |
ネストされた"with a profile"のcontext内では上位の"A User instance"のcontext内のsetupが実行された後に実行されます。つ まり、以下のような実行順序になります。
実行されるフィクスチャ(setup)がネストで自然に表現されていま す。
Test::Unit 2.0.1では以下のように書きます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class UserTest < Test::Unit::TestCase def setup @user = User.find(:first) end def test_full_name assert_equal('John Doe', @user.full_name) end class ProfileTest < UserTest def setup super @user.profile = Profile.find(:first) end def test_profile assert_true(@user.has_profile?) end end end |
これは以下のように実行されます。
実行されるフィクスチャ(setup)がネストとクラス階層で自然に表現 されています。
Test::UnitはRuby 1.9.1からは標準添付ではなくなりましたが、 Test::Unit 2.xとして活発に開発が続けられています。既存の他の フレームワークやライブラリのよいところは積極的に導入している ため、Ruby 1.8.xに標準添付されているTest::Unitよりもはるかに 使いやすくなっています。
今回は紹介しませんでしたが、他のフレームワークやライブラリに はないTest::Unit 2.x独自の便利な機能もあります。Test::Unitの 「Rubyプログラムらしい」テストの書き方が好きな場合はこれから もTest::Unitを使ってみてはいかがでしょうか。
GaUnit 0.1.6がリリースされました。 ([アナウンス]|[ダウンロード])
GaUnitは便利なSchemeインタプリタであるGauche用のxUnitベースの単体テストフレームワークです。0.1.6ではGauche標準のgauche.testモジュール(リンク切れしそうなリンク)用に書かれたテストを実行することができるようになりました。(簡単なものであれば)
gauche.testのテストを実行するために、テストスクリプトを読み込み、GaUnitが理解できるようにS式を変形します。変形されたS式はGaUnitが提供するgauche.test互換APIが使える無名モジュール内で評価され、GaUnitのテストとして認識されるようになります。
SchemeではマクロでS式を変換するということはよく行われます。もし、それで十分でない場合は上記のように、スクリプト自体をS式として読み込んで、変換して、評価、ということも行うことができます。これは、S式がプログラムで扱いやすいためにできると言えるでしょう。他の言語、例えばJavaScriptでは、プログラムを変換するためにtoSourceしたものをreplaceしてevalするということが行われることもあるようです。
ただ、非常に多くの場合はこのような方法は必要ありません。今回は(use gauche.test)をどうしてもうまく扱えなかったのでこの方式をとっています。
Gaucheスクリプトを書くときのちょっとした豆知識を紹介しておきます。
Gaucheスクリプトの内容をS式のリストとして読み込む処理は以下のようになります。ファイル先頭のcoding: utf-8などのような文字エンコーディング指定を考慮するためにopen-coding-aware-portをはさむことを忘れてはいけません。
1 2 3 4 |
(define (file->sexp-list file) (call-with-input-file file (lambda (input) (port->sexp-list (open-coding-aware-port input))))) |
GaUnit 0.1.6ではgauche.testのテストも実行できるようになったため、既存のgauche.testのテスト(大事な資産)を捨てることなくGaUnitに移行できるようになりました。これを機にGaUnitも試してみてはいかがでしょうか。
gauche.testであれGaUnitであれ、自動化されたテストがあるということは安心できるものです。
ここには書いていませんでしたが、Cutter 1.0.3のリリース のリリースの約1ヶ月後に1.0.4がリリースされました。 さらにその約2ヶ月後の昨日、1.0.5がリリースされました。 CutterとはC言語用の単体テス トフレームワークです。
詳細は NEWSに 書いてありますが、1.0.5での目玉新機能は以下の3点です。
gdk-pixbufをサポートすることにより、画像が等しいかどうかを検 証できるようになりました。また、もし、画像が異なる場合は画像 間の差分を示し、画像のどこが異なるのかをわかりやすくしていま す。
例えば、このような画像を比較したとします。
これらの画像では赤い丸の部分が異なっています。
これらの画像の差分画像は以下のようになります。
左上に期待画像、右上に実際の画像、左下に異なるピクセルを示し た画像、右下に異なるピクセルを強調表示、同じピクセルを弱めに 表示した画像を配置しています。左下の画像を見ることでどの部分 が異なるのかが具体的にわかり、右下の画像を見ることで比較画像 はどのあたりが異なるのかを相対的に確認する事ができます。
もっとよい表現方法があるかもしれませんが、しばらくはこの方法 を採用しする予定です。もしかすると、今後、よりよい表現方法に 変更されるかもしれません。
ちなみに、この機能はPDF操作・レンダリングライブラリである Popplerのテスト で利用されていま す。
Cutterを使用していて、こんな検証・便利関数があったら便利だ、 と感じたものは積極的にCutter本体に取り込んでいます。
1.0.5では12個の検証、9個の便利関数が追加されました。追加され た検証・便利関数のリストは NEWSに 書かれています。
今までもユーザが独自で検証を定義することはできたのですが、バッ クトレースを取得するためにマクロとして定義する必要がありまし た。
1.0.5では cut_trace() というマクロが追加され、検証を関数として定義してもバックトレー スを取得することができるようになりました。マクロは可変長引数 が簡単に書けるなど便利な事も多いのですが、構文エラーが見つけ にくいなどという問題もあります。1.0.5からは関数とマクロを使い 分けられるようになり、より便利にデバッグのしやすいテストが書 けるようになりました。
Cutter 1.0.5ではテスト作成・デバッグ支援の機能が強化され、よ り便利なテスト環境を提供するようになりました。今までよりもC 言語でのテスト作成が楽しくなるかもしれません。
[チュートリアル ] [リファレンスマニュアル ] [ダウンロード ]
ちょうど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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
... 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
... 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
... 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
... 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
#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も検討してみてはいかがでしょうか。
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を以下のように変更 する必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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:
1 2 3 4 5 6 |
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:
1 2 3 4 5 6 7 8 9 |
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:
1 |
SUBDIRS = unit |
続いて、configure.acのAC_CONFIG_FILESにtest/unit/Makefileを 追加し、configureがtest/unit/Makefileを生成するようにします。
configure.ac:
1 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#!/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:
1 2 |
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:
1 2 3 4 5 |
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が自動で見つけてくれます。
コードにすると以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
void data_XXX(void) { cut_add_data("データ1の名前", 1, 1free, "データ2の名前", 2, 2free, "データの例", 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で書かれたテストです。
1 2 3 4 5 6 7 8 9 10 |
# 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で書いたテストです。
1 2 3 4 5 6 |
# 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で書いたテストです。
1 2 3 4 5 6 7 |
# 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が表示されます。
1 2 3 4 5 6 |
# 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を表示します。
1 2 3 4 5 6 7 8 9 10 11 |
# 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 例えば、テストの粒 度やテスト駆動開発など
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」が正しく機能するかどうかをテストしています。
1 |
utils.include('../../components/pXMigemoFind.js', null, 'Shift_JIS'); |
冒頭では、ヘルパーメソッドのutils.includeを使って、DocShellIteratorクラスが定義されているファイルpXMigemoFind.jsの内容を取り込んでいます。第3引数で読み込むファイルのエンコーディングを指定していますが、これはファイルの中に含まれる日本語のコメントがShift_JISになっているためです。
なお、何らかの事情でそのままファイルをincludeできない(includeするとまずい)場合には、ファイルの内容をテキストとして読み込んで加工した後に評価するという方法もあります。上の例は、以下のように書き換えても同様に動作します。
1 2 3 4 5 |
var extract; eval('extract = function() { '+ utils.readFrom('../../components/pXMigemoFind.js') + '; return DocShellIterator }'); var DocShellIterator = extract(); |
utils.readFromはファイルの内容を文字列として返しますので、replaceなどを使って邪魔な部分を消してやれば、そのままではincludeできないファイルから必要な部分だけを取り出して評価できます。
このファイルにはテストケースが一つだけ定義されています。テストの前処理(setUp)と後処理(tearDown)は以下の通りです。
1 2 3 4 5 6 7 8 9 10 11 12 |
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クラスのインスタンスを生成して、検索対象のフレームを移動する処理を実際に行い、処理結果が期待されたものと等しいかどうかをテストしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
'前方検索': 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オブジェクトに追加されている新しいメソッドで、以下のように、内部でより単純なアサーションを複数行っています。
1 2 3 4 5 6 7 8 9 |
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点を行うだけで すむようになりました。
実際のコードは以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
(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-*手続きの最後に#fを付けているのは末尾再帰の最適化 でバックトレースを落とさないようにするためです。
新しいAPIでは、普通のGaucheライブラリと同じようにテストを書 けます。テストのために覚えることと言えば、どのassert-*を使お うかということくらいです。これは、普段の別のライブラリを用い た開発と同じですね。
GaUnitでのテスト書き方をもっと知りたい人はチュートリアル を読んでください。