RSpecでスレッドを利用したテストを実装するときに注意すること | Dev Driven 開発・デザインチーム RSpecでスレッドを利用したテストを実装するときに注意すること | 働くひとと組織の健康を創る iCARE

BLOG

RSpecでスレッドを利用したテストを実装するときに注意すること

2021/10/07

こんにちは。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で定義したものを呼び出します。新しいスレッドでロックを獲得しようとしますが、それはメインスレッドが獲得済みなので終了を待ちます。しかしメインスレッドは新しいスレッドの終了を待っているためデッドロックし、永遠に終わらない処理が出来上がる、という流れになっています。

具体的にどのようにロックを獲得しているのか知りたい方は、下記のコードを眺めてみるとなんとなく把握できると思います。

まとめ

並列処理をテストするときのハマりどころを紹介してみました。参考になれば幸いです(\( ⁰⊖⁰)/)