効果的なテストコードの書き方:RSpecの基本と実践テクニック
IVRy(アイブリー)のエンジニアの 島筒 ( @kshimadutsu ) です。
今年の IVRy Advent Calendar は紅白対抗戦を行っています!他のAdvent Calendarの記事も見てみてください。
ここから本題です。
IVRyでは、Railsを利用してバックエンドのAPIを提供しており、テストはRSpecを利用しています。
RSpecの基本的な書き方から、クラスをmockする書き方までをご紹介したいと思います。
記事の内容とは関係ないですが、IVRyのcoverageは、97.09%程になります。
効果的なRSpecの書き方
サンプルを読む時の前提
- factory_botを利用しています。factory_botを知らない方は こちら を参考にしてください
- 知らないmatcherがあると混乱すると可能性があるため、サンプルには、matcherは利用していません。(IVRy内ではガンガンmatcherを利用しています。)
1. 境界値
境界値テストは、基本のキですね。
モデルのテストを例に書きます。
Person
というクラスに、「お酒が飲める年齢か」という関数があったケースについて考えてみます。
20歳以上かの判定で、大なり小なりや、等号の有無を間違えると、20歳以下がアルコールを飲めたり、21歳にならないとアルコールが飲めなくなってしまいます。
RSpecでは、 age
の境界値である 20
を基準に、前後を含めた 19
、20
、 21
のケースを検証します。
schema
# Table name: people
#
# id :bigint not null, primary key
# age :integer not null
# created_at :datetime not null
# updated_at :datetime not null
class
class Person < ApplicationRecord
LEGAL_DRINKING_AGE = 20
def can_drink_alcohol?
age >= LEGAL_DRINKING_AGE
end
end
RSpec
RSpec.describe Person, type: :model do
describe '.can_drink_alcohol?' do
subject { person.can_drink_alcohol? }
let(:person) { build(:person, age: age) }
context 'when age is 19' do
let(:age) { 19 }
it { is_expected.to eq false }
end
context 'when age is 20' do
let(:age) { 20 }
it { is_expected.to eq true }
end
context 'when age is 21' do
let(:age) { 21 }
it { is_expected.to eq true }
end
end
end
2. 時間経過による変化
変化を確認するテストは、基本のホくらいのレベルでしょうか。
前述のPersonクラスでは、 年齢が時間経過ととも変化しません。
年齢属性を廃止、誕生日属性を追加し、誕生日を元に年齢を返してくれる関数を追加されたケースについて考えてみます。
RSpecでは、時間が経過するとともに年齢が変わることを検証します。
travel_to
を使って、時間を過去に、未来に変更させます。
schema
# Table name: people
#
# id :bigint not null, primary key
# birthday :date not null
# created_at :datetime not null
# updated_at :datetime not null
class
class Person
LEGAL_DRINKING_AGE = 20
def age
today = Date.today
age = today.year - birthday.year
age -= 1 if today.strftime('%m%d') < birthday.strftime('%m%d') # 月日だけで比較してbirthdayが過ぎていない場合、1歳引く
age
end
def can_drink_alcohol?
age >= LEGAL_DRINKING_AGE
end
end
RSpec
RSpec.describe Person, type: :model do
describe '.age' do
subject(:age) { person.age }
let(:person) { build(:person, birthday: '2003-12-12') }
context 'when current date is 2023/12/11' do
before do
travel_to Time.zone.local(2023, 12, 11)
end
it { is_expected.to eq 19 }
end
context 'when current date is 2023/12/12' do
before do
travel_to Time.zone.local(2023, 12, 12)
end
it { is_expected.to eq 20 }
end
context 'when current date is 2023/12/13' do
before do
travel_to Time.zone.local(2023, 12, 13)
end
it { is_expected.to eq 20 }
end
end
end
3. 変化の確認
時間経過による変化のテストですが、基本のンですね。
続いて、 Person
クラスに、お酒を飲むと血中アルコール濃度が増え、保存される関数を追加されたケースについて考えてみます。
RSpecでは、 drink_alcohol!
が呼ばれると、 blood_alcohol_level
の値が変化すること、 volume
引数に応じて変化量が変わっていることを検証します。
volumeは、ビールのmlを想定しており、500mlのビールを飲むと、血中アルコール濃度が0.025%上昇するロジックになっています。
酩酊状態は、血中アルコール濃度が0.16~0.30の状態といわれているので、3.5Lほどビールを飲むと酩酊する計算になっています。
schema
# Table name: people
#
# id :bigint not null, primary key
# birthday :date not null
# blood_alcohol_level :float default(0.0), not null
# created_at :datetime not null
# updated_at :datetime not null
class
class Person < ApplicationRecord
LEGAL_DRINKING_AGE = 20
def age
today = Date.today
age = today.year - birthday.year
age -= 1 if today.strftime('%m%d') < birthday.strftime('%m%d') # 月日だけで比較してbirthdayが過ぎていない場合、1歳引く
age
end
def can_drink_alcohol?
age >= LEGAL_DRINKING_AGE
end
def drink_alcohol!(volume)
update!(blood_alcohol_level: volume * 0.00005)
end
end
RSpec
RSpec.describe Person, type: :model do
describe '.drink_alcohol!' do
subject(:drink_alcohol) { person.drink_alcohol!(volume) }
let(:person) { create(:person, birthday: '2003-12-12') }
let(:volume) { 400 }
it 'blood_alcohol_levelが、0.02増えること' do
expect { drink_alcohol }.to change(person, :blood_alcohol_level).by(0.02)
end
# もしくは、
it 'blood_alcohol_levelが、0.00から0.02に変化すること' do
expect { drink_alcohol }.to change(person, :blood_alcohol_level).from(0.0).to(0.02)
end
context 'when volume is 4000' do
let(:volume) { 4000 }
it 'blood_alcohol_levelが、0.2増えること' do
expect { drink_alcohol }.to change(person, :blood_alcohol_level).by(0.2)
end
end
end
end
4. classをmock
ここから少し応用編になります。
Person
の血中アルコール濃度が 0.2
を超えると危険を察知し、slackにアラートを通知する仕様が追加されたケースについて考えてみます。
slackへの通知は、 Slack::Notifier gemを利用します。
RSpecでは、 Slack::Notifier
の ping
関数を呼ばれることを確認します。
schema
# Table name: people
#
# id :bigint not null, primary key
# birthday :date not null
# blood_alcohol_level :float default(0.0), not null
# slack_webhook_url :string
# created_at :datetime not null
# updated_at :datetime not null
class
class Person
LEGAL_DRINKING_AGE = 20
def age
today = Date.today
age = today.year - birthday.year
age -= 1 if today.strftime('%m%d') < birthday.strftime('%m%d') # 月日だけで比較してbirthdayが過ぎていない場合、1歳引く
age
end
def can_drink_alcohol?
age >= LEGAL_DRINKING_AGE
end
def drink_alcohol(volume)
update!(blood_alcohol_level: volume * 0.00005)
notify_alert_to_slack
end
private
def notify_alert_to_slack
return if blood_alcohol_level < 0.2
return if slack_webhook_url.blank?
notifier = Slack::Notifier.new(slack_webhook_url)
notifier.ping('お酒飲みすぎてヤバいかも')
end
end
RSpec
RSpec.describe Person, type: :model do
describe '.drink_alcohol' do
subject(:drink_alcohol) { person.drink_alcohol(volume) }
let(:person) { build(:person, birthday: '2003-12-12', slack_webhook_url: slack_webhook_url) }
let(:slack_webhook_url) { 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' }
let(:slack_notify_instance) { instance_double(Slack::Notifier) }
before do
allow(slack_notify_instance).to receive(:ping).and_return(true)
allow(Slack::Notifier).to receive(:new).and_return(slack_notify_instance)
end
context 'when volume is 3999' do
let(:volume) { 3999 }
it 'blood_alcohol_levelが、0.19995になり、ping関数が呼ばれないこと' do
drink_alcohol
expect(person.blood_alcohol_level).to eq 0.19995
expect(slack_notify_instance).to_not have_received(:ping)
end
end
context 'when volume is 4000' do
let(:volume) { 4000 }
it 'blood_alcohol_levelが、0.2になり、ping関数が呼ばれること' do
drink_alcohol
expect(person.blood_alcohol_level).to eq 0.2
expect(slack_notify_instance).to have_received(:ping).with('お酒飲みすぎてヤバいかも').once
end
context 'when slack_webhook_url is blank' do
let(:slack_webhook_url) { nil }
it 'ping関数が呼ばれないこと' do
drink_alcohol
expect(slack_notify_instance).to_not have_received(:ping)
end
end
end
context 'when volume is 4001' do
let(:volume) { 4001 }
it 'blood_alcohol_levelが、0.20005になり、ping関数が呼ばれること' do
drink_alcohol
expect(person.blood_alcohol_level).to eq 0.20005
expect(slack_notify_instance).to have_received(:ping).with('お酒飲みすぎてヤバいかも').once
end
end
end
end
5. request spec
これまでのテストをかけあわせたrequest specです。
Person
のデータははrails consoleなどから事前に登録された前提で、お酒を飲むAPIについて考えてみます。
RSpecでは、responseのstatusや、DB上の値の変化、外部システムへの通知処理が実行されたことを検証します。
routing
※ personの複数形は、peopleですので、PeopleControllerになります。
post 'people/:id/drink_alcohol', to: 'people#drink_alcohol'
Controller
class PeopleController
def drink_alcohol
@people = People.find(params[:id])
@people.drink_alcohol!(params[:volume].to_i)
head :no_content
end
end
RSpec
RSpec.describe PeopleController, type: :request do
describe 'PUT /people/:id/drink_alcohol' do
subject(:request) { post people_drink_alcohol_path(person.id), params: { volume: volume } }
let(:person) { create(:person, birthday: birthday, slack_webhook_url: slack_webhook_url) }
let(:birthday) { '2003-12-12' }
let(:slack_webhook_url) { 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' }
let(:slack_notify_instance) { instance_double(Slack::Notifier) }
before do
travel_to Time.zone.local(2023, 12, 12)
allow(slack_notify_instance).to receive(:ping).and_return(true)
allow(Slack::Notifier).to receive(:new).and_return(slack_notify_instance)
end
context 'when volume is 500' do
let(:volume) { 500 }
it 'HTTP Statusが、204で返ってきて、blood_alcohol_levelが、0.025になり、ping関数が呼ばれないこと' do
request
expect(response).to have_http_status(:no_content)
expect(person.reload.blood_alcohol_level).to eq 0.025
expect(slack_notify_instance).to_not have_received(:ping)
end
context 'when birthday is 2003-12-13(19 old)' do
let(:birthday) { '2003-12-13' }
it 'HTTP Statusが、403で返ってくること' do
request
expect(response).to have_http_status(:forbidden)
end
end
end
context 'when volume is 4000' do
let(:volume) { 4000 }
it do
request
expect(response).to have_http_status(:no_content)
expect(person.reload.blood_alcohol_level).to eq 0.2
expect(slack_notify_instance).to have_received(:ping)
end
end
end
end
お酒を飲むたびにAPIを叩けば、「お酒飲みすぎてヤバいかも」ということを確認できるアプリケーションをいつでも作れますね。
ここまでテストコードを一人で考えて書ければ、初心者を卒業といっても良いのではないのでしょうか
6. OpenAPIとcommittee-rails
最後に、BEのRailsがAPIで提供している場合に限りますが、 request
と response
の schema
を検証する方法の紹介です。
前提として、APIのドキュメントを OpenAPI
フォーマットで、 schema
が定義されていることが前提です。
schema定義
詳細は、OpenAPIを見ていただけたらと思いますが、以下に簡単に説明します。
components
に、 schema
を定義します。(再利用可能な定義です)
paths
に URL
と METHOD
, parameters
, responses
などを定義します。
この定義を RSpec
で検証します。
openapi: 3.0.3
info:
title: drinking
description: API of drinking. This is an internal api.
version: 1.0.0
servers:
- url: 'http://localhost:3000/'
description: local server
components:
schemas:
Person:
type: object
description: Person
required:
- id
- birthday
- blood_alcohol_level
- slack_webhook_url
- created_at
- updated_at
additionalProperties: false
properties:
id:
type: integer
description: id
example: 1
birthday:
type: string
description: 誕生日
example: '2003-12-12'
blood_alcohol_level:
type: number
description: 血中アルコール濃度
example: 0.05
slack_webhook_url:
type: string
description: slackのwebhookのURL
nullable: true
example: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
created_at:
type: string
description: 登録日時
example: '2022-03-11T04:07:46.712Z'
updated_at:
type: string
description: 更新日時
example: '2022-03-11T04:07:46.712Z'
paths:
/people/{id}:
get:
tags: [ person ]
description: Person
operationId: getPerson
parameters:
- in: path
name: id
schema:
type: integer
required: true
responses:
200:
description: ok
content:
application/json:
schema:
$ref: '#/components/schemas/Person'
routing
person GET /people/:id(.:format) people#show
Controller
class PeopleController < ApplicationController
def show
render json: Person.find(params[:id]).to_json
end
end
RSpec
簡単に手順を説明します。
-
committee-rails
,rspec-openapi
を install -
spec/rails_helper.rb
でschema
を定義したymal
のpath
を指定 - RSpecで、
request
,response
のschema
を検証-
assert_request_schema_confirm
で、request
のschema
を検証 -
assert_response_schema_confirm
で、response
のschema
を検証
-
group :development, :test do
gem 'committee-rails'
gem 'rspec-openapi'
end
RSpec::OpenAPI.path = 'docs/openapi.yml'
RSpec.describe PeopleController, type: :request do
describe 'GET /people/:id' do
subject(:request) { get person_path(person.id) }
let(:person) do
create(:person, birthday: '2003-12-12',
slack_webhook_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX')
end
it do
request
assert_request_schema_confirm
assert_response_schema_confirm(Rack::Utils::SYMBOL_TO_STATUS_CODE[:ok])
end
end
end
RSpecとの距離を縮めるシンプルな書き方 について書いた記事もあるので、良かったら見てください。
良いRSpecライフを!
最後に
RSpecの基本的な書き方から、IVRyで利用しているgemの紹介をしましたが、現在IVRyではRSpecの高速化を推し進めています。
CIで自動テストしてくれるとはいえ、エンジニアが少しでも快適な開発を行えるように改善していっています。
一緒に改善してくれるエンジニアをお待ちしています。
IVRyでは一緒に働いてくれるエンジニアを募集中です!
Discussion