マウスでクリックするようにテストしたい

モデルやコントローラー単体での動きが予想通りであることを確認できると、やはり最後はサーバーを起動して、マウスでクリックしながら全体の動きに問題が無いか確認したくなる。1アクションで複数モデルを同時に保存するサンプルプロジェクトでは、ある段階から、自分は以下の操作を何度も繰り返したことを思い出す。

  1. 伝票リストの一覧を開く。
  2. 伝票リストから「編集」リンクをクリック。
  3. 明細行の1行目で「挿入」リンクをクリック。
  4. 明細行の2行目で「コピー」リンクをクリック。
  5. 明細行の1行目で「削除」リックをクリック。
  6. 「修正する」ボタンを押して編集を完了する。
  7. 伝票に明細行が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を指定すると、テストクラスも自動生成される。