#276 Testing Time & Web Requests
- Download:
- source codeProject Files in Zip (112 KB)
- mp4Full Size H.264 Video (16.4 MB)
- m4vSmaller H.264 Video (9.81 MB)
- webmFull Size VP8 Video (10.8 MB)
- ogvFull Size Theora Video (26 MB)
前回のエピソードでは、テスト駆動開発の作業の流れを実際に見ていきました。ほとんどの部分については紹介したパターンでうまくいきますが、アプリケーションの一部にテストを行うのが難しい機能がある場合もあります。今回のエピソードでは、そのようなケースを2つ紹介します。
現在の時刻をテストする
前回はUser
モデルをテストするspecをいくつか作成しました。これらを早足で書いたので、そのひとつについて今度はよく中身を見ていきます。
it "saves the time the password reset was sent" do user.send_password_reset user.reload.password_reset_sent_at.should be_present end
このspecは、パスワードリセット用のEメールが送信されたときに、その時刻がpassword_reset_sent_at
フィールドに保存されることをチェックします。これはRSpecのbe_present
matcherを用いてテストされます。このmatcherは、オブジェクトが存在することをチェックする、Railsが提供するpresent?
というメソッドを呼び出します。
このspecは完全ではありません。password_reset_sent_at
の値が存在することはチェックしますが、その値が現在の時刻かどうかはチェックしません。User
モデルのsend_password_reset
メソッドでpassword_reset_sent_at
にTime.zone.now
を設定していますが、ここにどのような値を設定したとしてもspecは成功します。理想的には次のようなコードを書いて、値が現在の時刻かどうかをテストするべきでしょう。
it "saves the time the password reset was sent" do user.send_password_reset user.reload.password_reset_sent_at.should eq(Time.zone.now) end
しかし残念ながらこれはうまくいきません。テストが実行された時点での現在の時刻は、テスト対象のコードが実行された時刻の少し後だからです。このような問題に直面した場合には、テストを完全に行うためにそこまでテストを複雑にする価値があるかどうかを自問してみるのがいいでしょう。多くの場合、今回もそうですが、タイムスタンプの値が存在することをテストするだけで十分でしょう。ここで時刻を設定する1行のコードにバグがあるという可能性はほとんどないでしょう。しかしときには現在の時刻をテストしなくてはいけない場合もあるかも知れません。そこでspecでそれを行う方法を調べてみることにしましょう。
Guardが実行中で、specが失敗しているのがわかります。タイムスタンプは秒単位では同じなのですが、まったく同じ値ではないため当然specは失敗します。
Failures: 1) User#send_password_reset saves the time the password reset was sent Failure/Error: user.reload.password_reset_sent_at.should eq(Time.zone.now) expected Mon, 25 Jul 2011 20:34:46 UTC +00:00 got Mon, 25 Jul 2011 20:34:46 UTC +00:00 (compared using ==) Diff: # ./spec/models/user_spec.rb:16:in `block (3 levels) in <top (required)>' Finished in 1.95 seconds 9 examples, 1 failure
Timecopというgemを使えば、このような問題に対応することができます。このgemを使えば、現在の時刻をいろいろな方法で操作でき、時刻を凍結することもできます。つまりspecを実行している間現在の時刻を凍結することができるので、タイムスタンプが設定されるときの時刻が値をチェックするときの時刻と同じになります。
Timecopをアプリケーションに追加するためにGemfile
に追記し、bundle
を実行します。必要なのはテスト時だけなので、test
groupに追加します。
source 'http://rubygems.org' gem 'rails', '3.1.0.rc4' gem 'sqlite3' # Asset template engines gem 'sass-rails', "~> 3.1.0.rc" gem 'coffee-script' gem 'uglifier' gem 'jquery-rails' gem "rspec-rails", :group => [:test, :development] group :test do gem "factory_girl_rails" gem "capybara" gem "guard-rspec" gem "timecop" end
ここでspec_helper
ファイルのconfig.before(:each)
ブロックにTimecop.return
の呼び出しを追加しておきましょう。これによって、Timecopで加えたすべての修正は、各specの実行前に取り消されます。
これでどのspecからでもTimecop.freeze
を呼び出して、specが実行される間現在の時刻を凍結できるようになりました。つまり2つのタイムスタンプの比較が可能になったということです。
it "saves the time the password reset was sent" do Timecop.freeze user.send_password_reset user.reload.password_reset_sent_at.should eq(Time.zone.now) end
Guardが実行されると、specはすべて成功します。
もうひとつ、予期せずテストが失敗するケースを紹介します。別のタイムゾーンで開催されているRubyカンファレンスに参加していて、時間に関連するテストが突然失敗することに気づきました。そのようなときは地球を半周するよりもspecでタイムゾーンを設定する方がずっと安上がりでしょう。Time.zone
を設定してこれをおこないます。
Time.zone = "Paris"
ひとつのspec内で一時的にタイムゾーンを設定する場合は、Time.use_zone
を呼び出します。
it "saves the time the password reset was sent" do Timecop.freeze user.send_password_reset Time.use_zone("Paris") do user.reload.password_reset_sent_at.should eq(Time.zone.now) end end
ブロック内のコードは、パリにいるものとして実行されます。これを用いれば、違うタイムゾーンにいたとしてもspecは変わらずに成功します。
現在の時刻を扱うテストを行う場合は常にTimecopのようなツールを使って、タイムゾーンが変わったり夏時間の季節でも時間を変わりなく正しく処理できるようにするべきでしょう。また違うタイムゾーンでコードをテストして、アプリケーションが世界のどこでも正しく動作することを確認できるといいでしょう。
外部Webリクエストをテストする
次は外部Webリクエストをテストする方法を見ていきます。このセクションは、Ryan BatesがRailscastsサイトを書き直した際、複数あるビデオフォーマットのダウンロードリンクの上にカーソルを置いたときにファイルサイズを表示する機能を書いているときに経験した問題に基づいています。
動画ファイルは別のサーバでホストされているため、ファイルサイズは外部Webサーバから取得されます。つまり外部に向けてWebリクエストが発行されるということで、これをどうテストしたかを紹介します。小さなサンプルアプリケーションを用いてこの問題を見ていきます。このアプリケーションはWebRequestリソースと、URLを保持するテキストフィールドを含むフォームを持っています。
ビデオのURLをテキストボックスに入力してフォームを送信すると、URLとともに0バイトというファイルサイズが表示されます。
ファイルサイズが0になるのは、この機能をまだ実装していないためです。WebRequest
モデルにcontent_length
メソッドがあるのですが、これが0
を返すように決め打ちされています。このメソッドをTDDによって実装していきます。
class WebRequest < ActiveRecord::Base def content_length 0 end end
外部webリクエストをテストするのに役立つgemはいくつかありますが、今回はFakewebを使用します。このgemは、URIを登録してどのようなレスポンスを返すべきかを定義して使用します。そのURIを取得するためにNet::HTTP
を使用すると、外部リクエストを行う代わりに先ほど定義したレスポンスを返します。
Fakewebは通常の方法でインストールできるので、Gemfile
に追記後にbundle
を実行します。次にFakewebの設定を追加するために、spec_helper
ファイルを2ヶ所修正します。
ENV["RAILS_ENV"] ||= 'test' require File.expand_path("../../config/environment", __FILE__) require 'rspec/rails' require 'capybara/rspec' Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f} FakeWeb.allow_net_connect = false RSpec.configure do |config| config.mock_with :rspec config.use_transactional_fixtures = true config.include(MailerMacros) config.before(:each) do Timecop.return reset_email FakeWeb.clean_registry end end
ファイルの一番上近くでFakeweb.allow_net_connect
をfalse
に設定してspecが外部にHTTP接続を行わないようにします。これが便利なのは、spec中に外部リクエストを残していた場合に、テストの全体の動きを遅くしてしまうことなく、specが外部に接続しようとしていることをFakewebが教えてくれるからです。またbefore(:each)
の中でFakeweb.clean_registry
を呼び出して、各specが同じ状態で開始されるようにしています。
WebRequest
のspecでは、content lengthが取得されることをテストするspecを書きます。
require 'spec_helper' describe WebRequest do it "fetches the content length" do FakeWeb.register_uri(:head, "http://example.com", :content_length => 123) WebRequest.new(:url => "http://example.com").content_length.should eq(123) end end
ここでFakeWeb.register_uri
を呼び出して、偽のURLを登録します。このメソッドが取る最初の引数は、発行したいリクエストのタイプです。ヘッダ情報からファイルのサイズを取得できるので、ここで:head
を使用します。その他の引数は、URLとその他の取得したいヘッダ情報で、今回はContent Lengthだけです。次にこのURLを呼び出すWebRequest
オブジェクトを新規作成し、content_length
メソッドから返される値がヘッダで設定された値と同じかどうかをチェックします。
このspecを実行すると、content_length
メソッドが常に0
を返すため当然失敗します。specが成功するようにするためには、このメソッドを編集して、リクエストしているファイルの実際のcontent lengthの値を返すように修正する必要があります。
class WebRequest < ActiveRecord::Base def content_length uri = URI.parse(url) response = Net::HTTP.start(uri.host, uri.port) { |http| http.request_head(uri.path) } response["content-length"].to_i end end
これでメソッドが、モデルに渡されたURLを使ってNet::HTTP.start
を呼び出すようになります。メソッドが取るブロックが、レスポンスヘッダを取得するためにrequest_head
を呼び出します。最後にcontent-length
ヘッダの値が返されます。
RailsにはデフォルトではNet::HTTP
が含まれないため、アプリケーション内でrequire
する必要があります。これをapplication.rb
ファイルに記述します。
require File.expand_path('../boot', __FILE__) require 'net/http' require 'rails/all' # rest of file
これですべてのspecが成功し、Webリクエストを行っているページを再読み込みすると正しいファイルサイズが表示されます。
テストで外部Webリクエストを処理する必要がある場合は、Fakewebが優れた解決策となるでしょう。