前回の続き。
モデルに対するテスト。モデルのバリデーションやクラスメソッド、インスタンスメソッドをテストしていきます。
#モデルスペックの構造
モデルスペックには、以下の3つのテストを最低限入れます。
1.有効な属性で初期化された場合は、モデルの状態が有効(valid)になっていること
2.バリデーションを失敗させるデータであれば、モデルの状態が有効になっていないこと
3.クラスメソッドとインスタンスメソッドが期待通りに動作すること
#モデルスペック作成
まずはUserモデルのテストです。
前回、スペックファイルの自動生成を設定しましたが、今回は既存のモデルに対するテストなので、手動でスペックファイルを生成します。
$ rails g rspec:model user
このコマンドで、spec/models/user_spec.rbが生成されるので、このファイルにスペックを書いていきます。
repuire 'rails_helper' #ヘルパーの読み込み
RSpec.describe User, type: :model do #ブロック内にUserモデルのスペックをまとめている
#name, email, passwordがあれば有効な状態であること
it 'is valid with a name, email, and password' do #一つ一つのスペックはitで始まるブロック内に記述
user = User.new(
name: "Zeisho",
email: "hoge@hoge.com",
password: "hogehoge"
)
expect(user).to be_valid #userが有効(valid)ならパスする
end
end
RSpecでは、テストしたい値をexpect()に渡し、後ろに続くマッチャを呼び出すことでテストを行います。
今回の場合、userをbe_valid(有効か)というマッチャでテストしています。
また、(user)の後ろのtoは、テストする値がマッチャに適合すればsuccess、しなければfaildをテスト結果として返します。
toの反対の意味を持つto_notもよく使うので、覚えておくと良いでしょう。
#バリデーションのテスト
正しい値を与えたときにモデルが有効になっているかテストできたので、今度はバリデーションを失敗させるデータを与えたときにモデルが無効な状態かテストしていきます。
#nameがなければ無効であること
it 'is invalid without a name' do
user = User.new(
name: nil
email: "hoge@hoge.com"
password: "hogehoge"
)
user.valid?
expect(user.errors[:name]).to include("can't be blank")
end
今回のスペックでは、userのnameをnilにした状態でvalid?メソッドを使ってuserの有効性を検証し、最後にuser.errors[:name]に"can't be blank"という内容のものが含まれていればパスするという内容です。
nameがないことがバリデーションに失敗した原因であるかを知りたいので、発生したエラーの内容をテストしています。
user.valid?で有効性を検証しないとエラーメッセージが出ないので、注意しましょう。
この方法に沿って、他のバリデーションのスペックも書いていきましょう。
#emailが重複していれば無効であること
it 'is invalid without a duplicate email address' do
User.create(
neme: "zeisho"
email: "hoge@hoge.com"
password: "hogehoge"
)
user = User.new(
name: "Skywalker"
email: "hoge@hoge.com"
password: "hogehoge"
)
user.valid?
expect(user.errors[:name]).to include("has already been taken")
end
createを使ってユーザーを保存し、その後で同じemailを持ったユーザーを生成するとemailの重複エラーが出るかという内容のスペックです。
この調子でUserモデルのスペックを完成させましょう。
完成したら、Projectモデルのスペックも作っていきます。
$ rails g rspec:model project
require 'rails_helper'
RSpec.describe Project, type: :model do
# ユーザー単位では重複したプロジェクト名を許可しないこと
it "does not allow duplicate project names per user" do
user = User.create(
name: "Zeisho",
email: "hoge@hoge.com",
password: "hogehoge"
)
user.projects.create(
name: "Test Project"
)
new_project = user.projects.build(
name: "Test Project"
)
new_project.valid?
expect(new_project.errors[:name]).to include("has already been taken")
end
# 二人のユーザーが同じ名前を使うことは許可すること
it "allows two users to share a project name" do
user = User.create(
name: "Zeisho",
email: "hoge@hoge.com",
password: "hogehoge"
)
user.projects.create(
name: "Test Project"
)
other_user = User.create(
name: "Skywalker",
email: "hogehoge@hoge.com",
password: "hogehoge"
)
other_project = other_user.projects.build(
name: "Test Project"
)
expect(other_project).to be_valid
end
end
ProjectはUserに紐づいているので、今の書き方だとテストに必要なインスタンスを作るだけでコードが冗長化してしまいました。この辺りの問題は後で解決していきます。
#クラスメソッド、インスタンスメソッドのテスト
このアプリには、Projectモデルに紐づいたNoteがあり、プロジェクトのメモとして文字列を格納できるようになっており、Noteモデルには検索機能を実装しています。
scope :search, ->(term) {
where("LOWER(message) LIKE ?", "%#{term.downcase}%")
}
今回はこのクラスメソッドとスコープをテストしていきます。
$ rails g rspec:model note
require 'rails_helper'
RSpec.describe Note, type: :model do
#検索文字列に一致するメモを返すこと
it "returns notes that match the search term" do
user = User.cerate(
name: "Zeisho"
email: "hoge@hoge.com"
password: "hogehoge"
)
project = user.projects.create(
name: "Test Project"
)
note1 = project.notes.create(
message: "This is first note.",
user: user
)
note2 = project.notes.create(
message: "This is second note.",
user: user
)
note3 = project.notes.create(
message: "First, preheat the oven.",
user: user
)
expect(Note.search("first")).to include(note1, note3)
expect(Note.search("first")).to_not include(note2)
end
#検索結果が見つからなければ空のコレクションを返すこと
it "returns an empty collection when no results are found" do
user = User.cerate(
name: "Zeisho"
email: "hoge@hoge.com"
password: "hogehoge"
)
project = user.projects.create(
name: "Test Project"
)
note1 = project.notes.create(
message: "This is first note.",
user: user
)
note2 = project.notes.create(
message: "This is second note.",
user: user
)
note3 = project.notes.create(
message: "First, preheat the oven.",
user: user
)
expect(Note.search("message")).to be_empty
end
end
#マッチャについてもっと詳しく
これまで、3つのマッチャ(be_valid, include, be_enpty)を使ってきましたが、RSpecが提供するマッチャをもっと知りたい場合、rspec-expectationsを参照すると良いでしょう。
#スペックをDRYにする
describe, context, before, afterを使ってスペックをDRYにしていきます。
##describe, context
スペック群を分類してブロック内にまとめることができます。
Noteモデルのスペックを例に見ていきましょう。
require 'rails_helper'
RSpec.describe Note, type: :model do
#バリデーション用のスペック群
#メッセージ検索機能のスペック群
describe "search message for a term" do
#一致するデータが見つかるとき
context "when a match is found" do
#一致する場合のexample群
end
#一致するデータが見つからないとき
context "when no match is found" do
#一致しない場合のexample群
end
end
end
##before, after
全てのテストで使用するテストデータを一箇所にまとめることができます。
require 'rails_helper'
RSpec.describe Note, type: :model do
before do
@user = User.cerate(
name: "Zeisho"
email: "hoge@hoge.com"
password: "hogehoge"
)
@project = user.projects.create(
name: "Test Project"
)
end
#バリデーション用のスペック群
#メッセージ検索機能のスペック群
end
beforeには以下のオプションを設定できます。
before(:each)
describeまたはcontextブロック内の各(each)テストの前に実行
before(:all)
describeまたはcontextブロック内の全(all)テストの前に一回だけ実行
before(suite)
テストスイート全体の全ファイルを実行する前に実行
exampleの後に後片付けが必要であれば、afterを使うことができます。
##テストはDRYにし過ぎない!
テストは開発・本番環境とは違って可読性を優先してDRYにしていくので、もしスペックファイルの内容を確認するのにエディタのスクロールや、複数のファイルの行き来を頻繁に行っているならば、DRY過ぎます。
必要に応じてコードを重複させることを検討したり、ファイルを行き来しなくても役割のわかる変数・メソッド名をつけるよう心がけましょう。