デバッグしやすいHTMLのテストの書き方 - 2012-01-18 - ククログ

ククログ

株式会社クリアコード > ククログ > デバッグしやすいHTMLのテストの書き方

デバッグしやすいHTMLのテストの書き方

注意: 長いです。

一言まとめ: 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::DSLCapybara.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>

このくらいの量であればコンソールに出力されても解析しやすいでしょう。少なくとも「

はないが

    はある」ということはすぐにわかります。このヒントがあれば原因を特定するのにだいぶ役立つはずです。

    では、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]+が「.」を考慮していないことが原因ですね。

    1. 別途launchy gemが必要なことに注意。

    2. raise_find_errorメソッドをオーバーライドする