🥰

RSpecとの距離を縮めるシンプルな書き方

2023/09/25に公開

IVRyで、バックエンドエンジニアをしている島筒です。
https://twitter.com/kshimadutsu

IVRyでは、Railsを利用して一部のサービスを提供しており、テストは、RSpecを利用しています。
10年近くRailsを使った開発に携わっていますが、Railsを始めて数年は、RSpecを書くことがとても億劫でした。
それが今では、RSpecなしでは継続的な開発は難しいという意識にまでなったのだから、自分でも不思議に思います。

苦手意識が変わったきっかけは、既存関数を修正した際、その関数を利用しているコードを破壊するような修正をRSpecのおかげで気づけたことです。
こういった経験を体験すると、RSpecなしの開発できなくなります。
安心して改善・リファクタリングが行えます。

今回の記事では、何がきっかけで、RSpecの苦手意識が払拭できたか、記しておこうと思います。

  1. 最初に定義するテストは、一番シンプルな正常系を確認する
  2. modelのテストから書いていく
  3. describe, context, itを書く
  4. rubocopを入れて、1つの関数のコード量をできる限り減らし、シンプルにする
  5. matcherは覚えなくて良い

※ これらは、誰もが同じように思えるというわけではないですし、当たり前ことばかりかもしれませんが、私にとってきっかけになった事柄です。

RSpecについて書くなんて、今更?とか言わないで😅

1. 最初に定義するテストは、一番シンプルな正常系を確認する

一番シンプルなテストコードは何かといえば、正常系の処理です。
正常系を定義した後、異常系のテストコードを追加していきます。

早速、コードを紹介します。

あるUserモデルがあり、emailとnameが必須とした時、以下のようなクラスが定義されると思います。

class User < ApplicationRecord
  validates :email, presence: true
  validates :name, presence: true
end

このクラスに対応した正常系のテストコードは、以下のようになります。

RSpec.describe User, type: :model do
  describe '.valid' do
    let(:user) { build(:user, email: 'テスト', name: 'test@example.com') } # FactoryBotを利用しています。
  
    it { expect(user.valid).to be_truthy }
  end
end

上記のように正常系のコードが定義されていれば、後は異常系の条件を追加していくだけになります。

RSpec.describe User, type: :model do
  describe '.valid' do
    let(:user) { build(:user, email: 'test@example.com', name: 'テスト') } # FactoryBotを利用しています。
  
    it { expect(user.valid).to be_truthy }
  
    context 'when email is empty' do
      let(:user) { build(:user, email: '', name: 'テスト') }
    
      it { expect(user.valid).to be_falsey }
    end
  
    context 'when name is empty' do
      let(:user) { build(:user, email: 'test@example.com', name: '') }
    
      it { expect(user.valid).to be_falsey }
    end
  end
end

これに気づいたことが、テストを書くハードルを一番下げてくれました。

2. modelのテストから書いていく

modelのテストから書くことで、関連したテスト、特にRequestスペックを書くハードルが下がりました。

前述のモデルのテストで書いた let(:user) { build(:user, email: 'test@example.com', name: 'テスト') } を利用して、users_controllerのRequestスペックを書いてみます。

RSpec.describe UsersController, type: :request do
  describe 'GET /users' do
    before do
      create(:user, email: 'test@example.com', name: 'テスト')
      
      get users_path
    end
    
    it { expect(response).to have_http_status(:ok) }
  end
end

create(:user, email: 'test@example.com', name: 'テスト') の部分ですが、FactoryBotに登録しておくことで、もっと楽に書けるようになります。

3. describe, context, itを書く

describe: テストの対象
context: 条件や状態
it / example: テスト

当たり前のことなんですが、describe, context, itを正しく使いましょう。
正しく使うことで、テスト対象と条件が明確になり、コードの見通しが良くなります。
コードはたびたび改善されていくものです。
コード変更時、テストコードの見通しが悪いとテストコードを追随していくことが辛くなり、放置されることもあります。
テストコードの見通しが良いことで、テストを書くことが辛くなくなります。

書くときのコツを以下のテストコードを例に説明します。

RSpec.describe User, type: :model do
  let(:user) { build(:user, email: 'test@example.com', name: 'テスト') }
  
  it { expect(user.valid).to be_truthy }
  
  context 'when email is empty' do
    let(:user) { build(:user, email: '', name: 'テスト') }
    
    it { expect(user.valid).to be_falsey }
  end
end

RSpecは、文章として読むことができるようなっています。

1つ目のitは、「Userモデルの、user.valid の結果は、trueであることを期待します。」を意味しています

RSpec.describe User, type: :model do → 「Userモデル」についてのテストであること宣言
it { expect(user.valid).to be_truthy }user.valid の結果は、trueであることを期待します。

2つ目のitは、「Userモデルの、emailがemptyの時、user.valid の結果は、falseであることを期待します。」を意味しています

RSpec.describe User, type: :model do → 「Userモデル」についてのテストであること宣言
context 'when email is empty' do → 「emailがemptyの時」という状態を表す
it { expect(user.valid).to be_truthy }user.valid の結果は、falseであることを期待します。

4. rubocopを入れて、1つの関数のコード量をできる限り減らし、シンプルにする

RSpecのために導入したわけではありませんでしたが、結果的にRSpecを書くときに役立ちました。

以下は、ツッコミどころ満載のコードですが、以下のようなコードをrubocopは許しません。

こういったネストの深い関数、コードが連なっているコードに対し、RSpecを書こうと思うと躊躇してしまいます。

def save_and_save_file_and_send_mail(upload_file)
  ActiveRecord::Base.transaction do
    if save
      if upload_file.present?
        extension = upload_file.extname
        File.open(Rails.root.join("public/myprofile/#{id}#{extension}"), 'w+') do |f|
	  f.write(upload_file.read)
	end
      end
      if email.present?
        UserMailer.send_mail(email, 'ユーザ情報を更新しました', self)
      end
    else
      return false
    end
  end
end

以下のように書き換えると

def save_and_save_file_and_send_mail(upload_file)
  ActiveRecord::Base.transaction do
    return false unless save

    write_myprofile(upload_file)
    send_mail
  end
end

def write_myprofile(upload_file)
  return if upload_file.present?

  extension = upload_file.extname
  File.open(Rails.root.join("public/myprofile/#{id}#{extension}"), 'w+') do |f|
    f.write(upload_file.read)
  end
end
      
def send_mail
  return if email.present?
  UserMailer.send_mail(email, 'ユーザ情報を更新しました', self)
end

コードも見やすくなりましたが、RSpecも容易になりました。

RSpec.describe User, type: :model do
  describe '.save_and_save_file_and_send_mail' do
    let(:user) { build(:user, email: 'test@example.com', name: 'テスト') }
  
    it { expect(user.save_and_save_file_and_send_mail).to be_truthy }
  end
  
  describe '.write_myprofile' do
    let(:user) { build(:user, email: 'test@example.com', name: 'テスト') }
    let(:upload_file) { Rack::Test::UploadedFile.new(File.join(Rails.root, 'spec/fixtures/profile.png'), 'image/png') }
  
    before do
      user.write_myprofile(upload_file)
    end

    it { expect(Rails.root.join("public/myprofile/#{user.id}#.png")).to be_exist }
  end
  
  describe '.send_mail' do
    let(:user) { build(:user, email: 'test@example.com', name: 'テスト') }

    it { expect { user.send_mail }.to change(ActionMailer::Base.deliveries, :size).by(1) }

    context 'when email is empty' do
      let(:user) { build(:user, email: 'test@example.com', name: 'テスト') }

      it { expect { user.send_mail }.to not_change(ActionMailer::Base.deliveries, :size).by(1) }
    end
  end
end

一つの関数の中で条件分岐し、複雑だったコードがシンプルになりました。

5. matcherは覚えなくて良い

RSpecのmatcherは、数多く存在します。
初期は、matcherって何のメリットがあるの?と思うことが多々あります。

ちょっと困った時、助けてくれるのがmatcherです。
matcherを一つ一つは覚えなくても、必要になったとき、困ったとき、matcherを調べて利用できるか調べてみるだけで十分です。

  • 配列を比較するとき、関数が並び順を担保していないため、たまに失敗する時
  • 1行のコードを短くしたい時
  • 例外の発生を確認したい時

良いRSpecライフを!

最後に

IVRyでは一緒に働いてくれるエンジニアを募集中です!
https://ivry-jp.notion.site/IVRy-e1d47e4a79ba4f9d8a891fc938e02271

IVRyテックブログ

Discussion