注意: 長いです。
一言まとめ: within
とtest-unit-capybaraを使ってHTMLのテストを書くと問題を見つけやすくなる。あわせて読みたい: デバッグしやすいassert_equalの書き方
HTMLに対するテストに限らず、開発を進めていく中でテストが失敗する状況になることは日常的にあることです。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アプリケーションがあるとします。
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=
がポイントです。
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
それでは、まずは期待した見出しが返ってきているかを確認してみましょう。
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の一部も再掲します。
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を変更
それでは、HTMLを少し変更してナビゲーション用のリンクをいれましょう。
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を確認することができます。
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が表示されるため「失敗したときだけテストを変更する」といったことをする必要はありません。
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
を確認してみましょう。
assert_equal("No navigation.", find(".header p").text, source)
問題なさそうに見えますが、どうして出力されていないのでしょうか。
それは、assert_equal
が呼び出される前にfind
の中で例外が発生しているからです。Capybaraでは要素が見つけられなかったときはCapybara::ElementNotFound
例外を投げるのでassert_equal
は呼び出されなかったのです。
これを回避するためには以下のようにfind
ではなくhas_selector?
で事前にCSSセレクタがマッチするかを確認する必要があります。
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セレクタを同時に出力してくれないのであまりうれしくありません。
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。
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
というメソッドがあることを知っていますか?これを使うと検索範囲を限定できます。例えば、以下は同じ意味になります。
# これまでの書き方
find(".header p").text
# withinを使った書き方
within(".header") do
find("p").text
end
within
を使ってテストを書きなおしてみましょう。
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"がマッチすることを期待しています。
<div class="header">
<ul>
<li><a href="/">Top</a></li>
</ul>
</div>
このくらいの量であればコンソールに出力されても解析しやすいでしょう。少なくとも「<p>
はないが<ul>
はある」ということはすぐにわかります。このヒントがあれば原因を特定するのにだいぶ役立つはずです。
では、within
のブロック内でマッチしなかった場合に現在マッチしたノードのHTMLを出力するにはどうしたらよいでしょうか。これを実現できると、失敗した時だけ必要最低限の情報を得られて素早く原因を見つけられそうです。しかもCapybaraの自然な使い方です。
test-unit-capybara
test-unit 2でCapybaraを便利に使うためのtest-unit-capybaraというライブラリがあります。これを使えば、これまで通りCapybaraの作法で書くだけで必要な情報を過不足なく表示してくれます。以下のようにrequire "test/unit/capybara"
とするだけでtest-unit-capybaraを使えます。
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の書き方
おまけ: assert_match問題
ここで取り上げた問題は「問題があるだろう範囲が広すぎて問題を発見することが困難になる」ことが原因です。同様の問題はtest-unitのassert_match
、RSpecのshould match
にもあります。
以下はUser-Agentが任意のバージョンのbingbotであることをテストしています。このテストは失敗するのですが、どうして失敗するかすぐにわかるでしょうか?
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
で比較することをオススメします。
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]+
が「.」を考慮していないことが原因ですね。