ポイントシステム(7) -- context
2013/10/05
前回の末尾で予告したとおり、今回は、context
メソッドの使い方について説明します。
context
メソッドによるエグザンプルグループの階層化
エグザンプルグループの階層化について書くのは、この連載で2度目です。ユーザー認証のテスト(4) -- YAGNIの回では describe
メソッドを入れ子にして使用する例を示した後、describe
メソッドに複数の引数を渡すという別の書き方を紹介しました。
今回紹介するのは、もう少し複雑なパターンです。次のテストコードをご覧ください、
describe Customer, '#a_method' do context 'A' do before do # ... end specify '...' do # ... end specify '...' do # ... end # ... end context 'B' do before do # ... end specify '...' do # ... end specify '...' do # ... end # ... end end
全体として Customer#a_method
メソッドについての仕様を記述しています。context
メソッドによって、エグザンプルグループがさらに2つに分類されており、それぞれの冒頭に before
ブロックが置かれています。
context
は、文脈(context)によってサブグループを作るためのメソッドです。「文脈」は「前提条件」と言い換えることもできます。before
ブロックに記述された条件が成立する場合には、続く specify
ブロック群で記述された仕様が成立する、ということです。
常に before
ブロックが必要というわけではありません。let
で定義するヘルパーメソッドの中身を変えたり、エグザンプルの中身を変えたりしても、文脈や前提条件を表現できます。
実を言えば、context
メソッドは describe
メソッドの単なる別名(alias)に過ぎません。クラスやメソッドごとにエグザンプルを分類する場合には describe
を使用し、前提条件によってエグザンプルを分類する場合には context
を使用することをお勧めします。
ReceptionDesk#sign_in
のテストを書き換える
では、この新しい記述法を用いて ReceptionDesk#sign_in
のテストを書き換えてみましょう。まずは、書き換え前のコードを示します:
require 'spec_helper' describe ReceptionDesk, '#sign_in' do let(:customer) { create(:customer, username: 'taro', password: 'correct_password') } specify 'ユーザー名とパスワードに該当するCustomerオブジェクトを返す' do result = ReceptionDesk.new(customer.username, 'correct_password').sign_in expect(result).to eq(customer) end specify '該当するユーザー名が存在しない場合はnilを返す' do result = ReceptionDesk.new('hanako', 'any_string').sign_in expect(result).to be_nil end specify 'パスワードが一致しない場合はnilを返す' do result = ReceptionDesk.new(customer.username, 'wrong_password').sign_in expect(result).to be_nil end specify 'パスワード未設定のユーザーを拒絶する' do customer.update_column(:password_digest, nil) result = ReceptionDesk.new(customer.username, '').sign_in expect(result).to be_nil end specify 'ログインに成功すると、RewardManager#grant_login_pointsが呼ばれる' do expect_any_instance_of(RewardManager).to receive(:grant_login_points) ReceptionDesk.new(customer.username, 'correct_password').sign_in end specify 'ログインに失敗すると、RewardManager#grant_login_pointsは呼ばれない' do expect_any_instance_of(RewardManager).not_to receive(:grant_login_points) ReceptionDesk.new(customer.username, 'wrong_password').sign_in end end
そして、書き換え後:
require 'spec_helper' describe ReceptionDesk, '#sign_in' do context 'ユーザー名とパスワードが一致する場合' do let(:customer) { create(:customer, username: 'taro', password: 'correct_password') } specify '該当するCustomerオブジェクトを返す' do result = ReceptionDesk.new(customer.username, 'correct_password').sign_in expect(result).to eq(customer) end specify 'RewardManager#grant_login_pointsが呼ばれる' do expect_any_instance_of(RewardManager).to receive(:grant_login_points) ReceptionDesk.new(customer.username, 'correct_password').sign_in end end context '該当するユーザー名が存在しない場合' do specify 'nilを返す' do result = ReceptionDesk.new('hanako', 'any_string').sign_in expect(result).to be_nil end specify 'RewardManager#grant_login_pointsは呼ばれない' do expect_any_instance_of(RewardManager).not_to receive(:grant_login_points) ReceptionDesk.new('hanako', 'any_string').sign_in end end context 'パスワードが一致しない場合' do specify 'nilを返す' do result = ReceptionDesk.new(customer.username, 'wrong_password').sign_in expect(result).to be_nil end specify 'RewardManager#grant_login_pointsは呼ばれない' do expect_any_instance_of(RewardManager).not_to receive(:grant_login_points) ReceptionDesk.new(customer.username, 'wrong_password').sign_in end end context 'パスワード未設定の場合' do before { customer.update_column(:password_digest, nil) } specify 'nilを返す' do result = ReceptionDesk.new(customer.username, '').sign_in expect(result).to be_nil end specify 'RewardManager#grant_login_pointsは呼ばれない' do expect_any_instance_of(RewardManager).not_to receive(:grant_login_points) ReceptionDesk.new(customer.username, 'wrong_password').sign_in end end end
元のコードよりも少し長くなりましたが、文脈による仕様の違いが明確になりました。
エグザンプルの簡略化
さて、先ほどの各エグザンプルの説明文と中身のコードを見比べると、ちょっと冗長な印象を受けないでしょうか。例えば、
specify 'nilを返す' do result = ReceptionDesk.new('hanako', 'any_string').sign_in expect(result).to be_nil end
とありますが、説明文がなくてもコードを読めば、どういう仕様で何をテストしようとしているか分かりますね。
読者の中には異論のある方がいらっしゃるかもしれませんが、筆者の場合は、この種のエグザンプルに関しては説明文を省きます。
specify do result = ReceptionDesk.new('hanako', 'any_string').sign_in expect(result).to be_nil end
説明文を書かないことのデメリットは、-fd
オプション付きで bin/rspec
を実行したときに表示される documentation 形式のテスト結果があまりキレイではない、ということです。しかし、筆者は -fd
オプションをほとんど使用しないので、この点で不便は感じません。
また、各 context
ブロックの中で結果に関するテストと Message Expectation に関するテストを別々のエグザンプルにしていますが、次のようにひとつのエグザンプルとしてまとめてしまうと、さらにテストコードが短くなります:
specify do expect_any_instance_of(RewardManager).not_to receive(:grant_login_points) result = ReceptionDesk.new('hanako', 'any_string').sign_in expect(result).to be_nil end
注意 RSpec のドキュメントや教科書には、たいてい「エグザンプル1個につき expect
は1つだけにしましょう」と書いてあります。1つのエグザンプルに複数の expect
があると、最初の expect
で失敗した場合に2個目以降の expect
が実行されないという問題があります。また、エグザンプルの趣旨が曖昧になるという問題もあります。しかし、この教えに忠実に従おうとするとテストコードが長くなって仕様全体を把握しにくくなります。筆者はこのルールをあまり厳格に守る必要はないと考えていますが、おそらく正統的な考え方ではありません。採用は慎重に。
ReceptionDesk#sign_in
のテスト全体を簡略化した結果を次に示します:
require 'spec_helper' describe ReceptionDesk, '#sign_in' do let(:customer) { create(:customer, username: 'taro', password: 'correct_password') } context 'ユーザー名とパスワードが一致する場合' do specify do expect_any_instance_of(RewardManager).to receive(:grant_login_points) result = ReceptionDesk.new(customer.username, 'correct_password').sign_in expect(result).to eq(customer) end end context '該当するユーザー名が存在しない場合' do specify do expect_any_instance_of(RewardManager).not_to receive(:grant_login_points) result = ReceptionDesk.new('hanako', 'any_string').sign_in expect(result).to be_nil end end context 'パスワードが一致しない場合' do specify do expect_any_instance_of(RewardManager).not_to receive(:grant_login_points) result = ReceptionDesk.new(customer.username, 'wrong_password').sign_in expect(result).to be_nil end end context 'パスワード未設定の場合' do before { customer.update_column(:password_digest, nil) } specify do expect_any_instance_of(RewardManager).not_to receive(:grant_login_points) result = ReceptionDesk.new(customer.username, '').sign_in expect(result).to be_nil end end end
次回は
次回はいよいよ「土曜ログインボーナス」に関するテストを書いて実装し、ポイントシステムの話を締めくくりたいと思います。では、また。