先日RSpec on Rails0.7(0.7.1も)が出ました。
まだまだ枯れていないので実際のプロジェクトで採用するのは難しい面もありますが、isoration from Databaseを支えるmock/stub frameworkや、isoration from viewsは強力なので、それほど影響の無い作成済みの社内アプリにRSpec on Railsを適用してみました。
maihaさんがやっておられるように、自作で作るのもすごく楽しそうなのですがとりあえずは使ってみます。mock/stub frameworkの実装の詳細をあとでみる。
BBDでは、Behaviourを書いてからコードの実装を行うのですが、今回は以前にうみがめで作った社内用情報共有ツールBasecamp(某signalsのパクリ)にBehaviourを書いていくことになります。
BasecampのSpec
rake stats
- 社内でブログをベースに情報共有を行うグループウェア
- 認証は、Ajaxによるワンタイムパスワード認証
- 3日以内で作るために、BBDもTDDをしていなかった。
- はてな記法が使える。はてな日記のインポートが可能
[4141]% rake stats [/var/www/sankhon]
(in /var/www/sankhon)
+----------------------+-------+-------+---------+---------+-----+-------+
| Name | Lines | LOC | Classes | Methods | M/C | LOC/M |
+----------------------+-------+-------+---------+---------+-----+-------+
| Helpers | 37 | 33 | 0 | 5 | 0 | 4 |
| Controllers | 620 | 480 | 9 | 54 | 6 | 6 |
| Components | 0 | 0 | 0 | 0 | 0 | 0 |
| Models | 82 | 75 | 12 | 3 | 0 | 23 |
| Libraries | 574 | 472 | 6 | 15 | 2 | 29 |
| Model specs | 16 | 13 | 0 | 0 | 0 | 0 |
| View specs | 0 | 0 | 0 | 0 | 0 | 0 |
| Controller specs | 54 | 42 | 0 | 0 | 0 | 0 |
| Helper specs | 0 | 0 | 0 | 0 | 0 | 0 |
+----------------------+-------+-------+---------+---------+-----+-------+
| Total | 1383 | 1115 | 27 | 77 | 2 | 12 |
+----------------------+-------+-------+---------+---------+-----+-------+
Code LOC: 1060 Test LOC: 55 Code to Test Ratio: 1:0.1
You have new mail.
[4146]% [/var/www/sankhon]
セットアップ
% sudo gem install rspec
% sudo gem install zentest -v 3.4.1
% sudo gem install diff-lcs
% ./script/plugin install svn://rubyforge.org/var/svn/rspec/tags/REL_0_7_0/vendor/rspec_on_rails/vendor/plugins/rspec
AccountController#loginがログインに関する実装を提供するコントローラである。
GETの場合は、ワンタイムパスワードのためのトークンを埋め込んだログイン画面を表示し、POSTの際は認証を行い成功すると200を返し失敗すると400を返す。
ログインはXMLHttpRequestで行う。
BBDではBehaviourを書いてから実装するのがセオリーだが、ここでは既にあるコードにBehaviourを書いて必要に応じてリファクタリングを施していく。
旧AccountsController#login
def login
case request.method
when :get
@challenge_code = [rand(64), rand(64)].pack("C*").tr("\x00-\x3f", "A-Za-z0-9./").crypt([rand(64), rand(64)].pack("C*").tr("\x00-\x3f", "A-Za-z0-9./"))
session[:challenge_code] = @challenge_code
when :post
if user = User.find_by_username(params[:username])
hashed_password = Digest::MD5.new(user.password+session[:challenge_code]).to_s
if params[:hashed_passwd] == hashed_password
session[:challenge_code] = nil
session[:user] = user
render :text => "true"
else
render :text => "パスワードが間違っています", :status => 401 # Unauthroized
end
else
render :text => "入力されたユーザは存在しません", :status => 401 # Unauthroized
end
end
end
当初実装していたコードでは、認証の成功/失敗に関するBehaviourを書きづらいので、認証部分のロジックをモデルに移して再実装する。始めに、モデルに実装する認証メソッドのBehaviourを定義する。
[4138]% ./script/generate spec_model user
UserモデルのBehaviourを定義する。
spec/model/user_spec.rb
require File.dirname(__FILE__) + '/../spec_helper'
require 'digest/md5'
context "User class with fixtures loaded" do
fixtures :users
specify "should count one Users" do
User.should_have(1).records
end
specify "生のパスワード+トークンをMD5ハッシュ化した文字列をワンタイムパスワードとする" do
user = users(:junkonno)
challenge_code = [rand(64), rand(64)].pack("C*").tr("\x00-\x3f", "A-Za-z0-9./").crypt([rand(64), rand(64)].pack("C*").tr("\x00-\x3f", "A-Za-z0-9./"))
user.certify(challenge_code, Digest::MD5.new(user.password+challenge_code).to_s).should_eql true
end
end
Behaviourに基づきUser#certifyを実装する。(関連部分のみ)
app/model/user.rb
require 'digest/md5'
class User < ActiveRecord::Base
def certify(code, hashed_passwd)
hashed_passwd == Digest::MD5.new(self.password+code).to_s
end
end
fixturesを定義し検証を行う。
spec/controller/users.yml
# Read about fixtures at http://ar.rubyonrails.org/classes/Fixtures.html
junkonno:
id: 1
username: 'junkonno'
password: 'junkonno'
User#certifyを検証する。
[4138]% ./script/rails_spec spec/models/user_spec.rb [/var/www/sankhon]
..
Finished in 0.102308 seconds
2 specifications, 0 failures
[4141]% [/var/www/sankhon]
検証クリア。
User#certifyを使用して認証を行うようにAccountsController#loginを書き直す。
app/controller/accounts_controller.rb
when :post
user = nil
if (user = User.find_by_username(params[:username])) && user.certify(session[:challenge_code], params[:hashed_password])
session[:user] = user.dup
session[:challenge_code] = nil
render :text => "true"
else
render :text => "ユーザが存在しないかパスワードが間違っています", :status => 401 # Unauthroized
end
end
書き直したAccountsControllerに関するBeahviourを定義する。
spec/controller/accounts_controller_spec.rb
RSpec on Rails0.7の強力な機能である、mock/stub frameworkを使用する。
require File.dirname(__FILE__) + '/../spec_helper'
context "The AccountsController" do
# fixtures :accounts
controller_name :accounts
setup do
@user = mock('user')
@user.stub!(:new_record?).and_return(false)
User.stub!(:new).and_return(@user)
end
specify "should be a AccountsController" do
controller.should_be_an_instance_of AccountsController
end
specify "/accounts/loginへGETした際は、チャレンジコードを設定する" do
get 'login'
assigns[:challenge_code].should_match /[\w\d]+/
end
specify "/accounts/logoutへPOSTした際は、認証を行い成功したらtrueを出力し200を返す" do
User.should_receive(:find_by_username).with('junkonno').and_return(@user)
@user.should_receive(:certify).and_return(true)
controller.should_render :text => "true"
post 'login', :username => 'junkonno'
end
specify "/accounts/logoutへPOSTした際は、認証を行い失敗したら401を返す" do
User.should_receive(:find_by_username).with('junkonno').and_return(@user)
@user.should_receive(:certify).and_return(false)
controller.should_render :status => 401, :text => "ユーザが存在しないかパスワードが間違っています"
post 'login', :username => 'junkonno'
end
end
Mock/stubを使用することで、モデルとDBを切り離しBehaviourを書くことが出来る。
次のコードは、User#newでインスタンスが作成される際に、実際のインスタンスではなくSpec::Mocks::Mockクラスのインスタンスを返すようにスタブを定義する。コントローラ内でUser.newが実行された場合は、Mockオブジェクトのインスタンスである@userが返される。
setupのブロック内でスタブを定義し、specifyのブロック内でshould_receiveによってスタブメソッドを再定義するのが推奨されているやり方です。二つのメソッドは期待される結果は同じだが、stub!が検証(例外とか)されないのに対して、should_receiveは検証が行われる。
setup do
@user = mock('user')
@user.stub!(:new_record?).and_return(false)
User.stub!(:new).and_return(@user)
end
Behaviourは、プログラマでなくとも普通の英文として読むことができます。美しい。
assingsオブジェクトでは、コントローラ内で設定されたクラス変数を確認することが出来る。
'junkonno'が引数に与えられて、User.find_by_usernameが呼び出されたときには、@userを返すようにする。@user.certifyが呼びださされた際には、trueを返し認証が成功した際のBehaviourを検証する。
specify "/accounts/loginへGETした際は、チャレンジコードを設定する" do
get 'login'
assigns[:challenge_code].should_match /[\w\d]+/
end
User.should_receive(:find_by_username).with('junkonno').and_return(@user)
@user.should_receive(:certify).and_return(true)
検証を行う。
[ERROR:1]% ./script/rails_spec spec/controllers/accounts_controller_spec.rb [/var/www/sankhon]
....
Finished in 0.233141 seconds
4 specifications, 0 failures
[4148]% [/var/www/sankhon]
まとめ
今回は、ログインの実装に関するBehaviour(コントローラとモデル)を定義してリファクタリングを行った。
BBDを進める際は、ロジックを可能な限りモデルに定義しBehaviourを書いていくほうがよい。既存のソースに適用することでソースコードが綺麗になる。
Rspecのサイトにも書かれているように、RSpec0.7以上ではControllerとViewsのBehaviourを完全に分離して定義できます。これによりViewによりControllerのBahaviourが失敗するといったことがなくなります。そして、Views単体のBBDはselenium等のほかのフレームワークを利用して進める方法が推奨されています。
では、ふるたにでした。
参考
Mock Objects
Mock API
RSpec on Rails – Specifying Controllers
RSpec on Rails – Specifying Models