50
54

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

[Rails] 基本的なユーザ管理用Modelをテスト駆動で実装してみる

Last updated at Posted at 2013-11-26

Ruby on Rails Tutorialのエッセンスを自分なりに整理8

[Rails] RSpecのリファクタリング
http://qiita.com/kidachi_/items/ec184deb106e4e5c90c3
の続き。

Ruby on Rails Tutorial(chapter6)
http://railstutorial.jp/chapters/modeling-users?version=4.0#top

概要

認証機能をもつユーザ管理用のModelをテスト駆動で実装していく。

RSpecの導入方法はこちら
[Rails] RSpecによるBDD(振舞駆動開発)の基本 [SporkとGuardも]
http://qiita.com/kidachi_/items/cb8910eb74e924456df9

データモデルの定義

column type
id integer
name string
email string
cretaed_at datetime
updated_at datetime

モデルの生成とマイグレーション実行。

$ rails generate model User name:string email:string
$ bundle exec rake db:migrate

データ構造をモデルに示すgemの導入

annotate(gem)を用いることで、モデルにデータ構造をコメント形式で残すことができる。

Gemfile
group :development do
  gem 'annotate', '2.5.0'
end
$ bundle install
$ bundle exec annotate
Annotated (1): User

モデルへデータモデルを表すコメントが追加された。

app/models/user.rb
# == Schema Information
#
# Table name: users
#
#  id         :integer          not null, primary key
#  name       :string(255)
#  email      :string(255)
#  created_at :datetime
#  updated_at :datetime
#

class User < ActiveRecord::Base
end

※dbスキーマに変更がある度にbundle exec annotateする必要がある。

Rails4.0のモデルについて

3系まではattr_accessibleを定義するのが普通だが、
4系からはstrong_parametersで行うためattr_accessibleは記述しない。

app/models/user.rb
class User < ActiveRecord::Base
  # attr_accessible :name, :email  # 4系では不要
end

strong_parametersについては以下等を参照

[Rails4.0] フォームの基本とStrongParametersを理解する
http://qiita.com/kidachi_/items/99f2c90788bd931ea3ee

Rails4 の Strong Parameters でリクエストパラメータを検証する
http://www.techscore.com/blog/2013/01/29/rails4-%E3%81%AE-strong-parameters-%E3%81%A7%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%83%91%E3%83%A9%E3%83%A1%E3%83%BC%E3%82%BF%E3%82%92%E6%A4%9C%E8%A8%BC%E3%81%99%E3%82%8B/

バリデーションの実装

各カラムに対するバリデーションをテスト駆動で実装する。

テストの準備

spec/models/user_spec.rb
require 'spec_helper'

describe User do

  before { @user = User.new(name: "Example User", email: "user@example.com") }

  subject { @user }

  it { should respond_to(:name) }
  it { should respond_to(:email) }
end

before~とsubject~

@userをitで表す。

※beforeやsubjectの詳細は以下参照
[Rails] RSpecのリファクタリング
http://qiita.com/kidachi_/items/ec184deb106e4e5c90c3#refactor1

respond_to?

シンボルを引数として受け取り、そのメソッド(またはメンバ)を
オブジェクトが保持している場合はtrueを、そうでない場合はfalseを返す。

Rubyで記述した場合

Ruby
@user.respond_to?(:name)

RSpecで記述した場合

RSpec
@user.should respond_to(:name)
# or
@user.should be_respond_to(:name)
# or
expect(@user).to respond_to(:name)

真偽値を返すメソッドに関するRubyとRSpecの関係

Rubyで真偽値を返すfoo?というメソッドに応答するのであれば、
RSpecではそれに対応するbe_fooというテストメソッドが存在する。

Red to Green

現時点ではテスト用dbを準備していないのでRed

# テスト用DBの準備
$ rake db:test:prepare

これでGreenになる。

オブジェクトと各メンバの存在検証

オブジェクトが有効かどうか(obj.valid?)を検証する。

テストの準備

describe User do
  before { @user = User.new(name: "test", email:"test@example.com") }
  subject { @user }

  it { should respond_to(:name) }
  it { should respond_to(:email) }

  it { should be_valid }

  describe "when name is not present" do
    before { @user.name = "" }
    it { should_not be_valid }
  end
  describe "when email is not present" do
    before { @user.email = "" }
    it { should_not be_valid }
  end
end

Red to Green

app/models/user.rb
class User < ActiveRecord::Base
  validates :name, presence: true
  validates :email, presence: true
end

rails console(sandbox)で動作確認
※sandboxにしておくと、console終了時に変更を全てrollbackしてくれる。

$ rails c --sandbox
> user = User.new(name:"", email:"test@example.com")
=> #<User id: nil, name: "", email: "test@example.com", created_at: nil, updated_at: nil>
> user.save
   (0.1ms)  SAVEPOINT active_record_1
   (0.1ms)  ROLLBACK TO SAVEPOINT active_record_1
=> false
# name属性が欠けた状態での保存が出来ない事を確認

# valid?はfalseを返す
> user.valid?
=> false
> user.errors.full_messages
=> ["Name can't be blank"]

メールアドレス検証

メールアドレスについて、より詳細に正当性を検証する。

テストコードの準備

正規表現を用いて、適切なメールアドレスの形式を保っているかチェック。

spec/models/user_spec.rb
require 'spec_helper'

describe User do
  before { @user = User.new(name: "test", email:"test@example.com") }
  subject { @user }
  ~
  describe "when email format is invalid" do
    it "should be invalid" do
      addresses = %w[user@foo..com user_at_foo,org example.user@foo.
                    foo@bar_baz.com foo@bar+baz.com]
      addresses.each do |invalid_address|
        @user.email = invalid_address
        should_not be_valid
      end
    end
  end
  describe "when email format is valid" do
    it "should be valid" do
      addresses = %w[user@foo.COM A_US-ER@f.b.org frst.lst@foo.jp a+b@baz.cn]
      addresses.each do |valid_address|
        @user.email = valid_address
        should be_valid
      end
    end
  end
end

Red to Green

spec/models/user_spec.rb
class User < ActiveRecord::Base
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, format: { with: VALID_EMAIL_REGEX }
end

今回用いた正規表現

表現 意味
/ 正規表現の開始を示す
\A 文字列の先頭
[\w+-.]+ 英数字、アンダースコア (_)、プラス (+)、ハイフン (-)、
ドット (.) のいずれかを少なくとも1文字以上繰り返す
@ アットマーク
[a-z\d-.]+ 英小文字、数字、ハイフン、ドットのいずれかを
少なくとも1文字以上繰り返す
. ドット
[a-z]+ 英小文字を少なくとも1文字以上繰り返す
\z 文字列の末尾
/ 正規表現の終わりを示す
i 大文字小文字を無視するオプション

Ruby正規表現チェック&リファレンスは以下
http://www.rubular.com/

一意(ユニーク)性検証

メンバのユニーク性を検証する。

テストコードの準備

spec/models/user_spec.rb
describe "when email address is already taken" do
    before do
      #@userと同一の属性をuser_with_same_emailコピー
      user_with_same_email = @user.dup
      #メールアドレスでは大文字小文字が区別されないため、upcaseで統一。
      user_with_same_email.email = @user.email.upcase
      user_with_same_email.save
    end

    it { should_not be_valid }
  end

Red to Green

validates uniqueness: { case_sensitive: false }
大文字小文字を識別しない形で重複を排除する。

app/models/user.rb
class User < ActiveRecord::Base
  validates :name, presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, format: { with: VALID_EMAIL_REGEX },
            uniqueness: { case_sensitive: false }
end

validates: uniquenessの問題

高トラフィックのタイミングでリクエストが短時間に連続送信された場合、
それぞれが検証をパスしてしまうことがある。

対策として、データベースレベルでも重複を禁止する。

DB側での一意(ユニーク)性検証

Unique INDEX の追加

$ rails g migration add_index_to_users_email
db/migrate/20131124064205_add_index_to_users_email.rb
class AddIndexToUsersEmail < ActiveRecord::Migration
  def change
    add_index :users, :email, unique:true
  end
end

rake db:migrateするとエラーが出た。

$ rake db:migrate
rake aborted!
An error has occurred, this and all later migrations canceled:

SQLite3::BusyException: database is locked: commit transaction
Tasks: TOP => db:migrate
(See full trace by running task with --trace)

rails console --sandboxのセッションがDBをロックしている様子。

$ ps au |grep rails
 68910   0.0  0.0  2512656      4 s007  T     9:58PM   0:02.28 /Users/taniguchidaiki/.rbenv/versions/2.0.0-p195/bin/ruby bin/rails c --sandbox
$ kill -9 68910

プロセスをkillして再度rake db:migrateで通った。

$ rake db:migrate
==  AddIndexToUsersEmail: migrating ===========================================
-- add_index(:users, :email, {:unique=>true})
   -> 0.0011s
==  AddIndexToUsersEmail: migrated (0.0012s) ==================================

ユニークインデックス追加完了。

小文字変換処理の追加

全てのデータベースが常に大文字小文字を区別するインデックスを
使っているとは限らないため、メールアドレスを保存する前に
すべての文字を小文字に変換する処理を追加する。

テストコードの準備

spec/models/user_spec.rb
  describe "email address with mixed case" do
    let(:mixed_case_email) { "Foo@ExAMPle.CoM"}

    it "should be saved as all lower-case" do
      @user.email = mixed_case_email
      @user.save
      expect(@user.reload.email).to eq mixed_case_email.downcase
    end
  end

Red to Green

コールバックメソッド(※)before_saveを用いる。

※Active Recordオブジェクトが持続している間の任意のタイミングで呼び出される。

app/models/user.rb
class User < ActiveRecord::Base
  before_save { self.email = email.downcase }    # { email.downcase! }でも可
  ~
end

これで重複データの保管がリクエストされると、
ActiveRecord::StatementInvalid例外を投げる。

セキュアなパスワードの実装

ユーザ認証用のパスワードを生成する。

認証の流れは以下。

  • パスワードの送信
  • 暗号化
  • db内の暗号化された値との比較

暗号化を挟むことにより、万が一db内の情報が流出してもパスワードの安全性は保たれる。

password追加(データモデルの変更)

テストの準備

spec/models/user_spec.rb
describe User do
  ~
  subject { @user }

  it { should respond_to(:name) }
  it { should respond_to(:email) }
  it { should respond_to(:password_digest) }

  it { should be_valid }
  ~
end

Red to Green

password_digestの追加(データモデルを変更する)

column type
id integer
name string
email string
password_digest string
cretaed_at datetime
updated_at datetime

マイグレーションファイルの作成

$ rails g migrate add_password_digest_to_users password_digest:string

rails g migrate add_column名_to_table名 追加カラム名:カラムの型という
形式にしたがってgenerateすると、自動で適切なchangeメソッドが出力される。

db/migrate/20131124073401_add_password_digest_to_users.rb
class AddPasswordDigestToUsers < ActiveRecord::Migration
  def change
    add_column :users, :password_digest, :string
  end
end

マイグレーションの反映(とテストDBの準備)

$ rake db:migrate
$ rake test:prepare    # db:migrateの後に必要になることも。

passwordの入力受付と、正当性検証

必要な機能

  1. password属性とpassword_confirmation属性の追加
  2. passwordが存在することを要求
  3. passwordが最低限の文字数を保持していることを要求
  4. passwordとpassword_confirmationが一致することを要求
  5. 暗号化されたパスワードとpassword_digestを比較、認証するauthenticateメソッドの実装

テストコードの準備

spec/models/user_spec.rb
describe User do
  before do
    @user = User.new(name: "test", email:"test@example.com",
                      password: "foobar", password_confirmation: "foobar")
  end

  subject { @user }

  it { should respond_to(:name) }
  it { should respond_to(:email) }
  it { should respond_to(:password_digest) }
  it { should respond_to(:password) }
  it { should respond_to(:password_confirmation) }

  it { should be_valid }

~

  # passwordの長さ検証
  describe "with a password that's too short" do
    before { @user.password = @user.password_confirmation = "a" * 5 }
    it { should be_invalid }
  end
  # authenticateメソッド検証
  describe "return value of authenticate method" do
    before { @user.save }
    let(:found_user) { User.find_by(email: @user.email) }

    # passwordが正しい場合
    describe "with valid password" do
      it { should eq found_user.authenticate(@user.password) }
    end

    # passwordが不正である場合
    describe "with invalid password" do
      let(:user_for_invalid_password) { found_user.authenticate("invalid") }

      it { should_not eq user_for_invalid_password }
      specify { expect(user_for_invalid_password).to be_false }
    end
  end
end

eq

オブジェクト同士が同値であるかを調べる。

specify

itと同義であり、itを使用すると英語として不自然な場合にこれで代用する。
「it should not equal wrong user」(itはユーザーなど) とするのは英語として自然だが、
「user: user with invalid password should be false」は不自然。
「specify: user with invalid password should be false」とすれば自然になる。

let

以下を参照
[Rails] RSpecのリファクタリング
http://qiita.com/kidachi_/items/ec184deb106e4e5c90c3#refactor3

パスワードを不可逆に暗号化するための準備

bcrypt(gem)を利用する。

~
gem 'bcrypt-ruby', '~> 3.1.2'
~
$ bundle install

Red to Green

必要な機能

  1. password属性とpassword_confirmation属性の追加
  2. passwordが存在することを要求
  3. passwordが最低限の文字数を保持していることを要求
  4. passwordとpassword_confirmationが一致することを要求
  5. 暗号化されたパスワードとpassword_digestを比較、認証するauthenticateメソッドの実装

実は、このうち3以外は全てhas_secure_passwordが自動的に追加してくれる。

has_secure_passwordの追加と、passwordの長さ検証の実装

app/models/user.rb
  has_secure_password
  validates :password, length: { minimum: 6 }

ユーザが実際に作成できるか検証

$ rails c

# ユーザの作成
> User.create(name:"test", email:"test@example.com", password:"foobar", password_confirmation:"foobar")
   (0.1ms)  begin transaction
  User Exists (0.3ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER('test@example.com') LIMIT 1
Binary data inserted for `string` type on column `password_digest`
  SQL (4.0ms)  INSERT INTO "users" ("created_at", "email", "name", "password_digest", "updated_at") VALUES (?, ?, ?, ?, ?)  [["created_at", Sun, 24 Nov 2013 09:59:11 UTC +00:00], ["email", "test@example.com"], ["name", "test"], ["password_digest", "$2a$10$XJmRT5H6kIAvYA3PsFy1t.UV0HvRsA0V6ei0QzRz.yy.B6FUF8jm2"], ["updated_at", Sun, 24 Nov 2013 09:59:11 UTC +00:00]]
   (2.0ms)  commit transaction
=> #<User id: 1, name: "test", email: "test@example.com", created_at: "2013-11-24 09:59:11", updated_at: "2013-11-24 09:59:11", password_digest: "$2a$10$XJmRT5H6kIAvYA3PsFy1t.UV0HvRsA0V6ei0QzRz.yy....">

# 作成したユーザをdbから取得
> user = User.find_by(email:"test@example.com")
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."email" = 'test@example.com' LIMIT 1
=> #<User id: 1, name: "test", email: "test@example.com", created_at: "2013-11-24 09:59:11", updated_at: "2013-11-24 09:59:11", password_digest: "$2a$10$XJmRT5H6kIAvYA3PsFy1t.UV0HvRsA0V6ei0QzRz.yy....">

# 作成したユーザのpassword_digestを確認
> user.password_digest
=> "$2a$10$XJmRT5H6kIAvYA3PsFy1t.UV0HvRsA0V6ei0QzRz.yy.B6FUF8jm2"

# authenticateメソッドの動作確認(不正なパスワードを弾く)
> user.authenticate("invalid")
=> false

# authenticateメソッドの動作確認(正当なパスワードを承認する)
> user.authenticate("foobar")
=> #<User id: 1, name: "test", email: "test@example.com", created_at: "2013-11-24 09:59:11", updated_at: "2013-11-24 09:59:11", password_digest: "$2a$10$XJmRT5H6kIAvYA3PsFy1t.UV0HvRsA0V6ei0QzRz.yy....">

うまくいった。

以下に続く

[Rails4.0] フォームの基本とStrongParametersを理解する
http://qiita.com/kidachi_/items/7c1a9b61dd1853d5a7dc

50
54
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
50
54

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?