ユーザー認証のテスト(4) -- YAGNI

2013/09/04

turtle

前回は、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行目の createFactoryGirl.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 の実装はまだまだ続きます。次回は、パスワードが一致しない場合について。

では、また。