3. モデルスペック

RSpecのインストールが完了し、これで信頼性の高いテストスイートを構築する準備が整いました。まずアプリケーションのコアとなる部分、すなわちモデルから始めてみましょう。

本章では次のようなタスクを完了させます。

  • まず既存のモデルに対してモデルスペックを作ります。
  • それからモデルのバリデーション、クラスメソッド、インスタンスメソッドのテストを書きます。テストを作りながらスペックの整理もします。

既存のモデルがあるので、最初のスペックファイルは手作業で追加します。それから新しいモデルをアプリケーションに追加します。こうすると第2章で設定した便利なRSpecのジェネレータが仮のファイルを作成してくれます。

モデルスペックの構造

私はモデルレベルのテストが一番学習しやすいと思います。なぜならモデルをテストすればアプリケーションのコアとなる部分をテストすることになるからです。このレベルのコードが十分にテストされていれば土台が堅牢になり、そこから信頼性の高いコードベースを構築できます。

はじめに、モデルスペックには次のようなテストを含めましょう。

  • 有効な属性で初期化された場合は、モデルの状態が有効(valid)になっていること
  • バリデーションを失敗させるデータであれば、モデルの状態が有効になっていないこと
  • クラスメソッドとインスタンスメソッドが期待通りに動作すること

良い機会なので、ここでモデルスペックの基本構成を見てみましょう。スペックの記述をアウトラインと考えるのが便利です。たとえば、メインとなるUserモデルの要件を見てみましょう。

describe User do
  # 姓、名、メール、パスワードがあれば有効な状態であること
  it "is valid with a first name, last name, email, and password"
  # 名がなければ無効な状態であること
  it "is invalid without a first name"
  # 姓がなければ無効な状態であること
  it "is invalid without a last name"
  # メールアドレスがなければ無効な状態であること
  it "is invalid without an email address"
  # 重複したメールアドレスなら無効な状態であること
  it "is invalid with a duplicate email address"
  # ユーザーのフルネームを文字列として返すこと
  it "returns a user's full name as a string"
end

このアウトラインはすぐあとに展開していきますが、初心者はここからたくさんのことが学べます。これは本当にシンプルなモデルのシンプルなスペックです。しかし、次のような4つのベストプラクティスを示しています。

  • 期待する結果をまとめて記述(describe)している。 このケースではUserモデルがどんなモデルなのか、そしてどんな振る舞いをするのかということを説明しています。
  • example( it で始まる1行)一つにつき、結果を一つだけ期待している。 私が first_namelast_nameemail のバリデーションをそれぞれ分けてテストしている点に注意してください。こうすれば、exampleが失敗したときに問題が起きたバリデーションを 特定 できます。原因調査のためにRSpecの出力結果を調べる必要はありません。少なくともそこまで細かく調べずに済むはずです。
  • どのexampleも明示的である。 技術的なことを言うと、 it のあとに続く説明用の文字列は必須ではありません。しかし、省略してしまうとスペックが読みにくくなります。
  • 各exampleの説明は動詞で始まっている。shouldではない。 期待する結果を声に出して読んでみましょう。 User is invalid without a first name (名がなければユーザーは無効な状態である)、 User is invalid without a last name (姓がなければユーザーは無効な状態である)、 User returns a user’s full name as a string (ユーザーは文字列としてユーザーのフルネームを返す)。可読性は非常に重要であり、RSpecのキーとなる機能です!

こうしたベストプラクティスを念頭に置きながら User モデルのスペックを書いてみましょう。

モデルスペックを作成する

第2章ではモデルやコントローラを追加するたびに定型のテストファイルが自動的に作成されるようにRSpecをセットアップしました。ジェネレータはいつでも起動できます。最初のモデルスペックを作成するため、この作業の出発地点となるファイルを実際に生成してみましょう。

まず、 rspec:model ジェネレータをコマンドラインから実行してください。

$ bin/rails g rspec:model user

RSpecは新しいファイルを作成したことを報告します。

      create  spec/models/user_spec.rb

作成されたファイルを開き、内容を確認しましょう。

spec/models/user_spec.rb
1 require 'rails_helper'
2 
3 RSpec.describe User, type: :model do
4   pending "add some examples to (or delete) #{__FILE__}"
5 end

この新しいファイルを見れば、RSpecの構文と規約がわかります。まず、このファイルでは rails_helperrequire しています。この記述はテストスイート内のほぼすべてのファイルで必要になります。この記述でRSpecに対し、ファイル内のテストを実行するためにRailsアプリケーションの読み込みが必要であることを伝えています。次に、 describe メソッドを使って、 User という名前の モデル のテストをここに書くことを示しています。 pending 機能については第9章で説明しますが、とりあえずここでは bundle exec rspec を使ってこのテストを実行してみましょう。いったい何が起こるでしょうか?

User
  add some examples to (or delete)
  /Users/asumner/code/examples/projects/spec/models/user_spec.rb
  (PENDING: Not yet implemented)

Pending: (Failures listed here are expected and do not affect your
suite's status)

  1) User add some examples to (or delete)
  /Users/asumner/code/examples/projects/spec/models/user_spec.rb
     # Not yet implemented
     # ./spec/models/user_spec.rb:4


Finished in 0.00107 seconds (files took 0.43352 seconds to load)
1 example, 0 failures, 1 pending

describe の外枠はそのままにして、その内側を先ほど作成したアウトラインに置き換えてみましょう。

spec/models/user_spec.rb
 1 require 'rails_helper'
 2 
 3 RSpec.describe User, type: :model do
 4   # 姓、名、メール、パスワードがあれば有効な状態であること
 5   it "is valid with a first name, last name, email, and password"
 6   # 名がなければ無効な状態であること
 7   it "is invalid without a first name"
 8   # 姓がなければ無効な状態であること
 9   it "is invalid without a last name"
10   # メールアドレスがなければ無効な状態であること
11   it "is invalid without an email address"
12   # 重複したメールアドレスなら無効な状態であること
13   it "is invalid with a duplicate email address"
14   # ユーザーのフルネームを文字列として返すこと
15   it "returns a user's full name as a string"
16 end

詳細はこのあと追加していきますが、この状態でコマンドラインからスペックを実行すると(コマンドラインから bundle exec rspec とタイプしてください)、出力結果は次のようになります。

User
  is valid with a first name, last name, email, and password (PENDING:
  Not yet implemented)
  is invalid without a first name (PENDING: Not yet implemented)
  is invalid without a last name (PENDING: Not yet implemented)
  is invalid without an email address (PENDING: Not yet implemented)
  is invalid with a duplicate email address (PENDING: Not yet implemented)
  returns a user's full name as a string (PENDING: Not yet implemented)

Pending: (Failures listed here are expected and do not affect your
suite's status)

  1) User is valid with a first name, last name, email, and password
     # Not yet implemented
     # ./spec/models/user_spec.rb:5

  2) User is invalid without a first name
     # Not yet implemented
     # ./spec/models/user_spec.rb:7

  3) User is invalid without a last name
     # Not yet implemented
     # ./spec/models/user_spec.rb:9

  4) User is invalid without an email address
     # Not yet implemented
     # ./spec/models/user_spec.rb:11

  5) User is invalid with a duplicate email address
     # Not yet implemented
     # ./spec/models/user_spec.rb:13

  6) User returns a user's full name as a string
     # Not yet implemented
     # ./spec/models/user_spec.rb:15


Finished in 0.00176 seconds (files took 2.18 seconds to load)
6 examples, 0 failures, 6 pending

すばらしい!6つの保留中(pending)のスペックができあがりました。私たちはまだ実行可能なテストを何も書いていないので、RSpecはここで作成したスペックを pending と表示しています。それでは実際にテストを書いていきましょう。まずは一番最初のexampleから始めます。

RSpecの構文

その昔、RSpecは「~が期待した結果と一致すべきだ/すべきでない(something should or should_not match expected output)」と読むことができる should 構文を使っていました。

しかし、2012年にリリースされたRSpec 2.11からは「私は~が~になる/ならないことを期待する(I expect something to or not_to be something else)」と読むことができる expect 構文に変わりました。構文が変わったのは、古い構文でときどき発生していた技術的な問題を回避するためです。

2つの構文を比較するために、簡単なテスト、つまりエクスペクテーション(expectation、期待する内容)の使用例を見てみましょう。このexampleの場合、2 + 1はいつでも3に等しいはずですよね?古いRSpecの構文ではこのように書きます。

# 2と1を足すと3になること
it "adds 2 and 1 to make 3" do
  (2 + 1).should eq 3
end

現行の expect 構文ではテストする値を expect() メソッドに渡し、それに続けてマッチャを呼び出します。

# 2と1を足すと3になること
it "adds 2 and 1 to make 3" do
  expect(2 + 1).to eq 3
end

GoogleやStack OverflowでRSpecに関する質問を検索したり、古いRailsアプリケーションを開発したりすると、古い should 構文を使ったコードを今でも見かけることがあるかもしれません。この構文は現行バージョンのRSpecでも動作しますが、使うと非推奨であるとの警告が出力されます。設定を変更すればこの警告を出力しないようにすることも できます が、そんなことはせずに新しい expect() 構文を学習した方が良いと思います。

では、実際のexampleではどうなるでしょうか?Userモデルの最初のエクスペクテーションで使ってみましょう。

spec/models/user_spec.rb
 1 require 'rails_helper'
 2 
 3 RSpec.describe User, type: :model do
 4   # 姓、名、メール、パスワードがあれば有効な状態であること
 5   it "is valid with a first name, last name, email, and password" do
 6     user = User.new(
 7       first_name: "Aaron",
 8       last_name:  "Sumner",
 9       email:      "tester@example.com",
10       password:   "dottle-nouveau-pavilion-tights-furze",
11     )
12     expect(user).to be_valid
13   end
14 
15   # 名がなければ無効な状態であること
16   it "is invalid without a first name"
17   # 姓がなければ無効な状態であること
18   it "is invalid without a last name"
19   # メールアドレスがなければ無効な状態であること
20   it "is invalid without an email address"
21   # 重複したメールアドレスなら無効な状態であること
22   it "is invalid with a duplicate email address"
23   # ユーザーのフルネームを文字列として返すこと
24   it "returns a user's full name as a string"
25 end

この単純なexampleは be_valid というRSpecのマッチャを使って、モデルが有効な状態を理解できているかどうかを検証しています。まずオブジェクトを作成し(このケースでは新しく作られているが保存はされていない User クラスのインスタンスを作成し、 user という名前の変数に格納しています)、それからオブジェクトを expect に渡して、マッチャと比較しています。

それでは bundle exec rspec をコマンドラインから再実行してみましょう。すると、1つのexampleがパスしたと表示されるはずです。

User
  is valid with a first name, last name and email, and password
  is invalid without a first name (PENDING: Not yet implemented)
  is invalid without a last name (PENDING: Not yet implemented)
  is invalid without an email address (PENDING: Not yet implemented)
  is invalid with a duplicate email address (PENDING: Not yet implemented)
  returns a user's full name as a string (PENDING: Not yet implemented)

Pending: (Failures listed here are expected and do not affect your
suite's status)

  1) User is invalid without a first name
     # Not yet implemented
     # ./spec/models/user_spec.rb:16

  2) User is invalid without a last name
     # Not yet implemented
     # ./spec/models/user_spec.rb:18

  3) User is invalid without an email address
     # Not yet implemented
     # ./spec/models/user_spec.rb:20

  4) User is invalid with a duplicate email address
     # Not yet implemented
     # ./spec/models/user_spec.rb:22

  5) User returns a user's full name as a string
     # Not yet implemented
     # ./spec/models/user_spec.rb:24


Finished in 0.02839 seconds (files took 0.28886 seconds to load)
6 examples, 0 failures, 5 pending

おめでとうございます。これで最初のテストが完成しました!ではこれからもっとコードをテストしていって、保留中のテストを完全になくしてしまいましょう。

バリデーションをテストする

バリデーションはテストの自動化に慣れるための良い題材です。バリデーションのテストはたいてい1~2行で書けます。では first_name バリデーションのスペックについて詳細を見てみましょう。

spec/models/user_spec.rb
1 # 名がなければ無効な状態であること
2 it "is invalid without a first name" do
3   user = User.new(first_name: nil)
4   user.valid?
5   expect(user.errors[:first_name]).to include("can't be blank")
6 end

今回は新しく作ったユーザー( first_name には明示的に nil をセットします)に対して valid? メソッドを呼び出すと有効(valid)に ならず 、ユーザーの first_name 属性にエラーメッセージが付いていることを 期待(expect) します。RSpecをもう一度実行すると、二番目までのスペックがパスするはずです。ここでRSpecの include マッチャについて確認しましょう。このマッチャは繰り返し可能な値(enumerable value)の中に、ある値が存在するかどうかをチェックします。ではRSpecをもう一度実行します。すると、今回は2つのスペックがパスするはずです。

ここまでのアプローチにはちょっとした問題があります。現時点で2つのテストがパスしていますが、私たちはまだテストが 失敗 するところを見ていません。これは警告すべき兆候です。特に、テストを書き始めたタイミングであればなおさらです。私たちはテストコードが意図した通りに動いていることを確認しなければなりません。これは「テスト対象のコードでいろいろ試すアプローチ(exercising the code under test)」としても知られています。

誤判定ではないことを証明するためには二つのやり方があります。ひとつめは、 toto_not に変えてエクスペクテーションを反転させてみます。

spec/models/user_spec.rb
1 # 名がなければ無効な状態であること
2 it "is invalid without a first name" do
3   user = User.new(first_name: nil)
4   user.valid?
5   expect(user.errors[:first_name]).to_not include("can't be blank")
6 end

当然のごとく、RSpecはテストの失敗を報告します。

Failures:

  1) User is invalid without a first name
     Failure/Error: expect(user.errors[:first_name]).to_not
     include("can't be blank")
       expected ["can't be blank"] not to include "can't be blank"
     # ./spec/models/user_spec.rb:17:in `block (2 levels) in <main>'

Finished in 0.06211 seconds (files took 0.28541 seconds to load)
6 examples, 1 failure, 5 pending

Failed examples:

rspec ./spec/models/user_spec.rb:14 # User is invalid without a first name

もうひとつ、アプリケーション側のコードを変更して、テストの実行結果にどんな変化が起きるか確認する方法もあります。先ほどのテストコードの変更を元に戻し( to_notto に戻す)、それから User モデルを開いて first_name のバリデーションをコメントアウトしてください。

app/models/user.rb
 1 class User < ApplicationRecord
 2   # Include default devise modules. Others available are:
 3   # :confirmable, :lockable, :timeoutable and :omniauthable
 4   devise :database_authenticatable, :registerable,
 5          :recoverable, :rememberable, :trackable, :validatable
 6 
 7   # validates :first_name, presence: true
 8   validates :last_name, presence: true
 9 
10   # 残りのコードは省略 ...

スペックを再実行すると、再度失敗が表示されるはずです。これはすなわち、私たちはRSpecに対して名を持たないユーザーは無効であると伝えたのに、アプリケーション側がその仕様を実装していないことを意味しています。

この二つの方法は、自分の書いたテストが期待どおりに動いているかどうか確認する簡単な方法です。シンプルなバリデーションからもっと複雑なロジックに進むときであれば、特に有効です。また、この方法は既存のアプリケーションをテストするためにも有効です。もしテストの出力結果に何も変化がなければ、それはよいチャンスです。変化がない場合は、テストがアプリケーション側のコードと連携していなかったり、コードが期待した動きと異なっていたりすることを意味しています。

では、 :last_name のバリデーションも同じアプローチでテストしてみましょう。

spec/models/user_spec.rb
1 # 姓がなければ無効な状態であること
2 it "is invalid without a last name" do
3   user = User.new(last_name: nil)
4   user.valid?
5   expect(user.errors[:last_name]).to include("can't be blank")
6 end

「こんなテストは役に立たない。モデルに含まれるすべてのバリデーションを確認しようとしたらどれくらい大変になるのかわかっているのか?」そんなふうに思っている人もいるかもしれません。ですが、実際はあなたが考えている以上にバリデーションは書き忘れやすいものです。しかし、それよりもっと大事なことは、テストを書いている 最中に モデルが持つべきバリデーションについて考えれば、バリデーションの追加を忘れにくくなるということです。(このプロセスはテスト駆動開発でコードを書くのが理想的ですし、最後は実際そうします。)

ここまでに得た知識を使って、もう少し複雑なテストを書いてみましょう。今回はemail属性のユニークバリデーションをテストします。

spec/models/user_spec.rb
 1 # 重複したメールアドレスなら無効な状態であること
 2 it "is invalid with a duplicate email address" do
 3   User.create(
 4     first_name:  "Joe",
 5     last_name:  "Tester",
 6     email:      "tester@example.com",
 7     password:   "dottle-nouveau-pavilion-tights-furze",
 8   )
 9   user = User.new(
10     first_name:  "Jane",
11     last_name:  "Tester",
12     email:      "tester@example.com",
13     password:   "dottle-nouveau-pavilion-tights-furze",
14   )
15   user.valid?
16   expect(user.errors[:email]).to include("has already been taken")
17 end

ここではちょっとした違いがあることに注意してください。このケースではテストの前にユーザーを保存しました( User に対して new の代わりに create を呼んでいます)。それから2件目のユーザーをテスト対象のオブジェクトとしてインスタンス化しました。もちろん、最初に保存されたユーザーは有効な状態(姓、名、メール、パスワードが全部ある)であり、なおかつ、同一のメールアドレスも設定されている必要があります。第4章ではこのプロセスをもっと効率よく処理する方法を説明します。では、 bundle exec rspec を実行して新しいテストの出力結果を確認してください。

続いてもっと複雑なバリデーションをテストしましょう。Userモデルの話はいったん横に置いて、今度はProjectモデルに着目します。たとえば、ユーザーは同じ名前のプロジェクトを作成できないという要件があったとします。つまり、プロジェクト名はユーザーごとにユニークでなければならない、ということです。別の言い方をすると、私は Paint the house (家を塗る)という複数のプロジェクトを持つことはできないが、あなたと私はそれぞれ Paint the house というプロジェクトを持つことができる、ということです。あなたならどうやってテストしますか?

ではProjectモデル用に新しいスペックファイルを作成しましょう。

$ bin/rails g rspec:model project

続いて、作成されたファイルに二つのexampleを追加します。ここでテストしたいのは、一人のユーザーは同じ名前で二つのプロジェクトを作成できないが、ユーザーが異なるときは同じ名前のプロジェクトを作成できる、という要件です。

spec/models/project_spec.rb
 1 require 'rails_helper'
 2 
 3 RSpec.describe Project, type: :model do
 4   # ユーザー単位では重複したプロジェクト名を許可しないこと
 5   it "does not allow duplicate project names per user" do
 6     user = User.create(
 7       first_name: "Joe",
 8       last_name:  "Tester",
 9       email:      "joetester@example.com",
10       password:   "dottle-nouveau-pavilion-tights-furze",
11     )
12 
13     user.projects.create(
14       name: "Test Project",
15     )
16 
17     new_project = user.projects.build(
18       name: "Test Project",
19     )
20 
21     new_project.valid?
22     expect(new_project.errors[:name]).to include("has already been taken")
23   end
24 
25   # 二人のユーザーが同じ名前を使うことは許可すること
26   it "allows two users to share a project name" do
27     user = User.create(
28       first_name: "Joe",
29       last_name:  "Tester",
30       email:      "joetester@example.com",
31       password:   "dottle-nouveau-pavilion-tights-furze",
32     )
33 
34     user.projects.create(
35       name: "Test Project",
36     )
37 
38     other_user = User.create(
39       first_name: "Jane",
40       last_name:  "Tester",
41       email:      "janetester@example.com",
42       password:   "dottle-nouveau-pavilion-tights-furze",
43     )
44 
45     other_project = other_user.projects.build(
46       name: "Test Project",
47     )
48 
49     expect(other_project).to be_valid
50   end
51 end

今回は User モデルと Project モデルがActive Recordのリレーションで互いに関連するため、そのぶん多くの情報を記述する必要があります。最初のexampleでは両方のプロジェクトを割り当てられた一人のユーザーがいます。二つ目のexampleでは二つの別々のプロジェクトに同じ名前が割り当てられ、それらが別々のユーザーに属しています。ここでは以下の点に注意してください。二つのexampleはどちらもユーザーを create してデータベースに保存しています。これはユーザーをテスト対象のプロジェクトに割り当てる必要があるためです。

Project モデルには以下のようなバリデーションが設定されています。

app/models/project.rb
validates :name, presence: true, uniqueness: { scope: :user_id }

今回作成したスペックは問題なくパスします。ですが、例のチェックをお忘れなく。一時的にバリデーションをコメントアウトしたり、テストを書き換えたりして、結果が変わることを確認してください。テストはちゃんと失敗するでしょうか?

もちろん、バリデーションはscopeが一つしかないような単純なものばかりではなく、もっと複雑になる場合があります。もしかするとあなたは複雑な正規表現やカスタムバリデータを使っているかもしれません。こうしたバリデーションもテストする習慣を付けてください。正常系のパターンだけでなく、エラーが発生する条件もテストしましょう。たとえば、これまでに作ってきたexampleではオブジェクトが nil で初期化された場合の実行結果もテストしました。もし数値しか受け付けない属性のバリデーションがあるなら、文字列を渡してください。もし4文字から8文字の文字列を要求するバリデーションがあるなら、3文字と9文字の文字列を渡してください。

インスタンスメソッドをテストする

それではUserモデルのテストに戻ります。このサンプルアプリケーションでは、ユーザーの姓と名を毎回連結して新しい文字列を作るより、 @user.name を呼び出すだけでフルネームが出力されるようにした方が便利です。というわけでこんなメソッドが User クラスに作ってあります。

app/models/user.rb
def name
  [first_name, last_name].join(' ')
end

バリデーションのexampleと同じ基本的なテクニックでこの機能のexampleを作ることができます。

spec/models/user_spec.rb
1 it "returns a user's full name as a string" do
2   user = User.new(
3     first_name: "John",
4     last_name:  "Doe",
5     email:      "johndoe@example.com",
6   )
7   expect(user.name).to eq "John Doe"
8 end

テストデータを作り、それからあなたが期待する振る舞いをRSpecに教えてあげてください。簡単ですね。では続けましょう。

クラスメソッドとスコープをテストする

このアプリケーションには渡された文字列でメモ(note)を検索する機能を用意してあります。念のため説明しておくと、この機能はNoteモデルにスコープとして実装されています。

app/models/note.rb
1 scope :search, ->(term) {
2   where("LOWER(message) LIKE ?", "%#{term.downcase}%")
3 }

ではNoteモデル用に3つめのファイルをテストスイートに追加しましょう。 rspec:model ジェネレータでファイルを作ったら、最初のテストを追加してください。

spec/models/note_spec.rb
 1 require 'rails_helper'
 2 
 3 RSpec.describe Note, type: :model do
 4   # 検索文字列に一致するメモを返すこと
 5   it "returns notes that match the search term" do
 6     user = User.create(
 7       first_name: "Joe",
 8       last_name:  "Tester",
 9       email:      "joetester@example.com",
10       password:   "dottle-nouveau-pavilion-tights-furze",
11     )
12 
13     project = user.projects.create(
14       name: "Test Project",
15     )
16 
17     note1 = project.notes.create(
18       message: "This is the first note.",
19       user: user,
20     )
21     note2 = project.notes.create(
22       message: "This is the second note.",
23       user: user,
24     )
25     note3 = project.notes.create(
26       message: "First, preheat the oven.",
27       user: user,
28     )
29 
30     expect(Note.search("first")).to include(note1, note3)
31     expect(Note.search("first")).to_not include(note2)
32   end
33 end

search スコープは検索文字列に一致するメモのコレクションを返します。返されたコレクションは一致したメモだけが含まれるはずです。その文字列を含まないメモはコレクションに含まれません。

このテストでは次のような実験ができます。 toto_not に変えたらどうなるでしょうか?もしくは検索文字列を含むメモをさらに追加したらどうなるでしょうか?

失敗をテストする

正常系のテストは終わりました。ユーザーが文字列検索すると結果が返ってきます。しかし、結果が返ってこない文字列で検索したときはどうでしょうか?そんな場合もテストした方が良いです。次のスペックがそのテストになります。

spec/models/note_spec.rb
 1 require 'rails_helper'
 2 
 3 RSpec.describe Note, type: :model do
 4   # 検索結果を検証するスペック...
 5 
 6   # 検索結果が1件も見つからなければ空のコレクションを返すこと
 7   it "returns an empty collection when no results are found" do
 8     user = User.create(
 9       first_name: "Joe",
10       last_name:  "Tester",
11       email:      "joetester@example.com",
12       password:   "dottle-nouveau-pavilion-tights-furze",
13     )
14 
15     project = user.projects.create(
16       name: "Test Project",
17     )
18 
19     note1 = project.notes.create(
20       message: "This is the first note.",
21       user: user,
22     )
23     note2 = project.notes.create(
24       message: "This is the second note.",
25       user: user,
26     )
27     note3 = project.notes.create(
28       message: "First, preheat the oven.",
29       user: user,
30     )
31 
32     expect(Note.search("message")).to be_empty
33   end
34 end

このスペックでは Note.search("message") を実行して返却された配列をチェックします。この配列は 確かに 空なのでスペックはパスします!これで理想的な結果、すなわち結果が返ってくる文字列で検索した場合だけでなく、結果が返ってこない文字列で検索した場合もテストしたことになります。

マッチャについてもっと詳しく

これまで四つのマッチャ( be_valideqincludebe_empty )を実際に使いながら見てきました。最初に使ったのは be_valid です。このマッチャは rspec-rails gemが提供するマッチャで、Railsのモデルの有効性をテストします。 eqincluderspec-expectations で定義されているマッチャで、前章でRSpecをセットアップしたときに rspec-rails と一緒にインストールされました。

RSpecが提供するデフォルトのマッチャをすべて見たい場合はGitHubにあるrspec-expectationsリポジトリREADME が参考になるかもしれません。この中に出てくるマッチャのいくつかは本書全体を通して説明していきます。また、第8章では自分でカスタムマッチャを作る方法も説明します。

describe、context、before、afterを使ってスペックをDRYにする

ここまでに作成したメモ用のスペックには冗長なコードが含まれます。具体的には、各exampleの中ではまったく同じ4つのオブジェクトを作成しています。アプリケーションコードと同様に、DRY原則はテストコードにも当てはまります(いくつか例外もあるので、のちほど説明します)。ではRSpecの機能をさらに活用してテストコードをきれいにしてみましょう。

先ほど作った Note モデルのスペックに注目してみましょう。まず最初にやるべきことは describe ブロックを describe Note ブロックの 中に 作成することです。これは検索機能にフォーカスするためです。アウトラインを抜き出すと、このようになります。

spec/models/note_spec.rb
 1 require 'rails_helper'
 2 
 3 RSpec.describe Note, type: :model do
 4 
 5   # バリデーション用のスペックが並ぶ
 6 
 7   # 文字列に一致するメッセージを検索する
 8   describe "search message for a term" do
 9     # 検索用のexampleが並ぶ ...
10   end
11 end

二つの context ブロックを加えてさらにexampleを切り分けましょう。一つは「一致するデータが見つかるとき」で、もう一つは「一致するデータが1件も見つからないとき」です。

spec/models/note_spec.rb
 1 require 'rails_helper'
 2 
 3 RSpec.describe Note, type: :model do
 4 
 5   # 他のスペックが並ぶ
 6 
 7   # 文字列に一致するメッセージを検索する
 8   describe "search message for a term" do
 9 
10     # 一致するデータが見つかるとき
11     context "when a match is found" do
12       # 一致する場合のexampleが並ぶ ...
13     end
14 
15     # 一致するデータが1件も見つからないとき
16     context "when no match is found" do
17       # 一致しない場合のexampleが並ぶ ...
18     end
19   end
20 end

お気づきかもしれませんが、このようにexampleのアウトラインを作ると、同じようなexampleをひとまとめにして分類できます。こうするとスペックがさらに読みやすくなります。では最後に、 before フックを利用してスペックのリファクタリングを完了させましょう。 before ブロックの中に書かれたコードは内側の各テストが実行される前に実行されます。また、 before ブロックは describecontext ブロックによってスコープが限定されます。たとえばこの例で言うと、 before ブロックのコードは "search message for a term" ブロックの内側にある全部のテストに先立って実行されます。ですが、新しく作ったdescribeブロックの外側にあるその他のexampleの前には実行されません。

spec/models/note_spec.rb
 1 require 'rails_helper'
 2 
 3 RSpec.describe Note, type: :model do
 4 
 5   before do
 6     # このファイルの全テストで使用するテストデータをセットアップする
 7   end
 8 
 9   # バリデーションのテストが並ぶ
10 
11   # 文字列に一致するメッセージを検索する
12   describe "search message for a term" do
13 
14     before do
15       # 検索機能の全テストに関連する追加のテストデータをセットアップする
16     end
17 
18     # 一致するデータが見つかるとき
19     context "when a match is found" do
20       # 一致する場合のexampleが並ぶ ...
21     end
22 
23     # 一致するデータが1件も見つからないとき
24     context "when no match is found" do
25       # 一致しない場合のexampleが並ぶ ...
26     end
27   end
28 end

RSpecの before フックはスペック内の冗長なコードを認識し、きれいにするための良い出発点になります。これ以外にも冗長なテストコードをきれいにするテクニックはありますが、 before を使うのが最も一般的かもしれません。 before ブロックはexampleごとに、またはブロック内の各exampleごとに、またはテストスイート全体を実行するごとに実行されます。

  • before(:each)describe または context ブロック内の 各(each) テストの前に実行されます。好みに応じて before(:example) というエイリアスを使ってもいいですし、上のサンプルコードで書いたように before だけでも構いません。もしブロック内に4つのテストがあれば、 before のコードも4回実行されます。
  • before(:all)describe または context ブロック内の 全(all) テストの前に一回だけ実行されます。かわりに before(:context) というエイリアスを使っても構いません。こちらは before のコードは一回だけ実行され、それから4つのテストが実行されます。
  • before(:suite) はテストスイート全体の全ファイルを実行する前に実行されます。

before(:all)before(:suite) は時間のかかる独立したセットアップ処理を1回だけ実行し、テスト全体の実行時間を短くするのに役立ちます。ですが、この機能を使うとテスト全体を汚染してしまう原因にもなりかねません。可能な限り before(:each) を使うようにしてください。

もしexampleの実行後に後片付けが必要になるのであれば(たとえば外部サービスとの接続を切断する場合など)、 after フックを使って各exampleのあと(after)に後片付けすることもできます。 before と同様、 after にも eachallsuite のオプションがあります。RSpecの場合、デフォルトでデータベースの後片付けをやってくれるので、私は after を使うことはほとんどありません。

さて、整理後の全スペックを見てみましょう。

spec/models/note_spec.rb
 1 require 'rails_helper'
 2 
 3 RSpec.describe Note, type: :model do
 4   before do
 5     @user = User.create(
 6       first_name: "Joe",
 7       last_name:  "Tester",
 8       email:      "joetester@example.com",
 9       password:   "dottle-nouveau-pavilion-tights-furze",
10     )
11 
12     @project = @user.projects.create(
13       name: "Test Project",
14     )
15   end
16 
17   # ユーザー、プロジェクト、メッセージがあれば有効な状態であること
18   it "is valid with a user, project, and message" do
19     note = Note.new(
20       message: "This is a sample note.",
21       user: @user,
22       project: @project,
23     )
24     expect(note).to be_valid
25   end
26 
27   # メッセージがなければ無効な状態であること
28   it "is invalid without a message" do
29     note = Note.new(message: nil)
30     note.valid?
31     expect(note.errors[:message]).to include("can't be blank")
32   end
33 
34   # 文字列に一致するメッセージを検索する
35   describe "search message for a term" do
36     before do
37       @note1 = @project.notes.create(
38         message: "This is the first note.",
39         user: @user,
40       )
41       @note2 = @project.notes.create(
42         message: "This is the second note.",
43         user: @user,
44       )
45       @note3 = @project.notes.create(
46         message: "First, preheat the oven.",
47         user: @user,
48       )
49     end
50 
51     # 一致するデータが見つかるとき
52     context "when a match is found" do
53       # 検索文字列に一致するメモを返すこと
54       it "returns notes that match the search term" do
55         expect(Note.search("first")).to include(@note1, @note3)
56       end
57     end
58 
59     # 一致するデータが1件も見つからないとき
60     context "when no match is found" do
61       # 空のコレクションを返すこと
62       it "returns an empty collection" do
63         expect(Note.search("message")).to be_empty
64       end
65     end
66   end
67 end

みなさんはもしかするとテストデータのセットアップ方法が少し変わったことに気づいたかもしれません。セットアップの処理を各テストから before ブロックに移動したので、各ユーザーはインスタンス変数にアサインする必要があります。そうしないとテストの中で変数名を指定してデータにアクセスできないからです。

これらのスペックを実行すると、こんなふうに素敵なアウトラインが表示されます(第2章でドキュメント形式を使うようにRSpecを設定したからです)。

Note
  is valid with a user, project, and message
  is invalid without a message
  search message for a term
    when a match is found
      returns notes that match the search term
    when no match is found
      returns an empty collection

Project
  does not allow duplicate project names per user
  allows two users to share a project name

User
  is valid with a first name, last name and email, and password
  is invalid without a first name
  is invalid without a last name
  is invalid with a duplicate email address
  returns a user's full name as a string

Finished in 0.22564 seconds (files took 0.32225 seconds to load)
11 examples, 0 failures
どれくらいDRYだとDRYすぎるのか?

本章では長い時間をかけてスペックを理解しやすいブロックに分けて整理しました。しかし、これは弊害を起こしやすい機能です。

exampleのテスト条件をセットアップする際、可読性を考えてDRY原則に違反するのは問題ありません。私はそう考えています。もし自分がテストしている内容を確認するために、大きなスペックファイルを頻繁にスクロールしているようなら(もしくはあとで説明する外部のサポートファイルを大量に読み込んでいるようなら)、テストデータのセットアップを小さな describe ブロックの中で重複させることを検討してください。 describe ブロックの中だけでなく、exampleの中でもOKです。

とはいえ、そんな場合でも変数とメソッドに良い名前を付けるのは大変効果的です。たとえば上のスペックでは @note1@note2@note3 のような名前をテスト用のメモに使いました。しかし、場合によっては @matching_note (一致するメモ)や @note_with_numbers_only (数字だけのメモ)といった変数名を使いたくなるかもしれません。何が適切かはテストする内容に依りますが、一般論としてはわかりやすい変数名とメソッド名を付けるように心がけてください!

このトピックについては第8章でさらに詳しく説明します。

まとめ

本章ではモデルのテストにフォーカスしましたが、このあとに登場するモデル以外のスペックでも使えるその他の重要なテクニックもたくさん説明しました。

  • 期待する結果は能動形で明示的に記述すること。 exampleの結果がどうなるかを動詞を使って説明してください。チェックする結果はexample一つに付き一個だけにしてください。
  • 起きて ほしい ことと、起きて ほしくない ことをテストすること。 exampleを書くときは両方のパスを考え、その考えに沿ってテストを書いてください。
  • 境界値テストをすること。 もしパスワードのバリデーションが4文字以上10文字以下なら、8文字のパスワードをテストしただけで満足しないでください。4文字と10文字、そして3文字と11文字もテストするのが良いテストケースです。(もちろん、なぜそんなに短いパスワードを許容し、なぜそれ以上長いパスワードを許容しないのか、と自問するチャンスかもしれません。テストはアプリケーションの要件とコードを熟考するための良い機会でもあります。)
  • 可読性を上げるためにスペックを整理すること。 describecontext はよく似たexampleを分類してアウトライン化します。 before ブロックと after ブロックは重複を取り除きます。しかし、テストの場合はDRYであることよりも読みやすいことの方が重要です。もし頻繁にスペックファイルをスクロールしていることに気付いたら、それはちょっとぐらいリピートしても問題ないというサインです。

アプリケーションに堅牢なモデルスペックを揃えたので、あなたは順調にコードの信頼性を上げてきています。

Q&A

describeとcontextはどう使い分けるべきでしょうか? RSpecの立場からすれば、あなたはいつでも好きなときに describe が使えます。RSpecの他の機能と同じく、 context はあなたのスペックを読みやすくするためにあります。私が本章でやったように、一つの条件をまとめるために context を使うのも良いですし、アプリケーションの状態(たとえば「発射準備完了」状態のロケットと、「準備未完了」状態のロケットなど)をまとめるために context を使うこともできます。

演習問題

サンプルアプリケーションにモデルのテストをさらに追加する。 私はモデルが持つ一部の機能にしかテストを追加していません。たとえば、Projectモデルのスペックにはバリデーションのスペックが欠けています。それを今、追加してみてください。もしあなたがRSpecを使ってテストできるように設定された自分自身のアプリケーションを持っているなら、そこにもモデルスペックを追加してみてください。