ユーザー認証のテスト(4) -- YAGNI
2013/09/04
前回は、RSpec のスタブ機能を利用して Customer.authenticate
メソッドの実装を後回しにしつつ、ユーザー認証が成功するするシナリオについてテストを通しました。
今回は、Customer.authenticate
メソッド自体の実装に着手します。
FactoryGirl::Syntax::Methods
モジュール
本題に入る前に、テストコードの分量を減らす方法を紹介しておきます。
現在、各所で FactoryGirl.build
あるいは FactoryGirl.create
というメソッド呼び出しが記述されています。今後も使用する回数は増えるばかりです。
そこで、spec/spec_helper.rb
を次のように修正します:
# (省略) RSpec.configure do |config| # (省略) config.expect_with :rspec do |c| c.syntax = :expect end config.include FactoryGirl::Syntax::Methods end
10行目で FactoryGirl::Syntax::Methods
モジュールをテストコードにインクルードする設定をしています。
こうすると FactoryGirl.build
および FactoryGirl.create
の代わりに build
あるいは create
と書けるようになります。
つまり、spec/features/login_and_logout_spec.rb
は
Customer.stub(:authenticate).and_return(FactoryGirl.create(:customer))
と書く代わりに、
Customer.stub(:authenticate).and_return(create(:customer))
と短く書けるわけです。
エグザンプルグループの階層化
さて、現在の spec/models/customer_spec.rb
のコードは次のような構造を持っています。
require 'spec_helper' describe Customer do specify '...' do # ... end specify '...' do # ... end # ... end
すでに説明したように、specify
メソッドに導かれるブロックがエグザンプルで、それらを囲む describe
ブロックがエグザンプルグループです。
エグザンプルグループは次のように入れ子にすることが可能です。
require 'spec_helper' describe Customer do describe 'A' do specify '...' do # ... end specify '...' do # ... end # ... end describe 'B' do specify '...' do # ... end specify '...' do # ... end # ... end end
こうすると Customer
クラスの仕様を示すためのエグザンプルを「A」と「B」に分類できます。
あるいは、次のようにも書けます。
require 'spec_helper' describe Customer, 'A' do specify '...' do # ... end specify '...' do # ... end # ... end describe Customer, 'B' do specify '...' do # ... end specify '...' do # ... end # ... end
筆者は主に後者の書き方を採用しています。エグザンプルの数が増えてくると、前者の書き方ではエグザンプルグループの構造を把握しにくくなるからです。
クラスメソッド Customer.authenticate
のテスト
spec/models/customer_spec.rb
を次のように書き換えてください:
require 'spec_helper' describe Customer, 'バリデーション' do # (省略) end describe Customer, '.authenticate' do specify 'ユーザー名とパスワードに該当するCustomerオブジェクトを返す' do # ... end end
新たにクラスメソッド Customer.authenticate
の仕様を記述するためのエグザンプルグループを作成し、最初のエグザンプルのひな形を書いたところです。# ...
の部分にはどんなテストを書けばいいでしょうか。ちょっと考えてみてください。
すでに sessions#create
アクションの中でこのクラスメソッドが使用されています。
Customer.authenticate(params[:username], params[:password])
この使用例がテストを書く際の参考になります。
まあ、こんな感じでしょうか:
describe Customer, '.authenticate' do let(:customer) { create(:customer, username: 'taro', password: 'correct_password') } specify 'ユーザー名とパスワードに該当するCustomerオブジェクトを返す' do result = Customer.authenticate(customer.username, 'correct_password') expect(result).to eq(customer) end end
2行目の create
は FactoryGirl.create
と同じです。'taro'
というユーザー名と 'correct_password'
というパスワードを持つ顧客のモデルオブジェクトを作成し、それをヘルパーメソッド customer
で参照できるようにしています。
5行目では、クラスメソッド Customer.authenticate
を実際に呼んで、その戻り値をローカル変数 result
にセットしています。そして、ローカル変数 result
が指すオブジェクトと ヘルパーメソッド customer
が返すオブジェクトが同一であることを確認しています。
テストを実行してみると、結果は次の通り:
Failures: 1) Customer.authenticate ユーザー名とパスワードに該当するCustomerオブジェクトを返す Failure/Error: let(:customer) { create(:customer, username: 'taro', password: 'correct_password') } NoMethodError: undefined method `username=' for #<Customer:0xb9b250fc> # ./spec/models/customer_spec.rb:68:in `block (2 levels) in <top (required)>' # ./spec/models/customer_spec.rb:71:in `block (2 levels) in <top (required)>'
username=
メソッドが未定義だと文句を言っています。想定通りです。次に進みましょう。
username
カラムの追加
エラーを解消するため customers
テーブルに username
カラムを追加します。db/migrate/..._create_customers.rb
を次のように修正してください(4行目を追加)。
class CreateCustomers < ActiveRecord::Migration def change create_table :customers do |t| t.string :username, null: false t.string :family_name, null: false t.string :given_name, null: false t.string :family_name_kana, null: false t.string :given_name_kana, null: false t.timestamps end end end
そして、マイグレーションをやり直します。
$ rake db:migrate:reset $ rake db:test:prepare
テストの結果はこう変わります:
Failures: 1) Customer.authenticate ユーザー名とパスワードに該当するCustomerオブジェクトを返す Failure/Error: let(:customer) { create(:customer, username: 'taro', password: 'correct_password') } NoMethodError: undefined method `password=' for #<Customer:0xba0cb1f0> # ./spec/models/customer_spec.rb:68:in `block (2 levels) in <top (required)>' # ./spec/models/customer_spec.rb:71:in `block (2 levels) in <top (required)>'
今度は password=
メソッドが未定義だそうです。
password
属性の定義
平文のパスワードをデータベースに記録するのは良い習慣ではありませんので、一時的にパスワードを記憶しておくための password
属性を Customer
クラスに定義します。データベースには何らかの方法でパスワードを変換した値を格納することになりますが、今はそのことについて考えません。
app/models/customer.rb
を次のように修正してください(4行目を追加)。
require 'nkf' class Customer < ActiveRecord::Base attr_accessor :password validates :family_name, :given_name, :family_name_kana, :given_name_kana, presence: true, length: { maximum: 40 } # (省略) end
そして、テストの結果:
Failures: 1) Customer.authenticate ユーザー名とパスワードに該当するCustomerオブジェクトを返す Failure/Error: result = Customer.authenticate(customer.username, 'correct_password') NoMethodError: undefined method `authenticate' for #<Class:0xb90a585c> # ./spec/models/customer_spec.rb:71:in `block (2 levels) in <top (required)>'
ようやくテスト対象のメソッドにたどり着きました。
クラスメソッド Customer.authenticate
の仮実装
いよいよクラスメソッド Customer.authenticate
の実装に入りますが、ここで大切なのはあまり先回りして考えずに、とにかく目の前にあるエラーを解消することだけに集中することです。
app/models/customer.rb
を次のように修正してください(20-24行を追加)。
require 'nkf' class Customer < ActiveRecord::Base attr_accessor :password validates :family_name, :given_name, :family_name_kana, :given_name_kana, presence: true, length: { maximum: 40 } validates :family_name, :given_name, format: { with: /\A[\p{Han}\p{Hiragana}\p{Katakana}]+\z/, allow_blank: true } validates :family_name_kana, :given_name_kana, format: { with: /\A\p{Katakana}+\z/, allow_blank: true } before_validation do self.family_name = NKF.nkf('-w', family_name) if family_name self.given_name = NKF.nkf('-w', given_name) if given_name self.family_name_kana = NKF.nkf('-wh2', family_name_kana) if family_name_kana self.given_name_kana = NKF.nkf('-wh2', given_name_kana) if given_name_kana end class << self def authenticate(username, password) find_by_username(username) end end end
拍子抜けするほど単純な実装です。より正確に言えば、まったく不十分な実装です。ユーザー認証になっていません。でも、これでいいのです。愚直に一歩一歩進んでください。
Factory Girl の修正
テスト spec/models/customer_spec.rb
は通りました。しかし、spec
ディレクトリ全体についてテストを実行すると、次のような失敗が報告されます。
Failures: 1) ログイン ユーザー認証成功 Failure/Error: Customer.stub(:authenticate).and_return(create(:customer)) ActiveRecord::StatementInvalid: Mysql2::Error: Field 'username' doesn't have a default value: INSERT INTO `customers` (省略) # ./spec/features/login_and_logout_spec.rb:5:in `block (2 levels) in <top (required)>'
データベーススキーマが変更されたので、Factory Girl の定義も変更が必要になったのです。
spec/factories/customers.rb
を次のように修正してください(3行目を挿入)。
FactoryGirl.define do factory :customer do username 'taro' family_name '山田' given_name '太郎' family_name_kana 'ヤマダ' given_name_kana 'タロウ' end end
これで、すべてのエグザンプルが成功します。
YAGNI -- 作りすぎないことが大事
テスト駆動開発の原則を分かりやすく示すため、「テストを書き、テストを通すための最小限の実装を行う」という短いサイクルを非常に丁寧に繰り返しました。
この短いサイクルの繰り返しがテスト駆動開発の肝です。
通常はここまで丁寧にやらなくても構いません。しかし、ソフトウェア開発で困難に直面した時、こういう丁寧なやり方が役に立ちます。急がば回れです。
「YAGNI」という言葉をご存じでしょうか。"You ain't gonna need it" の省略形です。ソフトウェア開発ではあとで使うだろうと思って作ったものの大半は無駄になる、という経験則を表した言葉です。それは無駄になるばかりではなく、ソースコードを乱雑にし、ソフトウェアの保守性を低下させます。それはあなたが作ったものの価値を減らすのです。
テスト駆動の短い開発サイクルを繰り返すことで、作りすぎの弊害を避けることができます。
次回は
クラスメソッド Customer.authenticate
の実装はまだまだ続きます。次回は、パスワードが一致しない場合について。
では、また。