マウスでクリックするようにテストしたい
モデルやコントローラー単体での動きが予想通りであることを確認できると、やはり最後はサーバーを起動して、マウスでクリックしながら全体の動きに問題が無いか確認したくなる。1アクションで複数モデルを同時に保存するサンプルプロジェクトでは、ある段階から、自分は以下の操作を何度も繰り返したことを思い出す。
- 伝票リストの一覧を開く。
- 伝票リストから「編集」リンクをクリック。
- 明細行の1行目で「挿入」リンクをクリック。
- 明細行の2行目で「コピー」リンクをクリック。
- 明細行の1行目で「削除」リックをクリック。
- 「修正する」ボタンを押して編集を完了する。
- 伝票に明細行が1行追加されたことを確認して満足!
最初から上記の手順が確定していた訳ではなく、何度となくマウスでの確認操作を繰り返しているうちに、自然とそのような手順になったという感じだ。たぶん意識のどこかに、より少ない手順で、より多くのことを確認したい、という気持ちがあったと思う。もちろん、上記の手順だけでは全てのチェックを網羅できていないが、伝票の明細行を自由に編集して確実に保存することについては、この手順で十分な気がして、繰り返していた。
そして気付いた...。Railsでは、上記のような、あるユーザーがwebサイトに訪れて行う一連の操作をシミュレーションして、テストする仕組みも準備されていた。確認のために繰り返し行うマウス操作は、integrationテストに登録してしまえば良かったのだ!
integrationテストの新規作成
- モデルやコントローラーのテスト(unitテストやfunctionalテスト)と違って*1、integrationのテストクラスは自動生成されないので、新規作成した。
- 例によって、script/generate integration_testというコマンドが用意されているので、以下のようにやってみた。
$ script/generate integration_test user_click
exists test/integration/
create test/integration/user_click_test.rb
- クラス名「user_click」とすることで、以下のような「UserClickTest」クラスが生成された。(絶対に成功するはずのテスト「test_truth」が設定されている。)
require "#{File.dirname(__FILE__)}/../test_helper" class UserClickTest < ActionController::IntegrationTest # fixtures :your, :models # Replace this with your real tests. def test_truth assert true end end
- 以下のようにintegrationテストを実行してみると、test_truthが期待通り成功してくれた。このテストクラスがちゃんと機能している証拠だ。準備だけは完了。
$ rake test:integration (in /Users/bebe/railsapp/test_slip202) /System/Library/Frameworks/Ruby.framework/Versions/1.8/usr/bin/ruby -Ilib:test "/Library/Ruby/Gems/1.8/gems/rake-0.8.1/lib/rake/rake_test_loader.rb" "test/integration/user_click_test.rb" Loaded suite /Library/Ruby/Gems/1.8/gems/rake-0.8.1/lib/rake/rake_test_loader Started . Finished in 0.177458 seconds. 1 tests, 1 assertions, 0 failures, 0 errors
fixturesの内容
- integrationテストについても、以前設定した以下のfixturesの内容で、テスト実行前にデータベースが初期化される。
# test/fixtures/slips.yml one: id: 1 number: 1 executed_on: 2/14 total_yen: 1000 two: id: 2 number: 2 executed_on: 2/15 total_yen: 2000 # test/fixtures/journals.yml one: id: 1 comment: test1 yen: 1000 slip_id: 1 position: 1 two: id: 2 comment: test2 yen: 2000 slip_id: 2 position: 1
思うままにテストを書く
- テスト中に実際にマウスがクリックされる訳ではなかった。
- マウスがクリックされる時には、ブラウザは何らかの情報をサーバーに送信する。
- テストコードがそれと同じ情報を送信するように設定することで、マウスをクリックした時と同じ動作が引き起こされるのだ。
- そのように考えて、手作業のマウスクリックをテストコードに置き換えると以下のようになった。
require "#{File.dirname(__FILE__)}/../test_helper" class UserClickTest < ActionController::IntegrationTest ...(中略)... def test_editing_row # indexページへアクセス # get "/slips"の代わりにRESTなURL指定が可能 get slips_path assert_response :success assert_template 'index' # 1行目の編集をクリック # get "/slips/#{slips(:one).id}/edit"の代わりにRESTなURL指定が可能 get edit_slip_path(slips(:one)) assert_response :success assert_template 'edit' # 1行目で「挿入」リンクをクリック # xhr(xml_http_requestのエイリアスメソッド)では、なぜか「1 errors」が発生してしまった xml_http_request :post, insert_journals_path(:index=>1), {:journal=>{1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>2} } } assert_response :success assert_select "tbody[id^=j9] tr" do assert_select 'th', 2 assert_select 'td', 2 end assert_select 'tbody#journals_footer tr' do assert_select 'th', 3 assert_select "th input[value=1,000]", 1 end # 2行目で「コピー」リンクをクリック # 1行目で挿入したデーターも含めて、ちょっと複雑なパラメーターが渡されている xml_http_request :post, copy_journals_path(:index=>1, :_method=>:post), {:journal=>{:j123456789=>{:comment=>nil, :yen=>nil, :index=>'j123456789', :position=>1}, 1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>2} } } assert_response :success assert_select 'tbody[id^=j9] tr' do assert_select 'th', 2 assert_select 'td', 2 assert_select "td input[value=test]", 1 assert_select "td input[value=1,000]", 1 end assert_select 'tbody#journals_footer tr' do assert_select 'th', 3 assert_select "th input[value=2,000]", 1 end # 1行目で「削除」リンクをクリック xml_http_request :delete, delete_journals_path(:index=>'j123456789'), {:journal=>{:j123456789=>{:comment=>nil, :yen=>nil, :index=>'j123456789', :position=>1}, 1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>2}, :j123456790=>{:comment=>'test', :yen=>1000, :index=>'j123456790', :position=>3} } } assert_response :success assert_select 'tbody#journals_footer tr' do assert_select 'th', 3 assert_select "th input[value=2,000]", 1 end # この状態で「修正」ボタンを押す # put slip_path(assigns("slip")では、直前のアクションdelete_row実行後の@slipになってしまうためfailure put slip_path(slips(:one)), {"slip"=>{"total_yen"=>"2,000", "number"=>"1", "executed_on"=>"2/14"}, "commit"=>"修正する", :journal=>{ 1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>1}, :j123456790=>{:comment=>'test', :yen=>1000, :index=>'j123456790', :position=>2} } } assert_redirected_to slip_path(assigns(:slip)) follow_redirect! assert_response :success assert_template 'show' # 1行追加されたslipとjournalの内容を検証 slip_one = Slip.find(slips(:one).id) assert_equal 2000, slip_one.total_yen assert_equal 2 , slip_one.journals.count end end
- ここまで書いてみて感じたこと...
- コントローラーのfunctionalテストを続けて書いているような気持ちになってきた。
- フォームを送信する時も、パラメーターを設定してあげる必要があり、その設定に結構苦労する。
- ログファイルに記録されたパラメーターがかなり参考になる。
- ただし、フォームを設定する時には、その時点でパラメーターの構造を自分で想像している訳で、その構造が分からなくなってしまうということは、自分で作っているアプリケーションを理解できなくなっている状態だ。ログファイルに頼りすぎるのも問題あるかも。
操作ごとにメソッドを分けて定義してみた
- 上記の書き方では、あまりにもダラダラと操作が続くので何をやっているのか分かり難い。操作ごとにメソッドを分けて定義してみることに。
- def go_to_index 以下で、一区切りの操作をメソッドに定義している。
- これで test_editing_row を見て、テストの概要が一目で理解できるようになった。
require "#{File.dirname(__FILE__)}/../test_helper" class UserClickTest < ActionController::IntegrationTest ...(中略)... def test_editing_row go_to_index go_to_edit insert_row_1 copy_row_2 delete_row_1 update_slip check_for_slip end # indexページへアクセス def go_to_index get slips_path assert_response :success assert_template 'index' end # 1行目の編集ページへアクセス def go_to_edit get edit_slip_path(slips(:one)) assert_response :success assert_template 'edit' end # 1行目で挿入 def insert_row_1 xml_http_request :post, insert_journals_path(:index=>1), {:journal=>{1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>1} } } assert_response :success assert_select "tbody[id^=j9] tr" do assert_select 'th', 2 assert_select 'td', 2 end assert_select 'tbody#journals_footer tr' do assert_select 'th', 3 assert_select "th input[value=1,000]", 1 end end # 2行目でコピー def copy_row_2 xml_http_request :post, copy_journals_path(:index=>1), {:journal=>{:j123456789=>{:comment=>nil, :yen=>nil, :index=>'j123456789', :position=>1}, 1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>2} } } assert_response :success assert_select 'tbody[id^=j9] tr' do assert_select 'th', 2 assert_select 'td', 2 assert_select "td input[value=test]", 1 assert_select "td input[value=1,000]", 1 end assert_select 'tbody#journals_footer tr' do assert_select 'th', 3 assert_select "th input[value=2,000]", 1 end end # 1行目で削除 def delete_row_1 xml_http_request :delete, delete_journals_path(:index=>'j123456789'), {:journal=>{:j123456789=>{:comment=>nil, :yen=>nil, :index=>'j123456789', :position=>1}, 1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>2}, :j123456790=>{:comment=>'test', :yen=>1000, :index=>'j123456790', :position=>3} } } assert_response :success assert_select 'tbody#journals_footer tr' do assert_select 'th', 3 assert_select "th input[value=2,000]", 1 end end # 「修正する」ボタンを押す def update_slip put slip_path(slips(:one)), {"slip"=>{"total_yen"=>"2,000", "number"=>"1", "executed_on"=>"2/14"}, "commit"=>"修正する", :journal=>{ 1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>1}, :j123456790=>{:comment=>'test', :yen=>1000, :index=>'j123456790', :position=>2} } } assert_redirected_to slip_path(assigns(:slip)) follow_redirect! assert_response :success assert_template 'show' end # 保存されたslipとjournalの内容を検証 def check_for_slip slip_one = Slip.find(slips(:one).id) assert_equal 2000, slip_one.total_yen assert_equal 2 , slip_one.journals.count end end
コントローラーのfunctionalテストとの違い
テストとしてはかなり見易くなったが、現状ではコントローラーのfunctionalテストと大差ない感じ。同じようなテストはfunctionalテストでもある程度書けそうな気がする。それではintegrationテストは何のために?
調べてみると、integrationテストでは、ユーザーが個別に操作した時の環境をテスト出来るようになっていた。具体的には「open_session」を利用することで、以下のようなテストの書き方も可能になった。
- ユーザーを明示的に特定して、そのユーザーの操作環境でのテストが可能になる。(現状ログイン機能は無いので、必要性は無いかもしれないが...。)
require "#{File.dirname(__FILE__)}/../test_helper" class UserClickTest < ActionController::IntegrationTest ...(中略)... def test_editing_row zarigani = general_user zarigani.go_to_index zarigani.go_to_edit zarigani.insert_row_1 zarigani.copy_row_2 zarigani.delete_row_1 zarigani.update_slip check_for_slip end def general_user open_session do |user| # indexページへアクセス def user.go_to_index get slips_path assert_response :success assert_template 'index' end # 1行目の編集ページへアクセス def user.go_to_edit get edit_slip_path(slips(:one)) assert_response :success assert_template 'edit' end # 1行目で挿入 def user.insert_row_1 xml_http_request :post, insert_journals_path(:index=>1), {:journal=>{1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>1} } } assert_response :success assert_select "tbody[id^=j9] tr" do assert_select 'th', 2 assert_select 'td', 2 end assert_select 'tbody#journals_footer tr' do assert_select 'th', 3 assert_select "th input[value=1,000]", 1 end end # 2行目でコピー def user.copy_row_2 xml_http_request :post, copy_journals_path(:index=>1), {:journal=>{:j123456789=>{:comment=>nil, :yen=>nil, :index=>'j123456789', :position=>1}, 1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>2} } } assert_response :success assert_select 'tbody[id^=j9] tr' do assert_select 'th', 2 assert_select 'td', 2 assert_select "td input[value=test]", 1 assert_select "td input[value=1,000]", 1 end assert_select 'tbody#journals_footer tr' do assert_select 'th', 3 assert_select "th input[value=2,000]", 1 end end # 1行目で削除 def user.delete_row_1 xml_http_request :delete, delete_journals_path(:index=>'j123456789'), {:journal=>{:j123456789=>{:comment=>nil, :yen=>nil, :index=>'j123456789', :position=>1}, 1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>2}, :j123456790=>{:comment=>'test', :yen=>1000, :index=>'j123456790', :position=>3} } } assert_response :success assert_select 'tbody#journals_footer tr' do assert_select 'th', 3 assert_select "th input[value=2,000]", 1 end end # 「修正する」ボタンを押す def user.update_slip put slip_path(slips(:one)), {"slip"=>{"total_yen"=>"2,000", "number"=>"1", "executed_on"=>"2/14"}, "commit"=>"修正する", :journal=>{ 1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>1}, :j123456790=>{:comment=>'test', :yen=>1000, :index=>'j123456790', :position=>2} } } assert_redirected_to slip_path(assigns(:slip)) follow_redirect! assert_response :success assert_template 'show' end end end # 保存されたslipとjournalの内容を検証 def check_for_slip slip_one = Slip.find(slips(:one).id) assert_equal 2000, slip_one.total_yen assert_equal 2 , slip_one.journals.count end end
二人のユーザーが交互に操作するテスト
webアプリケーションは複数のユーザーの操作を同じようなタイミングで処理する可能性もある。上記のテスト方法を活用すれば、そのような環境もテストすることが出来る。
- ユーザーごとに条件を変えるため、伝票を操作するメソッドに引数を持たせて、ちょっとだけ汎用的に利用できるように変更した。
- 明細行を操作するメソッドについては条件を変えることに重要性を感じなかったので、そのまま引数なしで同一条件でのテストにしてしまった。
require "#{File.dirname(__FILE__)}/../test_helper" class UserClickTest < ActionController::IntegrationTest ...(中略)... # 1人のユーザーが伝票を編集するテスト def test_editing_row zarigani = general_user zarigani.go_to_index zarigani.go_to_edit(slips(:one)) zarigani.insert_row_1 zarigani.copy_row_2 zarigani.delete_row_1 zarigani.update_slip(slips(:one)) check_for_slip(slips(:one)) end # 2人のユーザーが異なる伝票を編集するテスト def test_two_users_editing_row user1 = general_user user2 = general_user user1.go_to_index user2.go_to_index user1.go_to_edit(slips(:one)) user2.go_to_edit(slips(:two)) user1.insert_row_1 user2.insert_row_1 user1.copy_row_2 user2.copy_row_2 user1.delete_row_1 user2.delete_row_1 user1.update_slip(slips(:one)) user2.update_slip(slips(:two)) check_for_slip(slips(:one)) check_for_slip(slips(:two)) end def general_user open_session do |user| # indexページへアクセス def user.go_to_index get slips_path assert_response :success assert_template 'index' end # 1行目の編集ページへアクセス def user.go_to_edit(slip) get edit_slip_path(slip) assert_response :success assert_template 'edit' end # 1行目で挿入 def user.insert_row_1 xml_http_request :post, insert_journals_path(:index=>1), {:journal=>{1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>1} } } assert_response :success @insert_index = assigns(:slip).editing_journals[0].index assert_select "tbody[id=#{@insert_index}] tr" do assert_select 'th', 2 assert_select 'td', 2 assert_select "td input[value]", 0 end assert_select 'tbody#journals_footer tr' do assert_select 'th', 3 assert_select "th input[value=1,000]", 1 end end # 2行目でコピー def user.copy_row_2 xml_http_request :post, copy_journals_path(:index=>1), {:journal=>{@insert_index=>{:comment=>'', :yen=>'', :index=>@insert_index, :position=>1}, 1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>2} } } assert_response :success @copy_index = assigns(:slip).editing_journals[2].index assert_select "tbody[id=#{@copy_index}] tr" do assert_select 'th', 2 assert_select 'td', 2 assert_select "td input[value=test]", 1 assert_select "td input[value=1,000]", 1 end assert_select 'tbody#journals_footer tr' do assert_select 'th', 3 assert_select "th input[value=2,000]", 1 end end # 1行目で削除 def user.delete_row_1 xml_http_request :delete, delete_journals_path(:index=>@insert_index), {:journal=>{@insert_index=>{:comment=>'', :yen=>'', :index=>@insert_index, :position=>1}, 1=>{:comment=>'test', :yen=>1000, :index=>1, :position=>2}, @copy_index=>{:comment=>'test', :yen=>1000, :index=>@copy_index, :position=>3} } } assert_response :success assert_select 'tbody#journals_footer tr' do assert_select 'th', 3 assert_select "th input[value=2,000]", 1 end end # 「修正する」ボタンを押す def user.update_slip(slip) journal = slip.journals[0] put slip_path(slip), {"slip"=>{"total_yen"=>slip.total_yen * 2, "number"=>slip.number, "executed_on"=>slip.executed_on}, "commit"=>"修正する", :journal=>{journal.index=>{:comment=>journal.comment, :yen=>journal.yen, :index=>journal.index, :position=>1}, @copy_index=>{:comment=>journal.comment, :yen=>journal.yen, :index=>@copy_index, :position=>2} } } assert_redirected_to slip_path(assigns(:slip)) follow_redirect! assert_response :success assert_template 'show' end end end # 保存されたslipとjournalの内容を検証 def check_for_slip(slip) slip_record = Slip.find(slip.id) assert_equal slip.total_yen * 2, slip_record.total_yen assert_equal 2, slip_record.journals.count end end
ちょっとテストに悩み過ぎたので、本日はここまで。とりあえず、テスト結果はグリーンの状態をキープしている。
*1:script/generateでモデルやコントローラー、scaffoldを指定すると、テストクラスも自動生成される。