Azuki'sLog

都内で働くエンジニアのブログ!RubyとかPHPとか

ActiveRecord::RecordNotUniqueが起きるケースのRSpecを書いた

Ruby経験は多いけど、テストコードの経験が少ない・・・そんなRuby技術者です。
最近テストコード(RSpecなど)を大事にするプロジェクトに携わらせて頂く事になりました。
経験が少ない自分には涙が出るほどありがたいお話なのですが、、わからないことが多い!
テスト書くのに時間が掛かりコミットしていけないなんて事に・・・なりましたorz
これはすぐ解決できる問題ではない認識で、少しずつ学んでいくしかないとは思うのですが、
少しでも早く改善していく為にハマッたポイントは振り返る。
なのでRSpecについて書きます!

書くこと

RSpecreceivereturn_toand_raise によるモックで ActiveRecord::RecordNotUnique
の例外が起きるパターンのテストコードを書く。

解決したかった課題

ボタン連打により発生する ActiveRecord::RecordNotUniqueRSpecでまず再現し、
再現した後に改善していきたい。

前提

例として扱うアプリ

  1. レシピに材料と個数を指定し、材料を登録する機能を想定
  2. レシピに材料が未登録の場合は中間テーブルを作成する
  3. レシピにその材料が既に登録されている場合は中間テーブルの個数を更新する
  4. ボタン連打などでほぼ同時に2リクエストが来た時、2. の動作が2回動き、
    DB側でDuplicate Entryエラーとなる。
    ActiveRecord としてはActiveRecord::RecordNotUnique の例外をraiseする。

コードにすると以下のイメージ Recipe#add_ingredient! が今回のテスト対象

class Recipe < ApplicationRecord

  has_many :recipe_ingredients

  def add_ingredient!(ingredient, quantity)
    recipe_ingredient = self.recipe_ingredients.find_or_initialize_by(ingredient: ingredient)
    recipe_ingredient.increment(:quantity, quantity)
    recipe_ingredient.save!
  end
end

class Ingredient < ApplicationRecord
end

class RecipeIngredient < ApplicationRecord
  belongs_to :recipe
  belongs_to :ingredient

  validates :ingredient_id, presence: true, uniqueness: { scope: :recipe_id }
end

どう改善したいか?

ActiveRecord::RecordNotUniqueがraiseされた場合は、リトライしたい。

自分のハマったポイントと書いたRSpec

  • リトライ処理をさせた時に1回目と2回目でメソッドに異なる動作をさせたい
    • and_return に引数を複数指定すると呼び出しごとに返り値を変える事ができる
      • 引数を使い切った後は最後の引数を返す動作となる
# find_or_initialize_byに固定で1回目は新規レコードを返させ、2回目は登録済みのオブジェクトを返させる
new_recipe_ingredient = recipe_ingredients.new(ingredient_id:ingredient.id)
allow(recipe_ingredients).to receive(:find_or_initialize_by).and_return(new_recipe_ingredient, recipe_ingredient)
  • ActiveRecord::RecordNotUnique が起きる状況を再現させる事が出来ない
    • 状況揃えて起こさせる事は難しいのでモックに置き換えて無理やり例外を起こさせる
# 1回目の新規レコードのみ固定でActiveRecord::RecordNotUniqueをraiseする様にする
allow(new_recipe_ingredient).to receive(:save!).and_raise(ActiveRecord::RecordNotUnique)

書いたテストコード全体

require 'rails_helper'

RSpec.describe Recipe, type: :model do

  describe '#add_ingredient!' do
    # recipesに目玉焼きレコードを登録
    let(:recipe) { FactoryBot.create(:recipe) }
    # ingredientsに卵レコードを登録
    let(:ingredient) { FactoryBot.create(:ingredient) }

    context '同じ材料が同時に登録されたとき' do
      # 目玉焼きの材料に卵を1個登録しておく
      let(:recipe_ingredient) { 
        FactoryBot.create(:recipe_ingredient, recipe: recipe, ingredient: ingredient, quantity: 1)
      }

      before do
        # 目玉焼きの材料のCollectionProxyに固定のオブジェクトを返させる用に変更
        recipe_ingredients = recipe.recipe_ingredients
    allow(recipe).to receive(:recipe_ingredients).and_return(recipe_ingredients)

    # find_or_initialize_byに固定で1回目は新規レコードを返させ、2回目は登録済みのオブジェクトを返させる
    new_recipe_ingredient = recipe_ingredients.new(ingredient_id:ingredient.id)
    allow(recipe_ingredients).to receive(:find_or_initialize_by).and_return(new_recipe_ingredient, recipe_ingredient)

    # save!時にvaidationによりActiveRecord::RecordInvalidに弾かれるので、
    # 1回目の新規レコードのみ固定でActiveRecord::RecordNotUniqueをraiseする様にする
    allow(new_recipe_ingredient).to receive(:save!).and_raise(ActiveRecord::RecordNotUnique)
      end

      it 'リトライされて指定された個数分の材料が追加登録されること' do
        expect {
          recipe.add_ingredient!(ingredient, 1)
    }.to change {recipe.recipe_ingredients.find_by(ingredient_id: ingredient.id).quantity}.by(1)
      end
    end
  end
end

結果

やったー!テストに失敗した。

% bundle exec rspec spec/models/recipe_spec.rb                                                                             (git)-[master]
F

Failures:

  1) Recipe#add_ingredient! 同じ材料が同時に登録されたとき リトライされて指定された個数分の材料が追加登録されること
     Failure/Error: recipe_ingredient.save!
     
     ActiveRecord::RecordNotUnique:
       ActiveRecord::RecordNotUnique
     # ./app/models/recipe.rb:18:in `add_ingredient!'
     # ./spec/models/recipe_spec.rb:43:in `block (5 levels) in <top (required)>'
     # ./spec/models/recipe_spec.rb:42:in `block (4 levels) in <top (required)>'

Finished in 0.19459 seconds (files took 5.26 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/models/recipe_spec.rb:41 # Recipe#add_ingredient! 同じ材料が同時に登録されたとき リトライされて指定された個数分の材料が追加登録されること