Rails で Minitest を使って悲観的ロック(排他制御)をテストする方法
https://gyazo.com/9b52b58cad3e2919562bd46367153a8d
くるくまカフェ
はじめに
Rails で悲観的ロック(排他制御)を行った際に、それを Minitest のテストコードで確かめる機会があったので、簡単に紹介します。
Rails で複数のリクエストが同時に処理される場合、データ競合が発生する可能性があります。悲観的ロックは、同時に1つのレコードに対して2つの更新リクエストが来た場合に、1つ目の処理が終わってから2つ目の処理が開始するようにして、競合を避けるようにする排他制御メカニズムです。Railsでは ModelName.with_lock do end などを用いて処理対象となるレコードをロックすることが可能です。
きっかけは、とあるリクエストがエラーを吐いたことでした。そのリクエストでは、いくつかのモデルを作成するのですが、複合ユニーク制約が貼られています。エラーの内容を見てみると、ユニーク制約に違反するDBのデータ挿入エラーでした。原因を調査したところ、同一のエンドポイントに対して同一のリクエストが複数回連続で発生した場合に、後続のリクエストが失敗するという内容でした。その後の調べで、トランザクションは貼っていたものの、悲観的ロックがされておらず、ボタンにも連打対策がなされていなかったことが原因でした。
テストを書く
テストコードを書いてエラーが起こることを再現して、悲観的ロックをかけた上でテストがパスしたことを確認してみたいと思います。例えば以下のように Parallel gem を使うと簡単に非同期で複数回の処理を呼び出すことができます。
code:parallel_test.rb
require 'parallel'
test test_pesimistic_lock do
Parallel.each(1, 2, in_threads: 2) do # 処理を実行するコードを書く
end
end
このコードは、Parallel gem を使って、1 と 2 を要素とする配列の要素に対して、それぞれ別スレッドで処理を実行します(1と2そのものに意味はありません)。しかし、実際にこれでテストを回してみても、ブロック内部の処理のトランザクションが並列に実行されません。Minitestは各テスト実行時にトランザクションを張り、テストの終了時にロールバックを行うようになっており、これがテストが意図通りに動かない原因となっています。
そこで、テストクラスに下記のコードを追加します。対象のテストクラス内で張られるトランザクションを無効にします。
code:disable_transaction
self.use_transactional_tests = false
その後、テストを実行します。この時、トランザクションを無効にしたテストは実行後、データベースが初期化されない(ロールバックされない)ため、DBのレコードを空にする必要があります。まとめてコマンド実行すると良いでしょう。
code:run test
rails db:migrate:reset RAILS_ENV=test & rails t path/to/test_file.rb:{テストメソッドの行番号}
もし書いたテストコードを永続的に残しておきたい場合は、テストクラスを分けた上で、テストコードのセットアップでデータを空にする処理を書いておくと良いでしょう。
参考