Techioz Blog

RSpec単体テストでの競合状態のシミュレーション

概要

オブジェクトに対して長時間実行される可能性のある計算を実行する非同期タスクがあります。結果はオブジェクトにキャッシュされます。複数のタスクが同じ作業を繰り返すのを防ぐために、アトミックな SQL 更新によるロックを追加しました。

UPDATE objects SET locked = 1 WHERE id = 1234 AND locked = 0

ロックは非同期タスクのみに適用されます。オブジェクト自体はユーザーによって更新される可能性があります。その場合、オブジェクトの古いバージョンの未完了のタスクは、結果が古い可能性があるため、結果を破棄する必要があります。これもアトミック SQL 更新を使用すると非常に簡単に実行できます。

UPDATE objects SET results = '...' WHERE id = 1234 AND version = 1

オブジェクトが更新されている場合、そのバージョンは一致しないため、結果は破棄されます。

これら 2 つのアトミック アップデートは、起こり得る競合状態に対処する必要があります。問題は、単体テストでそれをどのように検証するかです。

最初のセマフォは、(1) オブジェクトがロックされている場合と (2) オブジェクトがロックされていない場合の 2 つのシナリオで 2 つの異なるテストを設定するだけなので、テストは簡単です。 (SQL クエリのアトミック性をテストする必要はありません。それはデータベース ベンダーの責任であるためです。)

2 番目のセマフォをテストするにはどうすればよいでしょうか?最初のセマフォの後、2 番目のセマフォの前に、サードパーティによってオブジェクトを変更する必要があります。更新を確実かつ一貫して実行するには、実行を一時停止する必要がありますが、RSpec でのブレークポイントの挿入はサポートされていないことがわかります。これを行う方法はありますか?それとも、そのような競合状態をシミュレートするために私が見落としている他のテクニックがあるのでしょうか?

解決策

電子機器製造のアイデアを借用して、テスト フックを製品コードに直接組み込むことができます。回路を制御したり検査したりするためのテスト機器用の特別な場所を備えた回路基板を製造できるのと同じように、コードでも同じことができます。

データベースに行を挿入するコードがあるとします。

class TestSubject

  def insert_unless_exists
    if !row_exists?
      insert_row
    end
  end

end

ただし、このコードは複数のコンピューターで実行されています。したがって、別のプロセスがテストと挿入の間に行を挿入する可能性があるため、競合状態が発生し、DuplicateKey 例外が発生します。コードが競合状態から生じる例外を処理するかどうかをテストしたいと考えています。これを行うには、テストで row_exists? の呼び出しの後に行を挿入する必要があります。ただし、insert_row を呼び出す前です。そこで、そこにテストフックを追加しましょう。

class TestSubject

  def insert_unless_exists
    if !row_exists?
      before_insert_row_hook
      insert_row
    end
  end

  def before_insert_row_hook
  end

end

実際に実行すると、フックは CPU 時間をわずかに消費するだけで何もしません。ただし、コードが競合状態についてテストされている場合、テストでは before_insert_row_hook にモンキー パッチが適用されます。

class TestSubject
  def before_insert_row_hook
    insert_row
  end
end

それはずるくないですか?疑うことを知らないイモムシの体を乗っ取った寄生蜂の幼虫のように、テストはテスト対象のコードをハイジャックして、テストに必要な正確な条件を作成します。

このアイデアは XOR カーソルと同じくらい単純なので、多くのプログラマーが独自に発明したのではないかと思います。これは、競合状態のあるコードをテストするのに一般的に役立つことがわかりました。お役に立てれば幸いです。