💯

効果的なテストコードの書き方:RSpecの基本と実践テクニック

2023/12/12に公開

IVRy(アイブリー)のエンジニアの 島筒 ( @kshimadutsu ) です。

今年の IVRy Advent Calendar は紅白対抗戦を行っています!他のAdvent Calendarの記事も見てみてください。
https://twitter.com/IVRy_jp/status/1730521537294520802

https://adventar.org/calendars/9247
https://adventar.org/calendars/9453

ここから本題です。

IVRyでは、Railsを利用してバックエンドのAPIを提供しており、テストはRSpecを利用しています。
RSpecの基本的な書き方から、クラスをmockする書き方までをご紹介したいと思います。

記事の内容とは関係ないですが、IVRyのcoverageは、97.09%程になります。
Simplecovを使ったカバレッジの結果

効果的なRSpecの書き方

サンプルを読む時の前提

  • factory_botを利用しています。factory_botを知らない方は こちら を参考にしてください
  • 知らないmatcherがあると混乱すると可能性があるため、サンプルには、matcherは利用していません。(IVRy内ではガンガンmatcherを利用しています。)

1. 境界値

境界値テストは、基本のキですね。

モデルのテストを例に書きます。

Person というクラスに、「お酒が飲める年齢か」という関数があったケースについて考えてみます。
20歳以上かの判定で、大なり小なりや、等号の有無を間違えると、20歳以下がアルコールを飲めたり、21歳にならないとアルコールが飲めなくなってしまいます。
RSpecでは、 age の境界値である 20 を基準に、前後を含めた 192021 のケースを検証します。

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

app/models/person.rb
class Person < ApplicationRecord
  LEGAL_DRINKING_AGE = 20

  def can_drink_alcohol?
    age >= LEGAL_DRINKING_AGE
  end
end

RSpec

spec/models/person_spec.rb
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

app/models/person.rb
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

spec/models/person_spec.rb
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

app/models/person.rb
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

spec/models/person_spec.rb
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::Notifierping 関数を呼ばれることを確認します。

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

app/models/person.rb
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

spec/models/person_spec.rb
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

app/controllers/people_controller.rb
class PeopleController
  def drink_alcohol
      @people = People.find(params[:id])
    @people.drink_alcohol!(params[:volume].to_i)

    head :no_content
  end
end

RSpec

spec/request/people_controller_spec.rb
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で提供している場合に限りますが、 requestresponseschema を検証する方法の紹介です。

前提として、APIのドキュメントを OpenAPI フォーマットで、 schema が定義されていることが前提です。

schema定義

詳細は、OpenAPIを見ていただけたらと思いますが、以下に簡単に説明します。
components に、 schema を定義します。(再利用可能な定義です)
pathsURLMETHOD, parameters, responses などを定義します。
この定義を RSpec で検証します。

docs/openapi.yml
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

app/controllers/people_controller.rb
class PeopleController < ApplicationController
  def show
    render json: Person.find(params[:id]).to_json
  end
end

RSpec

簡単に手順を説明します。

  1. committee-rails, rspec-openapi を install
  2. spec/rails_helper.rbschema を定義した ymalpath を指定
  3. RSpecで、 request, responseschema を検証
    • assert_request_schema_confirm で、 requestschema を検証
    • assert_response_schema_confirm で、 responseschema を検証
Gemfile
group :development, :test do
  gem 'committee-rails'
  gem 'rspec-openapi'
end
spec/rails_helper.rb
RSpec::OpenAPI.path = 'docs/openapi.yml'
spec/request/people_controller_spec.rb
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では一緒に働いてくれるエンジニアを募集中です!
https://www.notion.so/ivry-jp/2aad9d316a1c42009a7cc9b19a2dfd0c

IVRyテックブログ

Discussion