megutech

自身の備忘録として主にWEBサーバー周りの技術について投稿しています。

Rspecテストで同時アクセスによる排他テストを行う

商品購入に同時にアクセスがあった場合、在庫数がマイナスにならないようにテストしておきたい。
そんなテストをしたいタイミングが多々あるのではないでしょうか?

その際テーブルロックを利用して排他制御を行っていた場合、何も考えずにrspecでThreadを使って同時にロジックを実行させても上手く動きません。
これはrspecはテストの開始時にTransactionを張ってしまうため、Thread内の別プロセスからはメインプロセスで作った未コミットのモデル群にアクセスできないためです。

そこでrspec開始時にtransactionを張らないように修正する必要があります。

前提

今回はDatabaseCleanerを利用しています。

環境

Service Version
Ruby 3.2.2
Ruby on Rails 7.0.6
rspec 3.12.0
database_cleaner 2.0.2
mariadb 11.1.2

対応

rspecのデータ削除の仕組みであるuse_transactional_fixturesを非活性化し、特定条件下ではDatabaseCleanerのtransactionも張らないようにします。

spec/rails_helper.rb

  - config.use_transactional_fixtures = true
  + config.use_transactional_fixtures = false

  config.before(:suite) do
    DatabaseCleaner.strategy = :transaction
    DatabaseCleaner.clean_with(:truncation)
  end

  config.before(:each) do |example|
    + if example.metadata[:disable_database_cleaner]
    +   DatabaseCleaner.strategy = nil
    + else
      DatabaseCleaner.strategy = :transaction
      DatabaseCleaner.start
    + end
  end

  config.after(:each) do |example|
    + if example.metadata[:disable_database_cleaner]
    +   # do nothing
    + else
      DatabaseCleaner.clean
    + end
  end

  config.after(:all) do
    DatabaseCleaner.clean_with(:truncation)
  end

利用方法

上記設定を読むと、disabled_database_cleanerを設定するとstrategyとしてtransactionを利用しないようにしていることが分かります。
具体的な使い方としては下記のようになります。

RSpec.describe Order, type: :model do
  describe "self.create_from_cart!" do
    context "parallel test", disable_database_cleaner: true do
      let!(:product) { create(:product, :with_inventory) }
      let!(:cart_items) { create_list(:cart_item, 100, product: product, quantity: 1) }

      it "should work on multiple threads" do
        product.product_inventory.update!(amount: 50)

        cart_items.map do |cart_item|
          Thread.new(cart_item.cart) do |cart|
            ActiveRecord::Base.connection_pool.with_connection do
              Order.create_from_cart!(cart) rescue nil
            end
          end
        end.each {|t| t.join }

        expect(product.product_inventory.reload.amount).to eq(0)
        expect(Order.count).to eq(50)
      end
    end
  end
end

context "parallel test", disable_database_cleaner: true doの部分でtransactionを張らないよう設定をしています。

コード自体の説明はしませんが、create_from_cart!内ではproduct_inventorySELECT FOR UPDATEでコールし、排他制御を行っています。

所感

最近はやりのChatGTPに解決方法を聞いたりしたけど、全然的を射た回答はくれないし、「transactionが怪しいのでは?」と気づいたあたりで再度聞いても動きもしないコードを示してきたりで逆に時間がかかってしまった。。。
上司としての才能がないということが証明されてしまったようで辛い。。。