ユーザー認証のテスト(1) -- Outside-In

2013/08/31

signing_in

今回からしばらくの間、ユーザー認証機能をテスト駆動開発方式で実装していきます。いわゆる「ログイン・ログアウト機能」です。

単にユーザーを認証するだけでは単純すぎるので、ユーザーに「ログインポイント」を付与することにします。すなわち、我々のサンプルアプリケーション Sinope に「ポイントをためる」という概念を導入し、ユーザーがログインしたらユーザーのポイントが増えるという仕様を追加するのです。

どうです、面白そうでしょ!

宿題の答え合わせ

さて、前回のモデルテストの演習では、読者の皆さんに宿題を提示しました。その答え合わせをしましょう。

1問目は、「姓フリガナと名フリガナはカタカナのみ」のテストおよびバリデーションの実装を行うことでした。そして、2問目はいわゆる半角カナは全角カナに自動変換する、という仕様のテストと実装です。以下、最終的な結果のみを示します。

テスト spec/models/customer_spec.rb のコードは次のようになります:

require 'spec_helper'

describe Customer do
  let(:customer) { FactoryGirl.build(:customer) }

  specify '妥当なオブジェクト' do
    expect(customer).to be_valid
  end

  %w{family_name given_name family_name_kana given_name_kana}.each do |column_name|
    specify "#{column_name} は空であってはならない" do
      customer[column_name] = ''
      expect(customer).not_to be_valid
      expect(customer.errors[column_name]).to be_present
    end

    specify "#{column_name} は40文字以内" do
      customer[column_name] = 'ア' * 41
      expect(customer).not_to be_valid
      expect(customer.errors[column_name]).to be_present
    end

    specify "#{column_name} に含まれる半角カナは全角カナに変換して受け入れる" do
      customer[column_name] = 'アイウ'
      expect(customer).to be_valid
      expect(customer[column_name]).to eq('アイウ')
    end
  end

  %w{family_name given_name}.each do |column_name|
    specify "#{column_name} は漢字、ひらなが、カタカナを含んでもよい" do
      customer[column_name] = '亜あア'
      expect(customer).to be_valid
    end

    specify "#{column_name} は漢字、ひらなが、カタカナ以外の文字を含まない" do
      ['A', '1', '@'].each do |value|
        customer[column_name] = value
        expect(customer).not_to be_valid
        expect(customer.errors[column_name]).to be_present
      end
    end
  end

  %w{family_name_kana given_name_kana}.each do |column_name|
    specify "#{column_name} はカタカナを含んでもよい" do
      customer[column_name] = 'アイウ'
      expect(customer).to be_valid
    end

    specify "#{column_name} はカタカナ以外の文字を含まない" do
      ['亜', 'A', '1', '@'].each do |value|
        customer[column_name] = value
        expect(customer).not_to be_valid
        expect(customer.errors[column_name]).to be_present
      end
    end

    specify "#{column_name} に含まれるひらがなはカタカナに変換して受け入れる" do
      customer[column_name] = 'あいう'
      expect(customer).to be_valid
      expect(customer[column_name]).to eq('アイウ')
    end
  end
end

そして、app/models/customer.rb のコードは次の通り:

require 'nkf'

class Customer < ActiveRecord::Base
  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
end

NKF はデフォルトでいわゆる半角カナを全角カナに変換するので、nkf メソッドに与えるオプションは出力文字コードを示す -w のみで構いません。

この文章を発表した後に、バリデーションコードの不備に気付きました。ページ末尾の「補遺」をご覧ください。

ユーザー認証機能の仕様

冒頭で説明したように、我々はサンプル Rails アプリケーション Sinope に「ポイントをためる」という概念を導入し、ユーザーがログインした時に「ログインポイント」を付与することにします。

詳細な仕様は以下の通りです:

  • ログイン前のユーザーがトップページのURLにアクセスすると、通常のトップページの代わりにログインフォームが表示される。
  • ユーザーがログインフォームに正しいユーザー名(username)とパスワード(password)を入力して「ログイン」ボタンをクリックすると、通常のトップページが表示される。
  • ログインに失敗した場合は、「ユーザー名またはパスワードが正しくありません。」というメッセージとログインフォームが表示される。
  • ユーザーがトップページの「ログアウト」リンクをクリックすると、「本当にログアウトしますか?」という警告ダイアログが表示され、「OK」ボタンをクリックすると、「ログアウトしました」というメッセージとログインフォームが表示される。
  • ユーザーがログインに成功すると、「ログインポイント」としてユーザーに1ポイントが与えられる。
  • ただし、「ログインポイント」はユーザーごとに1日1回しか与えられない。日の区切りは日本時間午前5時とする。
  • また、土曜日にログインすると、通常の「ログインポイント」の他に「土曜ログインボーナス」として2ポイントが与えられる。

かなり複雑な仕様です。こういう機能をテストの助けを借りずに実装しようと試みるのは無謀です。

どこから着手するか

上記のような複雑な仕様を示されると、初心者の方はどこから手を付けて良いのかわからなくなります。

データベースのスキーマ設計から始めるのか、ユーザーを認証するメソッドを実装するのか、ログインフォームのHTMLとCSSを組んでみるのか、あるいはAPIを固めるのか…

ビヘイビア駆動開発(BDD)の原則の一つに Outside-In という考え方があります。外側(ユーザーインターフェースなど)から内側(ビジネスロジックやデータ構造)に向かって実装を進めるべし、というものです。なぜ Outside-In の原則に従うとよいのか、そもそも従うべきか、という疑問はいったん忘れることにしましょう。まずは試してみるのが一番です。

早速、ユーザーインターフェースから実装開始。もちろん、実装の前にテストを書きます。

ログイン機能のテスト

新規ファイル spec/features/login_and_logout_spec.rb を次のような内容で作成してください。

require 'spec_helper'

describe 'ログイン' do
  specify 'ユーザー認証成功' do
    visit root_path
    within('form#new_session') do
      fill_in 'username', with: 'taro'
      fill_in 'password', with: 'correct_password'
      click_button 'ログイン'
    end
    expect(page).not_to have_css('form#new_session')
  end

  specify 'ユーザー認証失敗' do
    visit root_path
    within('form#new_session') do
      fill_in 'username', with: 'taro'
      fill_in 'password', with: 'wrong_password'
      click_button 'ログイン'
    end
    expect(page).to have_css('p.alert', text: 'ユーザー名またはパスワードが正しくありません。')
    expect(page).to have_css('form#new_session')
  end
end

2つのエグザンプルがあり、第1のエグザンプルで「ログイン成功」のシナリオ、第2のエグザンプルで「ログイン失敗」のシナリオを記述しています。それぞれの1行目にある visit root_path は、すでにRSpec/Capybara -- はじめの一歩で説明しました。「URL パス / にアクセスする」という意味です。

within(...) は、「... の内側で」という意味です。引数の form#new_sessionnew_session という id 属性を持つ form 要素を指す CSS セレクタです。続く do ... end ブロック内のメソッドは、HTML 文書の該当部分だけが対象となります。

fill_in は入力フォームのテキストフィールドまたはパスワードフィールドに値を入力するメソッドです。第1引数にはフィールドの名前を指定し、with オプションに入力する文字列を指定します。ここでは username という名前を持つテキストフィールドに「taro」という文字列を入力し、パスワードフィールドには「correct_password」および「wrong_password」という文字列を入力しています。

click_button は、文字通りボタンをクリックするメソッドです。ボタンの上に描かれている文字列(画像ボタンの場合は alt 属性に指定されている文字列)を引数に指定します。ここでいう「ボタン」には、以下の HTML 要素が含まれます:

  • 送信ボタン(input[type="submit"]
  • リセットボタン(input[type="reset"]
  • 画像ボタン(input[type="image"]
  • 汎用ボタン(input[type="button"]
  • button 要素

11行目の expect(page).not_to have_css('form#new_session') は、ログインフォームが表示されていないということを確認しています。ユーザー認証に成功した直後に表示されるページにはログインフォームが表示されていないはずです。

21-22行は、ログイン失敗時には「ユーザー名またはパスワードが正しくありません。」という警告メッセージとともにログインフォームが再度表示されることを確認しています。

テストを正しく失敗させる

テストを書いたら、アプリケーション本体のソースコードには手を付けずにテストを実行します。ここで大事なことは、テストが正しく失敗することです。シンタックスエラーでテストが動かなかったり、テストが通ってしまったりしてはダメです。テストのコードをじっくり見直しましょう。

初回のテスト結果は次のように出るはずです:

Failures:

  1) ログイン ユーザー認証成功
     Failure/Error: within('form#new_session') do
     Capybara::ElementNotFound:
       Unable to find css "form#new_session"
     # ./spec/features/login_and_logout_spec.rb:6:in `block (2 levels) in <top (required)>'

  2) ログイン ユーザー認証失敗
     Failure/Error: within('form#new_session') do
     Capybara::ElementNotFound:
       Unable to find css "form#new_session"
     # ./spec/features/login_and_logout_spec.rb:16:in `block (2 levels) in <top (required)>'

まだログインフォームのための HTML テンプレートを書いていないので、これらが「正しい失敗」です。

読者の皆さんのターミナルでは、テスト失敗の報告順が逆(「ログイン ユーザー認証失敗」が先)になっているかもしれません。RSpec はエグザンプルをランダムな順序で実行するので、報告順は毎回変わります。

ログインフォームを用意する

次に行うことは、失敗の解消です。今は form#new_session という CSS セレクタに合致する要素がない、と言われています。これを用意しましょう。

まず app/views/top/index.html.erb を次のように修正します。

<h1>Top#index</h1>
<p>Hello World!</p>

<%= render 'login_form' %>

そして、新規ファイル app/views/top/_login_form.html.erb を次のような内容で作成してください。

<%= form_tag '', id: 'new_session' do %>
  <div>
    <%= label_tag 'username', 'ユーザー名' %>
    <%= text_field_tag 'username' %>
  </div>
  <div>
    <%= label_tag 'password', 'パスワード' %>
    <%= password_field_tag 'password' %>
  </div>
  <div>
    <%= submit_tag 'ログイン' %>
  </div>
<% end %>

本来 form_tag の第1引数にはフォームデータの送信先 URL を指定しますが、とりあえず空文字を指定しておきます。

この段階でテスト結果は次のようになります:

Failures:

  1) ログイン ユーザー認証成功
     Failure/Error: click_button 'ログイン'
     ActionController::RoutingError:
       No route matches [POST] "/"
     # ./spec/features/login_and_logout_spec.rb:9:in `block (3 levels) in <top (required)>'
     # ./spec/features/login_and_logout_spec.rb:6:in `block (2 levels) in <top (required)>'

  2) ログイン ユーザー認証失敗
     Failure/Error: click_button 'ログイン'
     ActionController::RoutingError:
       No route matches [POST] "/"
     # ./spec/features/login_and_logout_spec.rb:19:in `block (3 levels) in <top (required)>'
     # ./spec/features/login_and_logout_spec.rb:16:in `block (2 levels) in <top (required)>'

まだテストは通っていませんが、失敗する箇所が少し前進しました。このように、TDD/BDD においては失敗の原因を一つずつ解消することで開発が進んでいきます。

次回は

次回は、ユーザー認証を行うためのアクションを実装します。では、また。

補遺(2013/10/14)

前回の記事の「補遺」で書いたことの繰り返しになりますが、正規表現で使用する \p{Hiragana} および \p{Katakana} には長音符(音引き)が含まれないので、「あーろん」や「アーロン」を名前として指定するとバリデーションが失敗してしまいます。

その点を踏まえてテストを書き直すと、次のようになります:

require 'spec_helper'

describe Customer do
  let(:customer) { FactoryGirl.build(:customer) }

  specify '妥当なオブジェクト' do
    expect(customer).to be_valid
  end

  %w{family_name given_name family_name_kana given_name_kana}.each do |column_name|
    specify "#{column_name} は空であってはならない" do
      customer[column_name] = ''
      expect(customer).not_to be_valid
      expect(customer.errors[column_name]).to be_present
    end

    specify "#{column_name} は40文字以内" do
      customer[column_name] = 'ア' * 41
      expect(customer).not_to be_valid
      expect(customer.errors[column_name]).to be_present
    end

    specify "#{column_name} に含まれる半角カナは全角カナに変換して受け入れる" do
      customer[column_name] = 'アーン'
      expect(customer).to be_valid
      expect(customer[column_name]).to eq('アーン')
    end
  end

  %w{family_name given_name}.each do |column_name|
    specify "#{column_name} は漢字、ひらなが、カタカナを含んでもよい" do
      customer[column_name] = '亜あアーン'
      expect(customer).to be_valid
    end

    specify "#{column_name} は漢字、ひらなが、カタカナ以外の文字を含まない" do
      ['A', '1', '@'].each do |value|
        customer[column_name] = value
        expect(customer).not_to be_valid
        expect(customer.errors[column_name]).to be_present
      end
    end
  end

  %w{family_name_kana given_name_kana}.each do |column_name|
    specify "#{column_name} はカタカナを含んでもよい" do
      customer[column_name] = 'アーン'
      expect(customer).to be_valid
    end

    specify "#{column_name} はカタカナ以外の文字を含まない" do
      ['亜', 'A', '1', '@'].each do |value|
        customer[column_name] = value
        expect(customer).not_to be_valid
        expect(customer.errors[column_name]).to be_present
      end
    end

    specify "#{column_name} に含まれるひらがなはカタカナに変換して受け入れる" do
      customer[column_name] = 'あーん'
      expect(customer).to be_valid
      expect(customer[column_name]).to eq('アーン')
    end
  end
end

そして、app/models/customer.rb のコードは次のようになります:

require 'nkf'

class Customer < ActiveRecord::Base
  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}\u30fc]+\z/, allow_blank: true }
  validates :family_name_kana, :given_name_kana,
    format: { with: /\A[\p{Katakana}\u30fc]+\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
end

以上、訂正させていただきます。

[更新] 失敗の報告順がランダムに変わることについての注釈を追加しました。(2013/09/01)

[更新] 読者の方からのご指摘により、ログインフォームの部分テンプレート(パーシャル)のファイル名を _form.html.erb から _login_form.html.er に訂正しました。(2013/09/08)

[更新] 長音符を含む名前のバリデーションに関する補遺を追加しました。(2013/10/14)

[更新] 読者の方からのご指摘により、HTMLテンプレートのパスを app/top/index.html.erb から app/views/top/index.html.erb に、app/top/_login_form.html.erb から app/views/top/_login_form.html.erb に訂正しました。(2013/11/01)