RSpecでスレッドを利用したテストを実装するときに注意すること
こんにちは。iCAREでRails技術顧問をしている@willnetです。
みなさん、テスト書いてますか?今回は「並列に処理が走っても期待通り動く」というテストを書いているときに起きた不思議な現象と、その原因について共有したいと思います。
不思議な現象とは
アプリケーション中にDBのロックを利用しているコードがあります。それが同時に実行されたときにデータ不整合がないことを保証するためのテストを次のように書いていました(説明のためにコードはかなり省略しています)。
context '同時に同じ予約枠日時が選択されてリクエストが行われた時' do
let(:reservation_input) {
# 略
}
subject do
threads = []
threads << Thread.new do
RequestReservationService.call(reservation_input)
end
threads << Thread.new do
RequestReservationService.call(reservation_input)
end
threads.each(&:join)
end
it '後続のリクエストは予約ができないので例外になること' do
expect { subject }.to raise_error(CannotReserveError)
end
end
ぱっと見た感じ、問題ないように見えますよね。しかし、このテストを実行するとテストが止まり、永久に終わらなくなってしまいます…。
なぜなのか
この現象は、RSpec内でlet(やsubject)の呼び出しを行ったときに獲得されるロックが原因でおきています。最小の再現コードは次のとおりです。
RSpec.describe 'テストが止まる例' do
let(:hoge) { 'hoge' }
subject do # (1)
thread = Thread.new { hoge } # (2)
thread.join # (3)
end
it '止まる' do
subject
end
end
何が起きているのか
letの定義ブロックは初回呼び出し時にのみ実行され(メモ化)、二回目はメモ化された結果を取得するだけという仕様になっています。複数スレッドで同じletの定義を呼び出した場合、定義ブロックが2回呼ばれてしまわないようにロックを利用して回避しています。
関連PR: Make memoized helpers threadsafe by JoshCheek · Pull Request #1858 · rspec/rspec-core
まず再現コードの(1)でsubjectのブロックを定義しています。subjectはletとほぼ同じ作りになっていて呼び出し時にロックを獲得するようになっています。
(3)でテストのメインスレッドは、(2)の処理を待ちます。
(2)はletで定義したものを呼び出します。新しいスレッドでロックを獲得しようとしますが、それはメインスレッドが獲得済みなので終了を待ちます。しかしメインスレッドは新しいスレッドの終了を待っているためデッドロックし、永遠に終わらない処理が出来上がる、という流れになっています。
具体的にどのようにロックを獲得しているのか知りたい方は、下記のコードを眺めてみるとなんとなく把握できると思います。
- https://github.com/rspec/rspec-core/blob/425ba72e838e279cd35b808c5f12c5a8657e0b29/lib/rspec/core/memoized_helpers.rb#L343
- https://github.com/rspec/rspec-core/blob/425ba72e838e279cd35b808c5f12c5a8657e0b29/lib/rspec/core/memoized_helpers.rb#L176-L183
- https://github.com/rspec/rspec-support/blob/28526172c42302858c18681d7d7580490d885b4d/lib/rspec/support/reentrant_mutex.rb#L21-L26
まとめ
並列処理をテストするときのハマりどころを紹介してみました。参考になれば幸いです(\( ⁰⊖⁰)/)