テストの実行環境の違いを意識する

integrationテストで二人のユーザーが操作するテストを書いていた時に、また問題に気付いてしまった。現状では、二人が同時に同じ伝票を開いて、その後「修正ボタン」を押した時に、最後に「修正ボタン」を押した人の内容で保存されてしまう。これでは、最後に修正した人は、直前に伝票が別の意図によって修正されたことを知らずに、修正することになる。先に修正した人は、有無を言わさず破棄される結果になる。
このような状況は避けたいので、楽観的ロックを使って制限することにした。実装は簡単。でも、懲りずにテストを先に書いてみる。そして、またしてもハマった...。

Railsが提供する楽観的ロック

  1. フィールド名「lock_version」を追加する。(:integer, :default => 0)
  2. フォームを編集する時には、「lock_version」も取得する。
  3. フォームを送信する時には、上記で取得した「lock_version」も送信する。
  4. 保存する時に、DBのlock_versionと、送信されたlock_versionを比較して...
    • 同じであれば、lock_versionに1加算して保存、正常終了する。
    • 違っていれば、例外エラー「ActiveRecord::StaleObjectError」を発生させる。

1〜3の準備だけしておけば、4の処理はRailsの方で勝手に処理してくれる。素晴らしい!

例外を捕まえるテスト(問題あり)

上記の仕様と考えて、以下のようなテストを書いてみた。

  • ActiveRecord::StaleObjectError」が発生していれば成功。
# functionalテスト: test/functional/slips_controller_test.rb
...(中略)...
class SlipsControllerTest < ActionController::TestCase
  def test_shoould_not_update_slip_when_slip_in_lock
    assert_raise(ActiveRecord::StaleObjectError) do
      2.times do
        put :update, {:id     =>'1', 
                      :slip   =>{:number=>'1', :executed_on=>'2/14', :total_yen=>'1,000', :lock_version=>'0'}, 
                      :journal=>{"1"=>{:yen=>"1,000", :index=>"j943792544", :position=>"1", :comment=>"test"}
                                }
                     }
      end
    end
  end
...(中略)...
  • しかしこの書き方は、テストが実行される仕組みを知らない自分のみが書く、間違ったテストであった...。

実装

$ script/generate migration add_column_lock_version_to_slips
# マイグレーション: db/migrate/003_add_column_lock_version_to_slips.rb
class AddColumnLockVersionToSlips < ActiveRecord::Migration
  def self.up
    add_column :slips, :lock_version, :integer, :default => 0
  end

  def self.down
    remove_column :slips, :lock_version
  end
end
$ rake db:migrate
  • 以上の作業は、すべてdevelopment環境のDBに対する処理になるので、それをtest環境にコピーしておいた。
$ rake db:test:prepare
  • ビューに、lock_versionのhidden_fieldを追加。
<%# ビュー: app/views/slips/_form.html.erb %>

<% slip_form_for @slip do |f| %>
  <%= f.text_field :number  %>
  <%= f.text_field :executed_on  %>
  <%= f.yen_field :total_yen  %>
  <%= f.hidden_field :lock_version  %><%###### 追加した行 %>

  <%= f.error_messages_on :base %>
  <table class="journal" id="journal">
    <%= render :partial=>'journals/header' %>
    <%= render :partial=>'journals/form', :collection=>@journals %>
    <%= render :partial=>'journals/footer' %>
  </table>

  <%= f.submit button_value %>  
<% end %>
  • コントローラーは変更していない。現状は以下の状態。
# コントローラー: app/controllers/slips_controller.rb
class SlipsController < ApplicationController
...(中略)...
  def update
    @slip = Slip.find(params[:id])
    @journals = @slip.make_journals(params[:journal])
    Slip.transaction do
      @slip.update_attributes!(params[:slip])
      # 保存前にDBのjournalを一旦クリアする。journalは毎回、新規作成される。クリアしないと二重登録になってしまう。
      @slip.journals.clear
      @journals.each {|journal| journal.save! if journal.input?}
      respond_to do |format|
        flash[:notice] = _('Slip was successfully updated.')
        format.html { redirect_to(@slip) }
        format.xml  { head :ok }
      end
    end

  rescue
    @journals.each {|journal| journal.valid? if journal.input?}    
    respond_to do |format|
      format.html { render :action => "edit" }
      format.xml  { render :xml => @slip.errors, :status => :unprocessable_entity }
    end
  end
...(中略)...
  • 以上で、テストに対する楽観的ロックの実装は、完了。とりあえず成功する気がした。
  • しかし、結果は以下のように依然、失敗...。(例外は何も受け取らなかったと。)
1) Failure:
test_shoould_not_update_slip_when_slip_in_lock(SlipsControllerTest)
[./test/functional/slips_controller_test.rb:86:in `test_shoould_not_update_slip_when_slip_in_lock'
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/testing/default.rb:7:in `run']:
 exception expected but none was thrown.
  • ところが、実際にeditページを2枚開いて、マウスで修正ボタンを押すと、2回目に押した時には更新されないので、ちゃんと動いているはず。
  • この現象は当然の現象なのだが、経験不足の自分には深みにハマる原因になってしまった。いらん心配もした。
    • rescueがあると例外が捕捉されないんじゃないだろうか。
      • →rescueある、なしに関係なくチェック可能。
    • test環境サーバーの再起動が必要なのではないか。
      • →そもそも、test環境のサーバーの起動さえ不要。

updateアクションは現場で処理されている

この悩みは、put :updateが何をしているか考え直してみることで解決した。

  • この場合は、HTTPメソッドputによる、slipsコントローラーのupdateアクションの呼び出しをシミュレーションしている。
    • つまり、assert_raiseブロック内では、updateアクションの呼び出しをシミュレーションしているだけ。
    • updateアクションを実際に処理しているのは、slipsコントローラーになる。
  • だから、ActiveRecord::StaleObjectErrorは、slipsコントローラーで発生するが、assert_raiseのブロック内では発生しない。
  • よって、asset_raiseは、ブロック内で発生した例外エラーを捕捉するメソッドなので、このテストは常に失敗することになる、と理解した。

テストが現場なら

  • 以下のようにすれば、ActiveRecord::StaleObjectErrorを捕捉することも出来る。
  • テストコードの中で、Slipインスタンスを取得して、そのインスタンスに対して更新処理をしているので、その場で例外が発生する。
# unitテスト: test/unit/slip_test.rb
...(中略)...
class SlipTest < ActiveSupport::TestCase
  def test_should_not_update_when_lock_version_is_difference
    slip = slips(:one)
    slip.make_journals("j123456789"=>{:comment=>"test", :yen=>"1000"})
    assert_raise(ActiveRecord::StaleObjectError) do
      slip.update_attributes(:lock_version=>0)
      slip.update_attributes(:lock_version=>0)
    end
  end
...(中略)...
  • つまり、unitテストは常にこの方式のテストということだ。
  • functionテスト、integratinテストについては、本来、現場とテストが異なる。

unitテストをfunctional、integration環境でテストしてみる

  • 興味があったのでやってみた。すべてのunitテストをfunctionalやintegrationの環境にコピーしてみると、なんと、コピー先の環境でも問題なくてテストが実行できてしまった!テスト環境の中で、unitテストはどこに書いてもOKということだ。
  • しかし、上記の逆は許されない。(get, post, put, deleteやassert_response, assert_template, assert_redirect_toなど、コントローラーのテスト専用のメソッドが存在するため。)
  • そして、integrationテストは、functionalテストをいくつかまとめて、セッションを区別して実行するテストだと言える。おそらく、Railsのすべてのテストはintegration環境に書けると思う。(get等のメソッドの書式が異なるので、単純にコピーしただけでは無理だが。)

レスポンスとテンプレートを確認するテスト

ということで、コントローラーのテストで例外をチェックするのは諦め、レスポンスコードと利用したテンプレートをチェックする方式に変更した。

# functionalテスト: test/functional/slips_controller_test.rb
...(中略)...
class SlipsControllerTest < ActionController::TestCase
  def test_shoould_not_update_slip_when_slip_in_lock
    2.times do
      put :update, {:id     =>'1', 
                    :slip   =>{:number=>'1', :executed_on=>'2/14', :total_yen=>'1,000', :lock_version=>'0'}, 
                    :journal=>{"1"=>{:yen=>"1,000", :index=>"j943792544", :position=>"1", :comment=>"test"}
                              }
                   }
    end
    assert_response :success
    assert_template 'edit'
  end
...(中略)...

これでひとまず、テストは成功した。が、まだ続きが...。