🐈

RSpecを書く上で意識した方がいいと思うことと少しのTips | Offers Tech Blog

2022/05/26に公開約8,800字

こんにちは!Offersを開発しているバックエンドエンジニアのShunです。
前回「テストは絶対書いた方がいい」という記事を書いたので、今回はテストを書く上で留意していることを書ければと思います。

そもそも、テストは絶対書いた方がいい

単体テスト・結合テスト・UseCase テストを書いておくことで、テストファイルを見るだけで仕様を理解できたりします。
また、複雑なメソッドほどテストを書いておくことで、きちんと想定した出力を保証したメソッドの実装ができます。
さらに、一度書いたテストは追加機能・修正対応・リファクタリングなどを行った際に、影響してるか否かを自動でチェックしてくれます。
これにより、ユーザ様への円滑な価値提供並びにサービス品質を保証する大事な役割を担ってくれるのです。(テスト書いてなかった頃が恐ろしい)

しかし、きちんと書けば書くほどテストのコード量が爆増する

書けば増えます。しかし、コード量が増えることは今のところデメリットしか見当たりません。

  • 全体のテスト実行時間が長くなりがち
    • 1h 待ってテスト落ち -> 修正 -> また 1h 待機…は辛すぎる
  • 既存メソッドの変更やリファクタリングなどによるテストコードのメンテナンス工数が発生
    • ぐちゃぐちゃに書いていると、テストコードメンテだけで時間を取られてしまう
  • etc..

結果、ユーザ様への価値提供までのリードタイムが増加してしまう事態に陥ります。

故に、テスト実行時間の短縮と、メンテナンスしやすいテストコード記述を意識

上記の通り、「テスト実行時間の短縮」と「メンテナンスしやすいテストコード記述」はチーム内で意識する必要があると思っております。
弊社でこの 2 つの課題に対するアプローチをいくつか紹介できたらと思います。

テスト実行時間の短縮のためにやっていること

そもそもテスト実行時間増加の原因は?

特に大きい要因は DB に対する create, update, destroy などが挙げられます。
テストにおいては特に create の記述が数多なので、ここを改善していくと効果が現れやすくなります。

let_it_be の導入

厳密には test-prof という gem の機能の一部です。
中身を見ると、一度定義した変数を使い回して使えるようにするものとなっています。
インスタンス変数をセットしておいて、"@😸"をプレフィックスにして一意性を持っているみたいですね。可愛い。
OSS 開発者はみんな猫好きなようです。私は犬派です。

test_prof/recipes/rspec/let_it_be.rb

# Use uniq prefix for instance variables to avoid collisions
# We want to use the power of Ruby's unicode support)
# And we love cats!)
PREFIX = "@😸"

def let_it_be(identifier, **options, &block)
  initializer = proc do
    instance_variable_set(:"#{TestProf::LetItBe::PREFIX}#{identifier}", instance_exec(&block))
  rescue FrozenError => e
    raise e.exception("#{e.message}#{TestProf::LetItBe::FROZEN_ERROR_HINT}")
  end

  default_options = LetItBe.config.default_modifiers.dup
  default_options.merge!(metadata[:let_it_be_modifiers]) if metadata[:let_it_be_modifiers]

  options = default_options.merge(options)

  before_all(&initializer)

  let_accessor = LetItBe.wrap_with_modifiers(options) do
    instance_variable_get(:"#{PREFIX}#{identifier}")
  end

  LetItBe.module_for(self).module_eval do
    define_method(identifier) do
      # Trying to detect the context
      # Based on https://github.com/rspec/rspec-rails/commit/7cb796db064f58da7790a92e73ab906ef50b1f34
      if /(before|after)\(:context\)/.match?(@__inspect_output) || @__inspect_output.include?("before_all")
        instance_variable_get(:"#{PREFIX}#{identifier}")
      else
        # Fallback to let definition
        super()
      end
    end
  end

  let(identifier, &let_accessor)
end

実際に使ってみる

以下のメソッドをテストするとします。
first_name, last_name をカラムに持つ users テーブルを想定しています。

user.rb
class User < ApplicationRecord
  def full_name
    "#{self.last_name} #{self.first_name}"
  end
end

以下の例は、通常の let を使用したテストです。
これだと、user レコードを 2 回 create していることになり、その分テスト完了までの時間が長くなります。

user_spec.rb
let(:user) {
  create(:user, first_name: '太郎', last_name: '田中')
}

describe '#full_name' do
  it 'フルネームが返却されること' do
    expect(user.full_name).to eq '田中 太郎' # userが読み込まれた時点でcreate文が走る
  end

  context '名字が変更された場合' do
    it '更新後のフルネームが返却されること' do
      user.update_columns(last_name: '佐藤') # userが読み込まれた時点でcreate文が走る
      expect(user.full_name).to eq '佐藤 太郎'
    end
  end
end

これに、let_it_be を利用すると、create 文が 1 回だけ行われ使い回しされるのでテスト実行時間が短縮されます。

user_spec.rb
# この時点で1度だけcreate文が走る
let_it_be(:user) {
  create(:user, first_name: '太郎', last_name: '田中')
}

describe '#full_name' do
  it 'フルネームが返却されること' do
    expect(user.full_name).to eq '田中 太郎' # 上記で作成されたuserを使用
  end

  context '名字が変更された場合' do
    it '更新後のフルネームが返却されること' do
      user.update_columns(last_name: '佐藤') # 上記で作成されたuserを使用
      expect(user.full_name).to eq '佐藤 太郎'
    end
  end
end

余談ですが、猫ちゃんを使って書くこともできます w

user_spec.rb
let_it_be(:user) {
  create(:user, first_name: '太郎', last_name: '田中')
}

describe '#full_name' do
  it 'フルネームが返却されること' do
    expect(instance_variable_get(:@😸user).full_name).to eq '田中 太郎' # 上記で作成されたuserを使用
  end
end

create 文が不要なときは build を使う

テーブル保存が不要なテスト時に有効。build は単にインスタンスを生成するだけなので、create 文は走らない。故に速い!
以下は、ユーザのフルネームを返すインスタンスメソッドですが、わざわざ user レコードを生成しなくても良さそうですね。

user.rb
class User < ApplicationRecord
  def full_name
    "#{self.last_name} #{self.first_name}"
  end
end

create を使用すると以下のような記述になります。

user_spec.rb
describe '#full_name' do
  let(:user) { create(:user, first_name: '太郎', last_name: '田中') }

  it 'should return full name' do
    expect(user.full_name).to eq "田中 太郎"
  end
end

各実行環境によって異なりますが、少なくとも私のローカルでは 5.48s もかかりました...

build を使用してみます。

user_spec.rb
describe '#full_name' do
  let(:user) { build(:user, first_name: '太郎', last_name: '田中') }

  it 'should return title' do
    expect(user.full_name).to eq "田中 太郎"
  end
end

なんと 0.62281s で完了!

このような小さな積み重ねが大事です。

mock を使う

純粋に「インスタンスメソッドやクラスメソッドを call する」というテストなどでは、わざわざエンティティを作成する必要はないので、以下のような処理のテスト作成時に有効です。
double や spy は既存処理の変更時にエラーを出力してくれないので、できる限り instance_double や class_double を利用するのが良さそうです。

create_log_worker.rb
class CreateLogWorker
  sidekiq_options retry: 1, queue: :default

  def perform(user_id, category_type)
    user = User.find(user_id)
    user.create_log(category_type: category_type)
  end
end

mock を使った記述方法

create_log_worker_spec.rb
RSpec.describe CreateLogWorker do
  it 'should call instance method' do
    Sidekiq::Testing.inline! do
      instance_double = instance_double(User) # Userモデルに定義してあるインスタンスメソッドだけを許容する

      allow(instance_double).to receive(:create_log)
      allow(User).to receive(:find).and_return(instance_double)

      CreateLogWorker.perform_async(1, 'information')

      expect(instance_double).to have_received(:create_log)
    end
  end
end

テストの分散実行

これはテスト記述ではないですが、parallel_testsのような gem を利用して、テストファイルを幾つにも分割し、パラレル実行させることで全体のテスト実行時間を短縮が可能です。

テストコード自体をリーダブルにする観点での Tips

まずは記法的なものはrspec-best-practiceを踏襲するのが良さそうです。
プラスで、リーダブルな書き方の補足ができたらと思います。

基本 describe, context, it を使用する

基本的にはこの 3 つでおおよそのテストが書けると思います。
基本的なものを利用することで、新しくジョインした方や、RSpec 初めてという方にもとっつきやすくなります。

Model のテストにて、Scope, Callback, Validates などは一つの describe にまとめておくと見やすい

user.rb
class User < ApplicationRecord
  scope :valid_users, -> { where(valid: true) }
  validate :validate_name
  after_commit :perform_create_log, on: :create

  ...
end
user_spec.rb
RSpec.describe User, type: :model do
  describe 'scopes' do
    context 'valid_users' do
      let_it_be(:valid_user) { create(:user, valid: true) }
      let_it_be(:invalid_user) { create(:user, valid: false) }
      it '有効なユーザだけ取得すること' do
        expect(User.valid_users.first).to eq valid_user
      end
    end
  end

  describe 'validates' do
    context 'validate_name' do
      it 'バリデーションエラーが発生すること' do
        user = build(:user, name: nil)
        user.valid?
        expect(user).to be_invalid
        expect(user.errors[:base]).to include(...)
      end
    end
  end

  describe 'callbacks' do
    context 'after_create_commit' do
      it 'ログ作成Workerが登録されること' do
        ...
      end
    end
  end
end

ネストしすぎないこと

以下の例はめちゃめちゃ読みづらいです。

user_spec.rb

RSpec.describe User, type: :model do
  let(:company) { create(:company) }

  describe '#hogehoge' do
    before do
      @user = create(:user)
    end

    context 'パターンAの場合' do
      before do
        @user.post_comment!(comment: 'こんにちは')
      end

      context 'パターンAかつBの場合' do
        before do
          @valid = company.valid_contract_term?
        end

        shared_examples_for 'should return XXX' do |company|
          it do
            expect(company.get_xxxx).to eq 'XXX'
          end
        end

        it_behaves_like 'should return XXX', company
      end
    end
  end
end

まとめ

リーダブルかつ、高速に、テストカバレッジ(テストカバー率)を向上させていくことが、サービスの品質向上のために必要不可欠だなぁと最近感じております。
今後はフロント側の自動テストも導入して、デザインやレンダリング周りの挙動確認もできたらと思ってます。

エンジニア採用強化中

株式会社 overflow では Offers の開発メンバーを大募集中です。正社員はもちろん、副業でのジョインも歓迎です。とりあえず話を聞いてみたい!という方には カジュアル面談 がオススメです。

https://jobs.overflow.co.jp

関連記事

https://zenn.dev/offers/articles/20220509-ruby3-type-interpretation
https://zenn.dev/offers/articles/20220421-rspec-merit-and-demerit-and-tips
https://zenn.dev/offers/articles/20220523-component-design-best-practice

Discussion

ログインするとコメントできます