l'essentiel est invisible pour les yeux

Saturday, November 11, 2006

[rails] 実践RSpec on Rails - コントローラとモデルのBehaviourを書く

先日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

  • 社内でブログをベースに情報共有を行うグループウェア
  • 認証は、Ajaxによるワンタイムパスワード認証
  • 3日以内で作るために、BBDもTDDをしていなかった。
  • はてな記法が使える。はてな日記のインポートが可能
rake stats

[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

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
RSpec on Rails0.7の強力な機能である、mock/stub frameworkを使用する。
Mock/stubを使用することで、モデルとDBを切り離しBehaviourを書くことが出来る。

次のコードは、User#newでインスタンスが作成される際に、実際のインスタンスではなくSpec::Mocks::Mockクラスのインスタンスを返すようにスタブを定義する。コントローラ内でUser.newが実行された場合は、Mockオブジェクトのインスタンスである@userが返される。

setup do
@user = mock('user')
@user.stub!(:new_record?).and_return(false)
User.stub!(:new).and_return(@user)
end
setupのブロック内でスタブを定義し、specifyのブロック内でshould_receiveによってスタブメソッドを再定義するのが推奨されているやり方です。二つのメソッドは期待される結果は同じだが、stub!が検証(例外とか)されないのに対して、should_receiveは検証が行われる。
Behaviourは、プログラマでなくとも普通の英文として読むことができます。美しい。

assingsオブジェクトでは、コントローラ内で設定されたクラス変数を確認することが出来る。

specify "/accounts/loginへGETした際は、チャレンジコードを設定する" do
get 'login'
assigns[:challenge_code].should_match /[\w\d]+/
end
'junkonno'が引数に与えられて、User.find_by_usernameが呼び出されたときには、@userを返すようにする。@user.certifyが呼びださされた際には、trueを返し認証が成功した際のBehaviourを検証する。

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を書いていくほうがよい。既存のソースに適用することでソースコードが綺麗になる。
次回は、controllerとviewsの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