現状のUserモデルの問題点
name
属性とemail
属性に、あらゆる文字列を取ることができる
以下は、name
属性の値がnil
であるレコードがRDB上に存在することを示す例です。現状のUserモデルの実装では、このようなデータも有効とされてしまいます。
>> User.find(4)
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 4], ["LIMIT", 1]]
=> #<User id: 4, name: nil, email: "foo@bar.com", created_at: "2019-10-03 11:06:14", updated_at: "2019-10-03 11:06:14">
さらに、email
属性の値にもnil
を取ることができてしまいます。
>> User.find(4).update_attributes(email: nil)
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 4], ["LIMIT", 1]]
(0.1ms) SAVEPOINT active_record_1
SQL (0.2ms) UPDATE "users" SET "email" = ?, "updated_at" = ? WHERE "users"."id" = ? [["email", nil], ["updated_at", "2019-10-03 11:17:27.810162"], ["id", 4]]
(0.3ms) RELEASE SAVEPOINT active_record_1
=> true
>> User.find(4)
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 4], ["LIMIT", 1]]
=> #<User id: 4, name: nil, email: nil, created_at: "2019-10-03 11:06:14", updated_at: "2019-10-03 11:17:27">
あるべき姿
ユーザーアカウントを内容とするRDBのレコードの場合、例えば以下のような制約は実装されていてほしいものです。
-
name
属性は空であってはならない -
email
属性はメールアドレスのフォーマットに従う書式である -
email
属性は一意の値である必要がある- ログイン時のユーザー名として、メールアドレスを用いる予定であるため
検証/バリデーションとは
Railsの文脈において、検証ないしバリデーションという言葉は、「属性値に対して何らかの制約を課すためのActive Recordの機能」を指します。よく使われる制約の一例としては、以下のようなものが挙げられます。
- 存在性の制約
- 長さの制約
- フォーマットの制約
- 一意性の制約
バリデーション実装を題材とした、テスト駆動開発の基本の習得
バリデーション実装というのは、テスト駆動開発と相性がよい対象といえます。その理由は以下の通りです。
- 「値に対してルールを適用する」という動作は、テストの成功・失敗を定義しやすいこと
- 状態に依存しないため、特に困難なくテストを実装できる
- バリデーションが失敗した場合、結果としてやや複雑な動作が要求されること
- どのような理由でバリデーションが通らなかったかをユーザーに提示する動作
- RDBに当該変更を反映しない動作
- やや複雑な動作であるため、テストなしでは正常動作の立証が困難であること
というわけで、Railsチュートリアルにおいては、バリデーション実装を題材としてテスト駆動開発を行っています。
具体的には、以下のような手法で開発を進めていくということです。
- 有効なモデルであれば通るようなテストを書く
- 有効なモデルのオブジェクトを作成する
- この時点ではテストは通るはず
- 作成したオブジェクトの属性のうち、1つを有効でない属性に意図的に変更する
- バリデーションで失敗するかどうかをテストする
- この時点でテストが失敗する
テストコードの実装
モデルに対するテストのモックは、rails generate model
コマンドの実行結果として生成されます。例えば、現在題材としているUserモデルに対しては、以下のモックが既に存在します。
require 'test_helper'
class UserTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
見ての通り、現時点では単なるモックです。この時点で重要なのは、「Railsにおけるモデルに対するテストクラスは、ActiveSupport::TestCase
というクラスを継承している」という点でしょうか。コントローラに対するテストがActionDispatch::IntegrationTest
というクラスを継承していたのとは違いますね。
ここから実際のテストを書いていきましょう。まずは、有効なUserかどうかをテストするコードです。
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
test "should be valid" do
assert @user.valid?
end
end
テストメソッドにおいてsetup
というメソッドが使われています。Railsのテストフレームワークにおいて、「各テストが走る直前に実行される処理」を記述するためのメソッドです。@user
はインスタンス変数ですが、setupメソッド内で宣言しておけば、すべてのテスト内で当該インスタンス変数が使えるようになります。
というわけで、今書いたテストコードによって、valid?
メソッドを使ってUserオブジェクトの有効性をテストできる、というわけです。
初めてのテストコード実行
モデルオブジェクトに対するテストを実行するには、開発環境でrails test:models
というコマンドを実行します。
# rails test:models
Started with run options --seed 19540
1/1: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.08566s
1 tests, 1 assertions, 0 failures, 0 errors, 0 skips
余談 - 誤ってrails test:model
というコマンドを実行してしまうとどうなるか
誤ってrails test:model
というコマンドを実行してしまうと、以下のようにスタックトレースを含む長いエラーメッセージが表示されます。
# rails test:model
rails aborted!
Don't know how to build task 'test:model' (See the list of available tasks with `rails --tasks`)
Did you mean? test:models
/usr/local/bundle/gems/railties-5.1.6/lib/rails/commands/rake/rake_command.rb:21:in `block in perform'
/usr/local/bundle/gems/railties-5.1.6/lib/rails/commands/rake/rake_command.rb:18:in `perform'
/usr/local/bundle/gems/railties-5.1.6/lib/rails/command.rb:46:in `invoke'
/usr/local/bundle/gems/railties-5.1.6/lib/rails/commands.rb:16:in `<top (required)>'
/var/www/sample_app/bin/rails:9:in `require'
/var/www/sample_app/bin/rails:9:in `<top (required)>'
/usr/local/bundle/gems/spring-2.0.2/lib/spring/client/rails.rb:28:in `load'
/usr/local/bundle/gems/spring-2.0.2/lib/spring/client/rails.rb:28:in `call'
/usr/local/bundle/gems/spring-2.0.2/lib/spring/client/command.rb:7:in `call'
/usr/local/bundle/gems/spring-2.0.2/lib/spring/client.rb:30:in `run'
/usr/local/bundle/gems/spring-2.0.2/bin/spring:49:in `<top (required)>'
/usr/local/bundle/gems/spring-2.0.2/lib/spring/binstub.rb:31:in `load'
/usr/local/bundle/gems/spring-2.0.2/lib/spring/binstub.rb:31:in `<top (required)>'
/var/www/sample_app/bin/spring:15:in `<top (required)>'
bin/rails:3:in `load'
bin/rails:3:in `<main>'
(See full trace by running task with --trace)
演習 - バリデーション実装を題材とした、テスト駆動開発の基本の習得
1. コンソールから、新しく生成したuserオブジェクトが有効 (valid) であることを確認してみましょう。
>> User.new.valid?
=> true
2. 6.1.3で生成したuserオブジェクトも有効であるかどうか、確認してみましょう。
>> user.valid?
=> true
存在性を検証する
存在性の検証とは、「渡された属性が存在することを検証する」ことを言います。この節では、以下の存在性検証を実装します。
- ユーザー情報がRDBに保存される前に、nameフィールドが存在することを検証する
- ユーザー情報がRDBに保存される前に、emailフィールドが存在することを検証する
name
属性の存在性に関するテスト
name
属性の存在性に関するテストの追加
test/models/user_test.rb
に、まずはname
属性の存在性に関するテストを追加します。
```diff:test/models/user_test.rb
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
test "should be valid" do
assert @user.valid?
end
- test "name should be present" do
-
@user.name = " "
-
assert_not @user.valid?
- end
end
この時点では、テストが失敗するようになります。
rails test:models
Started with run options --seed 50708
FAIL["test_name_should_be_present", UserTest, 0.11231679999036714]
test_name_should_be_present#UserTest (0.11s)
Expected true to be nil or false
test/models/user_test.rb:15:in `block in class:UserTest'
2/2: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.13430s
2 tests, 2 assertions, 1 failures, 0 errors, 0 skips
### `name`属性の存在を検査するには
モデルオブジェクトのクラス定義において、第1引数を`:name`とした`validates`メソッドを、`{ presence: true }`というオプションハッシュを第2引数として与えて用いることにより実現できます。
```diff:app/models/user.rb
class User < ApplicationRecord
+ validates :name, presence: true
end
以下の3つのコードは、いずれも同じ意味です。
class User < ApplicationRecord
validates :name, presence: true
end
class User < ApplicationRecord
validates(:name, presence: true)
end
class User < ApplicationRecord
validates(:name, { presence: true })
end
重要なのは以下の事柄です。
- Rubyにおいては、特別な場合を除き、メソッド呼び出しの
()
は省略できる - 最後の引数がハッシュである場合、当該ハッシュに対する
{}
は省略できる
Userモデルに検証を追加した結果を確認してみる
コンソールを起動して、Userモデルに検証を追加した結果を見てみます。
>> user = User.new(name: "", email: "mhartl@example.com")
=> #<User id: nil, name: "", email: "mhartl@example.com", created_at: nil, updated_at: nil>
>> user.valid?
=> false
valid?
メソッドの戻り値がfalse
になっていますね。
どの検証が失敗したかを確認するには、検証が失敗した際に生成されるerrors
メソッドを使うと便利です。
>> user.errors.full_messages
=> ["Name can't be blank"]
>> user.name.blank?
=> true
エラーメッセージにblankとあるので、blank?
メソッドで確認してみました。確かにblank?
の結果がtrue
になっていますね。
validでないモデルオブジェクトは、RDBに保存できない
valid?
がfalse
であるようなモデルオブジェクトは、RDBに保存することができません。
>> user.save
(1.6ms) SAVEPOINT active_record_1
(0.2ms) ROLLBACK TO SAVEPOINT active_record_1
=> false
ROLLBACK
されていますね。
今度こそテストは成功
ここまで確認したところで、改めてrails test:models
を実行してみます。
# rails test:models
Started with run options --seed 17899
2/2: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.11375s
2 tests, 2 assertions, 0 failures, 0 errors, 0 skips
今度こそテストは成功しました。
email
属性の存在性に関するテスト
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
test "should be valid" do
assert @user.valid?
end
test "name should be present" do
@user.name = " "
assert_not @user.valid?
end
+ test "email should be present" do
+ @user.email = " "
+ assert_not @user.valid?
+ end
end
ここまでの変更に加え、email
属性の有効性に対するテストを追加するためには、まずtest/models/user_test.rb
に対して上記の変更を行います。
この時点では、以下の通りテストは通りません。
# rails test:models
Started with run options --seed 4227
FAIL["test_email_should_be_present", UserTest, 0.10753690000274219]
test_email_should_be_present#UserTest (0.11s)
Expected true to be nil or false
test/models/user_test.rb:20:in `block in <class:UserTest>'
3/3: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.12862s
3 tests, 3 assertions, 1 failures, 0 errors, 0 skips
現時点でテストが通るようにするためには、app/models/user.rb
に以下の変更を加えます。
class User < ApplicationRecord
validates :name, presence: true
+ validates :email, presence: true
end
改めてテストを実行します。
# rails test:models
Started with run options --seed 54749
3/3: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.10906s
3 tests, 3 assertions, 0 failures, 0 errors, 0 skips
今度こそテストが通りました。
改めて全体のテストを実行
全てのテストを実行してみます。
# rails test
Running via Spring preloader in process 2372
Started with run options --seed 40322
11/11: [=================================] 100% Time: 00:00:03, Time: 00:00:03
Finished in 3.63199s
11 tests, 22 assertions, 0 failures, 0 errors, 0 skips
通りました。
演習 - 存在性の検証
モデルオブジェクト(app/models
)に変更を加えた場合、Railsコンソールを再起動しなければ、モデルオブジェクトに対する変更が反映されません。
1.1. 新しいユーザーu
を作成し、作成した時点では有効ではない (invalid) ことを確認してください。
>> u = User.new
=> #<User id: nil, name: nil, email: nil, created_at: nil, updated_at: nil>
>> u.valid?
=> false
1.2. なぜ有効ではないのでしょうか? エラーメッセージを確認してみましょう。
>> u.errors.full_messages
=> ["Name can't be blank", "Email can't be blank"]
こういうときに使うのは、errors.full_messages
メソッドですね。結果、以下2つの文字列から成る配列を返してきました。
- Name can't be blank
- Email can't be blank
2.1. u.errors.messages
を実行すると、ハッシュ形式でエラーが取得できることを確認してください。
>> u.errors.messages
=> {:name=>["can't be blank"], :email=>["can't be blank"]}
2.2. emailに関するエラー情報だけを取得したい場合、どうやって取得すれば良いでしょうか?
errors.messages
はハッシュを返すのでしたね。ハッシュのfind
メソッドを使います。keyが:email
である要素をfind
するのです。
>> u.errors.messages.find {|k,v| k == :email}
=> [:email, ["can't be blank"]]
私事ですが、ハッシュのfind
メソッドを、および、ハッシュに関するブロックの構文を覚えていませんでした。
長さを検証する
今度はユーザー名・メールアドレスの長さに制限を与えていきます。
ユーザー名の長さに制限を与える理由として、Railsチュートリアルでは、「ユーザーの名前はサンプルWebサイトに表示される」旨を挙げていました。一方、メールアドレスの長さに制限を与える理由としては、「ほとんどのRDBMSでは文字列の上限を255文字としている」旨を挙げていました。
というわけで、文字列の長さの制限は以下とします。
- Name属性…最長50文字
- Email属性…最長255文字
長さに関するテストを追加する
というわけで、以下のコードをtest/models/user_test.rb
に追加していきます。
require 'test_helper'
class UserTest < ActiveSupport::TestCase
...略
+
+ test "name should not be too long" do
+ @user.name = "a" * 51
+ assert_not @user.valid?
+ end
+
+ test "email should be not to long" do
+ @user.email = "a" * 244 + "@example.com"
+ assert_not @user.valid?
+ end
end
長い文字列を作る方法
Rubyでは、文字列のかけ算によって長い文字列を容易に生成することができます。
>> "a" * 51
=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
>> ("a" * 51).length
=> 51
上記は、51文字の文字列を生成した例です。今回の学習では、Name属性のバリデーションに用いています。
>> "a" * 244 + "@example.com"
=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@example.com"
>> ("a" * 244 + "@example.com").length
=> 256
上記は、メールアドレスとして正しい書式になるような256文字の文字列を生成した例です。今回の学習では、Email属性のバリデーションに用いています。
テストは失敗する
# rails test:models
Started with run options --seed 56934
FAIL["test_name_should_not_be_too_long", UserTest, 0.11051520000910386]
test_name_should_not_be_too_long#UserTest (0.11s)
Expected true to be nil or false
test/models/user_test.rb:25:in `block in <class:UserTest>'
FAIL["test_email_should_be_not_to_long", UserTest, 0.11830520001240075]
test_email_should_be_not_to_long#UserTest (0.12s)
Expected true to be nil or false
test/models/user_test.rb:30:in `block in <class:UserTest>'
5/5: [===================================] 100% Time: 00:00:00, Time: 00:00:00
現状でrails test:models
は失敗します。各FAILメッセージの冒頭には、今追加したテストの名前をスネークケースにしたものが来ています。新たに追加したテストが、確かに正しく動作していますね。
長さを強制するためのコードをモデルに追加する
Railsのモデルクラスのvalidates
メソッドにおいて、最終引数のオプションハッシュで、:length
というキーを持つ属性が利用できます。:length
属性は、引数としてハッシュを取ります。:length
属性の引数たるハッシュに:maximum
というキーを持つ属性を与えると、:maximum
属性の値により、モデルオブジェクトの対応する属性の最大長を決めることができます。
早速、Userモデルのコードを書き換えていきましょう。
class User < ApplicationRecord
- validates :name, presence: true
+ validates :name, presence: true, length: { maximum: 50 }
- validates :email, presence: true
+ validates :email, presence: true, length: { maximum: 255 }
end
上記が「Name属性の最大長50文字・Email属性の最大長255文字という制約をUserクラスに追加する」というコードの例です。
テストが成功した!
Userモデルのコードを書き換えたところで、再びrails test:models
を実行してみましょう。
# rails test:models
Started with run options --seed 55077
5/5: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.11015s
5 tests, 5 assertions, 0 failures, 0 errors, 0 skips
今度こそテストが成功しました。
演習 - 長さの検証
1. 長すぎるname
とemail
属性を持ったuserオブジェクトを生成し、有効でないことを確認してみましょう。
>> u = User.new(name: "#{("a".."z").to_a.join * 2}", email: "#{("a".."z").to_a.join * 10}@example.net")
=> #<User id: nil, name: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwx...", email: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwx...", created_at: nil, updated_at: nil>
>> u.name.length
=> 52
>> u.email.length
=> 272
>> u.valid?
=> false
上記は「name
属性もemail
属性も長すぎる」という例です。valid?
は確かにfalse
を返しています。
>> u = User.new(name: "#{("a".."z").to_a.join * 2}", email: "#{("a".."z").to_a.join}@example.net")
=> #<User id: nil, name: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwx...", email: "abcdefghijklmnopqrstuvwxyz@example.net", created_at: nil, updated_at: nil>
>> u.name.length
=> 52
>> u.email.length
=> 38
>> u.valid?
=> false
上記、今度は「email
属性の長さは適切だが、name
属性が長すぎる」という例です。valid?
は確かにfalse
を返しています。
>> u = User.new(name: "#{("a".."z").to_a.join}", email: "#{("a".."z").to_a.join * 10}@example.net")
=> #<User id: nil, name: "abcdefghijklmnopqrstuvwxyz", email: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwx...", created_at: nil, updated_at: nil>
>> u.name.length
=> 26
>> u.email.length
=> 272
>> u.valid?
=> false
上記、今度は「name
属性の長さは適切だが、email
属性が長すぎる」という例です。valid?
は確かにfalse
を返しています。
>> u = User.new(name: "#{("a".."z").to_a.join}", email: "#{("a".."z").to_a.join * 2}@example.net")
=> #<User id: nil, name: "abcdefghijklmnopqrstuvwxyz", email: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwx...", created_at: nil, updated_at: nil>
>> u.name.length
=> 26
>> u.email.length
=> 64
>> u.valid?
=> true
上記、最後は「name
属性もemail
属性も適切な長さである」という例です。valid?
がtrue
になりました。
余談…"#{("a".."z").to_a.join}"
の意味
"abcdefghijklmnopqrstuvwxyz"
という26文字の文字列を得るためのコードです。実際に行われている処理は以下のとおりです。
-
"a"
から始まり、"z"
で終わる範囲を生成する - 1.で生成した範囲を配列に変換する
- 2.で生成した配列を文字列に変換する
>> ('a'..'z').to_a.join
=> "abcdefghijklmnopqrstuvwxyz"
>> ('a'..'z').to_a.join.length
=> 26
この文字列に* 10
という演算を行えば、260文字の文字列を生成することができます。見事に255文字を超えてきますね。
>> (('a'..'z').to_a.join * 10)
=> "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz"
>> (('a'..'z').to_a.join * 10).length
=> 260
2. 長さに関するバリデーションが失敗した時、どんなエラーメッセージが生成されるでしょうか? 確認してみてください。
>> u = User.new(name: "#{("a".."z").to_a.join * 2}", email: "#{("a".."z").to_a.join * 10}@example.net")
=> #<User id: nil, name: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwx...", email: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwx...", created_at: nil, updated_at: nil>
>> u.valid?
=> false
>> u.errors.full_messages
=> ["Name is too long (maximum is 50 characters)", "Email is too long (maximum is 255 characters)"]
- Name is too long (maximum is 50 characters)
- Email is too long (maximum is 255 characters)
以上のエラーメッセージが確認できます。そういえば、errors
オブジェクトの値は副作用により与えられるのですね。
フォーマットを検証する
メールアドレスの形式には、やや複雑なルールが存在します。そのため、単に文字数を制限するだけでは、メールアドレスのバリデーションとしては不十分です。というわけで、「メールアドレスとして最低限の体裁を整えていなければならない」という条件を実装しましょう。
文字列の配列を作る%w[]
書式
>> %w[foo bar baz]
=> ["foo", "bar", "baz"]
以下の例は、each
メソッドを使って、addresses
配列の各要素を繰り返し取り出す例です。
>> addresses = %w[USER@foo.COM THE_US-ER@foo.bar.org first.last@foo.jp]
=> ["USER@foo.COM", "THE_US-ER@foo.bar.org", "first.last@foo.jp"]
>> addresses.each do |address|
?> puts address
>> end
USER@foo.COM
THE_US-ER@foo.bar.org
first.last@foo.jp
=> ["USER@foo.COM", "THE_US-ER@foo.bar.org", "first.last@foo.jp"]
テストを記述する
有効なメールアドレスに対するテスト
class UserTest < ActiveSupport::TestCase
...略
+ test "email validation should be accept valid addresses" do
+ valid_addresses = %w[user@example.com USER@foo.COM A_US-ER@foo.bar.org first.last@foo.jp alice+bob@baz.cn]
+ valid_addresses.each do |valid_address|
+ @user.email = valid_address
+ assert @user.valid?, "#{valid_address.inspect} should be valid"
+ end
+ end
end
assert
の第2引数にエラーメッセージを追加していることがポイントです。このようにすれば、どのメールアドレスでテストが失敗したかを特定できます。
assert @user.valid?, "#{valid_address.inspect} should be valid"
inspect
メソッドというのは、第4章で出てきたメソッドですね。今回は、詳細な文字列情報を得るために使っています。
このテストを追加した時点では、テストは問題なく通ります。
# rails test:models
Started with run options --seed 22570
6/6: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.10920s
6 tests, 10 assertions, 0 failures, 0 errors, 0 skips
一つ前のテスト追加の時点では5 tests, 5 assertions
だったのが、今回は6 tests, 10 assertions
になっています。テスト対象のメールアドレスとして、有効なメールアドレス5つから成る配列を与えたからですね。
無効なメールアドレスに対するテスト
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
...略
+
+ test "email validation should reject invalid addresses" do
+ invalid_addresses = %w[user@example,com user_at_foo.org user.name@example. foo@bar_baz.com foo@bar+baz.com]
+ invalid_addresses.each do |invalid_address|
+ @user.email = invalid_address
+ assert_not @user.valid?, "#{invalid_address.inspect} should be invalid"
+ end
+ end
end
この時点では、テストが失敗するようになります。
# rails test:models
Started with run options --seed 33633
FAIL["test_email_validation_should_reject_invalid_addresses", UserTest, 0.079405600001337]
test_email_validation_should_reject_invalid_addresses#UserTest (0.08s)
"user@example,com" should be invalid
test/models/user_test.rb:45:in `block (2 levels) in <class:UserTest>'
test/models/user_test.rb:43:in `each'
test/models/user_test.rb:43:in `block in <class:UserTest>'
7/7: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.13012s
7 tests, 11 assertions, 1 failures, 0 errors, 0 skips
有効なメールアドレスであるかどうかを判別する、実用的な正規表現
Railsチュートリアルでは、「有効なメールアドレスであるかどうかを判別する実用的な正規表現」として、以下のものが挙げられていました。
\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z
この正規表現の意味については、Railsチュートリアル本文内表6.1にまとめられています。
上述正規表現の問題点
ただし、この正規表現を有効なメールアドレスかどうかの判別に使うことは、日本においては若干の問題があります。以下のような「有効でないにもかかわらず、未だ日本国内で使われている可能性があるメールアドレス」が有効であると判定されてしまうためです。
-
.user@example.com
(メールアドレスの先頭に.
がある) -
user.@example.com
(@
の直前に.
がある) -
foo..bar@example.com
(@
より前で.
が連続している)
これらの有効でないメールアドレスは、2009年3月以前のNTTドコモやauにおいて、キャリアメールのメールアドレスとして作成することができてしまっていたものです。

上記スクリーンショットのとおり、Rubularで試した場合も上述した有効でないメールアドレスの例が有効であると判定されています。
メールアドレスの有効性判定をUserモデルに追加する
上述有効でないメールアドレスの問題はさておき、正規表現によるメールアドレスの有効性判定をUserモデルに実装してみましょう。
class User < ApplicationRecord
validates :name, presence: true, length: { maximum: 50 }
+ VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
- validates :email, presence: true, length: { maximum: 255 }
+ validates :email, presence: true, length: { maximum: 255 },
+ format: { with: VALID_EMAIL_REGEX }
end
これでテストが通るようになります。
# rails test:models
Started with run options --seed 38765
7/7: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.13943s
7 tests, 15 assertions, 0 failures, 0 errors, 0 skips
assertionsの数が11から15に増えました。いいですねぇ。
演習 - フォーマットの検証
1. リスト 6.18にある有効なメールアドレスのリストと、リスト 6.19にある無効なメールアドレスのリストをRubularのYour test string:に転記してみてください。その後、リスト 6.21の正規表現をYour regular expression:に転記して、有効なメールアドレスのみがすべてマッチし、無効なメールアドレスはすべてマッチしないことを確認してみましょう。

2. 先ほど触れたように、リスト 6.21のメールアドレスチェックする正規表現は、foo@bar..comのようにドットが連続した無効なメールアドレスを許容してしまいます。まずは、このメールアドレスをリスト 6.19の無効なメールアドレスリストに追加し、これによってテストが失敗することを確認してください。

「foo@bar..comのようにドットが連続した無効なメールアドレスを許容する」というのは、上述スクリーンショットのような挙動をいいます。「@
以降で.
が連続している」というパターンですね。
このようなメールアドレスを無効と判定できるような実装を追加したい…その第一段階としてのテストの追加です。
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
..略
test "email validation should reject invalid addresses" do
- invalid_addresses = %w[user@example,com user_at_foo.org user.name@example.foo@bar_baz.com foo@bar+baz.com]
+ invalid_addresses = %w[user@example,com user_at_foo.org user.name@example.foo@bar_baz.com foo@bar+baz.com foo@bar..com]
invalid_addresses.each do |invalid_address|
@user.email = invalid_address
assert_not @user.valid?, "#{invalid_address.inspect} should be invalid"
end
end
end
この時点で、テストを走らせてみます。
# rails test:models
Started with run options --seed 27813
FAIL["test_email_validation_should_reject_invalid_addresses", UserTest, 0.1293631999869831]
test_email_validation_should_reject_invalid_addresses#UserTest (0.13s)
"foo@bar..com" should be invalid
test/models/user_test.rb:45:in `block (2 levels) in <class:UserTest>'
test/models/user_test.rb:43:in `each'
test/models/user_test.rb:43:in `block in <class:UserTest>'
7/7: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.13355s
7 tests, 16 assertions, 1 failures, 0 errors, 0 skips
期待通り(?)、テストは失敗しました。"foo@bar..com" should be invalid
というメッセージ内容を見るに、失敗してほしいところできちんと失敗しているようですね。
2.2. 次に、リスト 6.23で示した、少し複雑な正規表現を使ってこのテストがパスすることを確認してください。
app/models/user.rb
を書き換えていきます。
class User < ApplicationRecord
validates :name, presence: true, length: { maximum: 50 }
- VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
+ VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX }
end
テストします。
# rails test:models
Started with run options --seed 39103
7/7: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.13441s
7 tests, 16 assertions, 0 failures, 0 errors, 0 skips
テストが通りました。
3. foo@bar..comをRubularのメールアドレスのリストに追加し、リスト 6.23の正規表現をRubularで使ってみてください。有効なメールアドレスのみがすべてマッチし、無効なメールアドレスはすべてマッチしないことを確認してみましょう。

一意性を検証する
メールアドレスの一意性についての重要注意
「メールアドレスの一意性」という観点において、Railsチュートリアルには重要な指摘事項が2点あります。
- 実用上、メールアドレスの大文字小文字は区別されない
- バリデーションも大文字小文字を区別しない形で実装しなければならない
- 「文字列型の大文字小文字を区別するかしないか」は、RDBMSの種類によって異なるので、その違いを吸収するメカニズムを実装しなければならない
- Active Recordには、RDBMSレベルでのデータの一意性を保証する仕組みはない
- ほぼ同時に同内容の
POST
リクエストを受け取った場合など、重複してはならないカラムに重複が発生するおそれがある
- ほぼ同時に同内容の
「これらを踏まえた上で、相応の対策が必要となる」ということです。…恐れずに行ってみましょう!
重複するメールアドレスを拒否する(Rails側で)
テストを書く
一意性をテストするためには、「実際にレコードをRDBMSに保存する」という操作が必要になります。ここで使うのは、モデルオブジェクトのsave
メソッドです。テストコードに登場するのは初めてですね。
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
...略
+
+ test "email addresses should be unique" do
+ duplicate_user = @user.dup
+ @user.save
+ assert_not duplicate_user.valid?
+ end
end
テストは通らない
この時点でテストは通りません。assert_not
の時点で、RDBMS上にduplicate_user
と同一内容のmail属性値を持つレコードが存在するのに、duplicate_user
のvalid?
がtrue
になるためです。
Started with run options --seed 45524
FAIL["test_email_addresses_should_be_unique", UserTest, 0.11071269999956712]
test_email_addresses_should_be_unique#UserTest (0.11s)
Expected true to be nil or false
test/models/user_test.rb:52:in `block in <class:UserTest>'
8/8: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.21069s
8 tests, 17 assertions, 1 failures, 0 errors, 0 skips
テストを通す
上記テストを通すためには、Userモデルにおけるemail
のバリデーションにuniqueness: true
というオプションを追加すればOKです。
class User < ApplicationRecord
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
- format: { with: VALID_EMAIL_REGEX }
+ format: { with: VALID_EMAIL_REGEX },
+ uniqueness: true
end
実は、「メールアドレスは、実用上大文字小文字を区別しない」ことに対応しなければならなかった
前述「メールアドレスの一意性についての重要注意」にあった事柄の一つですね。Railsチュートリアルのテストコードでは、「メールアドレスを大文字に変換して比較する」という操作を追加しています。
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
...略
test "email addresses should be unique" do
duplicate_user = @user.dup
+ duplicate_user.email = @user.email.upcase
@user.save
assert_not duplicate_user.valid?
end
end
「メールアドレスを大文字に変換して比較する」というのが何を指すか、Railsコンソールで順を追って確認してみます。
>> user = User.create(name: "Example User", email: "user@example.com")
(0.1ms) SAVEPOINT active_record_1
User Exists (1.2ms) SELECT 1 AS one FROM "users" WHERE "users"."email" = ? LIMIT ? [["email", "user@example.com"], ["LIMIT", 1]]
SQL (14.0ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["name", "Example User"], ["email", "user@example.com"], ["created_at", "2019-10-09 23:07:22.293648"], ["updated_at", "2019-10-09 23:07:22.293648"]]
(0.1ms) RELEASE SAVEPOINT active_record_1
=> #<User id: 1, name: "Example User", email: "user@example.com", created_at: "2019-10-09 23:07:22", updated_at: "2019-10-09 23:07:22">
>> user.email.upcase
=> "USER@EXAMPLE.COM"
>> duplicate_user = user.dup
=> #<User id: nil, name: "Example User", email: "user@example.com", created_at: nil, updated_at: nil>
>> duplicate_user.email = user.email.upcase
=> "USER@EXAMPLE.COM"
>> duplicate_user.valid?
User Exists (0.2ms) SELECT 1 AS one FROM "users" WHERE "users"."email" = ? LIMIT ? [["email", "USER@EXAMPLE.COM"], ["LIMIT", 1]]
=> true
最後のduplicate_user.valid?
がtrue
になっています。前述"email addresses should be unique"
テストを通すためには、duplicate_user.valid?
はfalse
にならなければなりません。
モデルオブジェクトにおいて、「一意性検証において大文字小文字を区別しない」という実装をするには、:uniquness
オプションの:case_sensitive
オプションの値false
とすればOKです。
class User < ApplicationRecord
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX }
format: { with: VALID_EMAIL_REGEX },
- uniqueness: true
+ uniqueness: { case_sensitive: false }
end
「:uniqueness
オプションの値にハッシュのみを与えている」という点は注目に値します。このような書き方をした場合、Railsは:uniquness
オプションをtrue
と判断した上で処理を行います。
この時点で、Rails側のテストは通るようになりました。
# rails test:models
Started with run options --seed 11495
8/8: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.21977s
8 tests, 17 assertions, 0 failures, 0 errors, 0 skips
# rails test
Running via Spring preloader in process 2521
Started with run options --seed 16633
16/16: [=================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.45354s
16 tests, 36 assertions, 0 failures, 0 errors, 0 skips
RDBMSレベルでメールアドレスの一意性を保証する
現時点で、Rails側でメールアドレスの重複を排除する仕組みは実装できています。しかし、前述「メールアドレスの一意性についての重要注意」で言及したように、RDBMSレベルでメールアドレスの一意性を保証する仕組みは未だ実装されていません。そのため、現時点では「重複するメールアドレスを持つ複数レコードがRDBMSに登録されてしまう」という事態の発生を排除しきれません。Railsチュートリアルでは、「Submitボタンを素早く2回クリックしてしまった場合」を例として、そうした事態が発生しうることについて言及しています。
というわけで、RDBMS側にもメールアドレスの一意性を保証する仕組みを実装する必要があります。具体的には以下の手順を踏みます。
- RDBMS上のemailカラムにインデックスを追加する
- 追加したインデックスについて、一意性制約を追加する
変更を加えるのはRDBMS側ですが、この操作はRails側から行うことが可能です。「マイグレーションの実装」という操作ですね。ただ、今回はマイグレーションの新規実装ではなく、既存のモデルへの構造の追加なので、新規実装時とは異なる手順を踏む必要があります。
既存のモデルに構造を追加する
既存のモデルに構造を追加する場合、migration
ジェネレーターを用いてマイグレーションを直接作成する必要があります。
# rails generate migration add_index_to_users_email
Running via Spring preloader in process 2535
invoke active_record
create db/migrate/[timestamp]_add_index_to_users_email.rb
db/migrate/{[timestamp]_add_index_to_users_email.rb
というファイルが生成されました。これが新たに作成したマイグレーションの実体です。
生成時点のマイグレーションには、特に何も定義されていません。ここにメールアドレスの一意性制約を追加していきます。
class AddIndexToUsersEmail < ActiveRecord::Migration[5.1]
def change
+ add_index :users, :email, unique: true
end
end
追加したコードのポイントは以下です。
-
users
テーブルのemail
カラムにインデックスを追加している - インデックスはRails(ActiveRecord::Migration)の
add_indexメソッド
により追加することができる- デフォルトでは一意性制約を追加しない
- オプションとして
unique: true
を指定することにより、一意性制約を追加できる
マイグレーションのコードを変更したら、最後に実際にマイグレートの処理を行います。
# rails db:migrate
== [timestamp] AddIndexToUsersEmail: migrating =============================
-- add_index(:users, :email, {:unique=>true})
-> 0.0222s
== [timestamp] AddIndexToUsersEmail: migrated (0.0224s) ====================
無事マイグレートの処理が完了しました。
fixture
Railsにおけるfixtureというのは、テストに際して初期データを投入する機構のことです。モデルオブジェクトに対応して存在します。最初の生成は、rails generate model
により自動で行われます。
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
name: MyString
email: MyString
two:
name: MyString
email: MyString
上記は自動生成されたままの状態のfixture定義データです。見ての通り、メールアドレスが一意ではありません。そのため、「RDBMSにメールアドレスの一意性制約を追加した時点で、一意性制約違反によりテストが通らなくなった」というのが現状です。
なお、そもそもfixtureの内容自体が有効でない代物なのですが、fixtureの内容はバリデーションを通らないゆえ、ここまで問題にはなっていませんでした。
現時点およびこの先しばらくはfixtureを使うことはありません。なので、ここでは単にfixture定義ファイルの内容を単純に空にしてしまうという解決策をとります。
- # Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
- one:
- name: MyString
- email: MyString
-
- two:
- name: MyString
- email: MyString
-
fixtureの中身を空にした時点で、モデルに対するテストは通るようになります。
# rails test:models
Started with run options --seed 158
8/8: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.20588s
8 tests, 17 assertions, 0 failures, 0 errors, 0 skips
大文字小文字を区別するRDBMSへの対処
インデックスにおいて大文字と小文字を区別するかは、使用するRDBMSによって異なります。
今回のアプリケーションでは、メールアドレスのインデックスにおいて大文字と小文字は区別してほしくありません。例えば、eRAseRmOToRpHAntOM@example.comとEraSErMotOrPhaNTom@ExAMPle.comは同一の文字列と解釈してほしいです。そのため、インデックスにおいて大文字と小文字を区別するRDBMSへの対処が必要となります。
今回は、「データベースに保存される直前に、すべての文字列を小文字に変換する」という方法をとります。例えば、"EraSErMotOrPhaNTom@ExAMPle.com"という文字列が渡されたら、保存直前に"erasermotorphantom@example.com"に変換してしまうわけです。
Active Recordのコールバック(callback)メソッドとは
RailsのActive Recordライブラリでは、オブジェクトのライフサイクル中の特定タイミングに実行されるメソッドを定義することができます。例えば、作成時・保存時・更新時・削除時・検索時・検証時・データベースからの読み込み時などがそのタイミングです。こうした特定タイミングに実行されるメソッドを「コールバック」といいます。
ここで「特定タイミング」と言いました。より厳密には、特定タイミングの「前」「後」というのも指定することができます。例えば、作成前・作成後。保存前・保存後…というような指定も可能です。
コールバックメソッドの実装
繰り返しますと、今回実装するのは、「データベースに保存される直前に、email
属性のすべての文字列を小文字に変換する」という処理です。「データベースに保存される直前」に実行されるコールバックは、before_save
という名前で定義されています。
では実装していきましょう。対象ファイルはapp/models/user.rb
です。
class User < ApplicationRecord
+ before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
end
重要なポイントは以下です。
- before_saveメソッドに渡しているのはブロックである
- 「小文字に変換する」という処理は、
String
クラスのdowncase
メソッドで実現している - このコードにおける
self
は、現在のユーザーを指す - 代入式右辺の
self
は省略することができる- 一方、代入式左辺の
self
は省略することができない
- 一方、代入式左辺の
この実装で実現できること
- RDBMSのemail属性に一意性制約を追加することにより、email属性の値が重複するユーザーの存在をRDBMSレベルで排除できるようになった
- RDBMSのemail属性にインデックスを追加することにより、検索効率が向上する
- メールアドレスからユーザーを引く際に全表スキャンをしなくて済むようになった
演習 - 一意性を検証する
1.1. リスト 6.33を参考に、メールアドレスを小文字にするテストをリスト 6.32に追加してみましょう。
ちなみに追加するテストコードでは、データベースの値に合わせて更新する
reload
メソッドと、値が一致しているかどうか確認するassert_equal
メソッドを使っています。
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com")
end
...略
test "email addresses should be unique" do
duplicate_user = @user.dup
duplicate_user.email = @user.email.upcase
@user.save
assert_not duplicate_user.valid?
end
+
+ test "email addresses should be saved as lower-case" do
+ mixed_case_email = "Foo@ExAMPle.com"
+ @user.email = mixed_case_email
+ @user.save
+ assert_equal mixed_case_email.downcase, @user.reload.email
+ end
end
"email addresses should be saved as lower-case"
という名前のテストが、設問内容に対応するテストですね。以下の処理を行っています。
- 大文字小文字が混ざったメールアドレスの例を
mixed_case_email
オブジェクトとして定義する - テストで使うユーザーオブジェクトのEmail属性に、
mixed_case_email
を代入する - テストで使うユーザーオブジェクトをRDBMS上に保存する
-
mixed_case_email
の値を全て小文字にしたものと、改めてRDBMSから読み込んだユーザーオブジェクトのemail属性の値が一致していればテスト成功
1.2. リスト 6.33のテストがうまく動いているか確認するためにも、before_save
の行をコメントアウトしてred
になることを確認してみましょう。
class User < ApplicationRecord
- before_save { self.email = email.downcase }
+ # before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
end
上述のコードは、before_save
の行をコメントアウトした状態です。これでテストを実行してみましょう。
# rails test:models
Started with run options --seed 47663
FAIL["test_email_addresses_should_be_saved_as_lower-case", UserTest, 0.11753190000308678]
test_email_addresses_should_be_saved_as_lower-case#UserTest (0.12s)
Expected: "foo@example.com"
Actual: "Foo@ExAMPle.com"
test/models/user_test.rb:60:in `block in <class:UserTest>'
9/9: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.27076s
9 tests, 18 assertions, 1 failures, 0 errors, 0 skips
それらしいエラーメッセージがありますね。以下の部分です。
test_email_addresses_should_be_saved_as_lower-case#UserTest (0.12s)
Expected: "foo@example.com"
Actual: "Foo@ExAMPle.com"
「"foo@example.com"
が入ってくるべきところに、"Foo@ExAMPle.com"
が入ってきている」という趣旨のエラーメッセージです。
1.3. before_save
の行のコメントアウトを解除するとgreen
になることを確認してみましょう。
class User < ApplicationRecord
- # before_save { self.email = email.downcase }
+ before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
end
変更内容は上述となります。テストを実行してみましょう。
# rails test:models
Started with run options --seed 6755
9/9: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.26453s
9 tests, 18 assertions, 0 failures, 0 errors, 0 skips
テストは無事完了しました。
2. テストスイートの実行結果を確認しながら、before_save
コールバックをemail.downcase!
に書き換えてみましょう。
ヒント: メソッドの末尾に!を付け足すことにより、email属性を直接変更できるようになります (リスト 6.34)。
class User < ApplicationRecord
- before_save { self.email = email.downcase }
+ before_save { email.downcase! }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
end
モデルのテストから先に実行してみましょう。コマンドはrails test:models
ですね。
# rails test:models
Started with run options --seed 33265
9/9: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.26898s
9 tests, 18 assertions, 0 failures, 0 errors, 0 skips
無事完了しました。
続いて、テストスイート全体も実行してみましょう。こちらのコマンドはrails test
です。
# rails test
Running via Spring preloader in process 2614
Started with run options --seed 40098
17/17: [=================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.36793s
17 tests, 37 assertions, 0 failures, 0 errors, 0 skips
無事完了しました。