LoginSignup
10
6

More than 5 years have passed since last update.

RSpecで非同期な関数をテストする

Last updated at Posted at 2017-06-08

要約

  1. RSpecで非同期関数をテストしたかった
  2. Mocha.jsのdone方式に失敗した
  3. Queueを使ってメインスレッドに結果を戻すことにした
  4. 作った関数をGemにした

目的

同期なAPIを非同期なAPIに変えたいとき、APIの機能を壊していないことを確認したい。
同期なAPIのテストコードを、生かして非同期なAPIを確認したいです。

背景

WAMP

WMAPというプロトコルがあります。
WebSocektベースの通信プロトコルです。
MQTTと比べると、Pub−subに加えRPCがある分すこし高機能です。

メッセージは非同期に送受信します。
次のような、同期的なメッセージシーケンスをサポートしています。

  • publishの送達確認
  • RPC(Remote Procedure Call)

WampClient

Ruby向けにwamp_clientというgemがあります。

RPC呼び出しでは

をサポートしています。
今回はCallDeferred Callに置き換える話です。

同期呼び出しと非同期呼び出しを1つのIFで扱う仕組み

wmap_clientの実装

# If a defer was returned, handle accordingly
if value.is_a? WampClient::Defer::CallDefer
  value.request = request
  value.registration = msg.registered_registration

  # Store the defer
  self._defers[request] = value

  # On complete, send the result
  value.on_complete do |defer, result|
    self._send_INVOCATION_result(defer.request, result, {}, true)
    self._defers.delete(defer.request)
  end

  # On error, send the error
  value.on_error do |defer, error|
    self._send_INVOCATION_error(defer.request, error, true)
    self._defers.delete(defer.request)
  end

# 割愛

# Else it was a normal response
else
  self._send_INVOCATION_result(request, value)
end

WampClient::Defer::CallDeferというオブジェクトを返された場合は、コールバックを待ちます。
そうでなければ、即座に結果を送信します。
シンプルな仕組みです。

プロダクションコードの変更イメージ

before

単一スレッドで処理し、完了したら値を返します。

result = execute(args)

if result.success?
  defer.succeed(WampClient::CallResult.new(result.to_a))
else
  defer.fail(WampClient::CallError.new('error', result.to_a))
end

after

リクエストのたびにスレッドを立て、処理が完了したらCallDeferのコールバックを呼びます。

defer = WampClient::Defer::CallDefer.new

Thread.new do
  result = execute(args)

  if result.success?
    defer.succeed(WampClient::CallResult.new(result.to_a))
  else
    defer.fail(WampClient::CallError.new('error', result.to_a))
  end
end

defer

ちなみにこのコードはこのままプロダクションに乗せてはいけません。
スレッド内の例外をrescueしていません。
例外が起きた場合はスレッドは何も言わずに終了します。
WampClient::Defer::CallDeferにはタイムアウト等の仕組みはありません。
明示的に終了しなければ、呼び出し側は永久に待たされます。

課題 非同期関数より先にテストが終わる

同期関数のようにテストを書いてみます。

it '結果が1であること' do
  defer = async_function
  defer.on_complete { |result| expect(result).to eq(1) }
end

そうすると、expectが呼ばれる前にテストが完了します。
悲しいことに実行結果は一見成功したようにみえます。
テストを失敗しても検知できません。

expectの実行は非同期関数の完了を待たなくてはいけません。

作戦1(失敗例)非同期関数のコールバックでexpectする

結論から言うと、この作戦は失敗しました。

理想のイメージ

やりたいのはMochadoneコールバックのイメージです。

describe('User', function() {
  describe('#save()', function() {
    it('should save without error', function(done) {
      var user = new User('Luna');
      user.save(done);
    });
  });
});
  1. テスティングフレームワークがテスト完了を待つ
  2. 非同期関数のコールバック関数でexpectする(この例ではexpectはありません)
  3. 非同期関数のコールバック関数の終わりにdoneを呼ぶ(この例ではコールバック関数としてdoneを渡しています)
  4. テスティングフレームワークはテストを終了する

Rubyでのイメージは

it 'should save without error' do
  async_test do |done|
    user = User.new('Luna')
    user.save { done.call }
  end
end

です。

WampClient::Defer::CallDefer を使うときは expect を次のようなイメージです。

it '結果が1であること' do
  async_test do |done|
    defer = async_function
    defer.on_complete do |result|
      expect(result).to eq(1)
      done.call
    end
  end
end

つまり、次のような関数があれば良さそうです。

def async_test
  q = Queue.new

  yield Proc.new{ q.push :done }

  q.pop
end

割とシンプルに実装できそうです!
重ねて言いますが、これはうまくいきません!!!

失敗理由

なぜうまくいかないか?テスト対象のasync_function関数を展開すると

it '結果が1であること' do
  async_test do |done|    
    defer = WampClient::Defer::CallDefer
    Thread.new do { defer.succeed 1 }

    defer.on_complete do |result|
      expect(result).to eq(1)
      done.call
    end
  end
end

です。expectを子スレッドで実行しています。
Rspecのexpectは子スレッドで実行するには、注意が必要です。

子スレッドでexpect

長いです。

Rspecメインスレッド以外でテストに失敗すると

子スレッドを待たないと失敗は検知されない

multi_thread_spec.rb
describe '子スレッドのテストついて' do
  it '失敗が検知されないこと' do
    Thread.new do
      expect(1).to eq(0)
    end
  end
end

実行すると

rspec multi_thread_spec.rb
.

Finished in 0.01314 seconds (files took 0.10969 seconds to load)
1 example, 0 failures

悲しいことに、一見成功したように見えます。
子スレッドの終了を待っていないので、エラーは捨てられます。当然です。

子スレッドを待つ

Thread#jion

子スレッドの完了を待つにはThread#joinします。

multi_thread_spec.rb
describe '子スレッドのテストついて' do
  it '失敗が検知されること' do
    t = Thread.new do
      expect(1).to eq(0)
    end
    t.join
  end
end

実行すると

rspec multi_thread_spec.rb                                                                                                                                                   ~/test
F

Failures:

  1) 子スレッドのテストついて 失敗が検知されないこと
     Failure/Error: expect(1).to eq(0)

       expected: 0
            got: 1

       (compared using ==)
     # ./multi_thread_spec.rb:4:in `block (3 levels) in <top (required)>'

Finished in 0.02165 seconds (files took 0.16001 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./multi_thread_spec.rb:2 # 子スレッドのテストついて 失敗が検知されること

:clap: やりました!

ちょっと待ってください。
今回テストしたいプロダクトコードを思い出してみましょう。

def async_fnuction
  defer = WampClient::Defer::CallDefer.new

  Thread.new do
    result = execute(args)

    if result.success?
      defer.succeed(WampClient::CallResult.new(result.to_a))
    else
      defer.fail(WampClient::CallError.new('error', result.to_a))
    end
  end

  defer
end

deferを返します。threadインスタンスは帰ってきません。
Thread#joinは使えません。:cry

Queueで待つ

Thread間の通信にはQueueが使えます。
作戦1と同じ方法です。

popに失敗する

expect に失敗すると、popでエラーがでます。

multi_thread_spec.rb
describe '子スレッドのテストについて' do
  it '失敗するとpushされないこと' do
    q = Queue.new

    Thread.new do
      expect(1).to eq(0)
      q.push ''
    end

    q.pop
  end
end

を実行すると

rspec multi_thread_spec.rb
F

Failures:

  1) 子スレッドのテストについて 失敗するとpushされないこと
     Failure/Error: q.pop

     fatal:
       No live threads left. Deadlock?
       1 threads, 1 sleeps current:0x007fd29da86580 main thread:0x007fd29b406d60
       * #<Thread:0x007fd29b87f0f8 sleep_forever>
          rb_thread_t:0x007fd29b406d60 native:0x007fff7f0f9000 int:0
          /Users/shigerunakajima/test/multi_thread_spec.rb:10:in `pop'
          /Users/shigerunakajima/test/multi_thread_spec.rb:10:in `block (2 levels) in <top (required)>'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/example.rb:254:in `instance_exec'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/example.rb:254:in `block in run'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/example.rb:500:in `block in with_around_and_singleton_context_hooks'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/example.rb:457:in `block in with_around_example_hooks'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/hooks.rb:464:in `block in run'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/hooks.rb:602:in `run_around_example_hooks_for'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/hooks.rb:464:in `run'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/example.rb:457:in `with_around_example_hooks'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/example.rb:500:in `with_around_and_singleton_context_hooks'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/example.rb:251:in `run'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/example_group.rb:627:in `block in run_examples'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/example_group.rb:623:in `map'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/example_group.rb:623:in `run_examples'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/example_group.rb:589:in `run'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/runner.rb:118:in `block (3 levels) in run_specs'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/runner.rb:118:in `map'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/runner.rb:118:in `block (2 levels) in run_specs'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/configuration.rb:1894:in `with_suite_hooks'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/runner.rb:113:in `block in run_specs'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/reporter.rb:79:in `report'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/runner.rb:112:in `run_specs'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/runner.rb:87:in `run'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/runner.rb:71:in `run'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/lib/rspec/core/runner.rb:45:in `invoke'
          /usr/local/lib/ruby/gems/2.4.0/gems/rspec-core-3.6.0/exe/rspec:4:in `<top (required)>'
          /usr/local/bin/rspec:23:in `load'
          /usr/local/bin/rspec:23:in `<main>'
     # ./multi_thread_spec.rb:10:in `pop'
     # ./multi_thread_spec.rb:10:in `block (2 levels) in <top (required)>'
     # ------------------
     # --- Caused by: ---
     #
     #   expected: 0
     #        got: 1
     #
     #   (compared using ==)
     #   ./multi_thread_spec.rb:6:in `block (3 levels) in <top (required)>'

Finished in 0.01328 seconds (files took 0.10413 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./multi_thread_spec.rb:2 # 子スレッドのテストについて 失敗するとpushされないこと

テストは失敗しています。
が、エラーが奇妙です。expectではなくq.popで失敗しています。
No live threads left. Deadlock?* #<Thread:0x007fd29b87f0f8 sleep_forever> と見慣れないものが表示されています。

ドキュメントには記載されていないエラーです。

スレッドが1つしか無いときQueue#popは失敗する

Queue#popが出しているエラーのようです。
試しにirbで実行してみると、再現できます。

irb(main):001:0> Queue.new.pop
fatal: No live threads left. Deadlock?
1 threads, 1 sleeps current:0x007fc6a8406d50 main thread:0x007fc6a8406d50
* #<Thread:0x007fc6a887f0e8 sleep_forever>
   rb_thread_t:0x007fc6a8406d50 native:0x007fff7f0f9000 int:0
   (irb):1:in `pop'
   (irb):1:in `irb_binding'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb/workspace.rb:87:in `eval'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb/workspace.rb:87:in `evaluate'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb/context.rb:381:in `evaluate'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb.rb:493:in `block (2 levels) in eval_input'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb.rb:627:in `signal_status'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb.rb:490:in `block in eval_input'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb/ruby-lex.rb:246:in `block (2 levels) in each_top_level_statement'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb/ruby-lex.rb:232:in `loop'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb/ruby-lex.rb:232:in `block in each_top_level_statement'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb/ruby-lex.rb:231:in `catch'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb/ruby-lex.rb:231:in `each_top_level_statement'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb.rb:489:in `eval_input'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb.rb:430:in `block in run'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb.rb:429:in `catch'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb.rb:429:in `run'
   /usr/local/Cellar/ruby/2.4.1_1/lib/ruby/2.4.0/irb.rb:385:in `start'
   /usr/local/bin/irb:11:in `<main>'

    from (irb):1:in `pop'
    from (irb):1
    from /usr/local/bin/irb:11:in `<main>'
Queueのソースコード

Queueのソースコードを確認してみましょう。

  1. rb_queue_pop関数 から入ります。
  2. queue_do_pop関数
  3. queue_sleep関数
  4. rb_thread_sleep_deadly_allow_spurious_wakeup関数
  5. sleep_forever関数
  6. rb_check_deadlock関数 で例外をあげます。

(ソースコードを読んだときは、エラーメッセージ(6)とrb_queue_pop(1)の両側から辿りました。)

argv[1] = rb_str_new2("No live threads left. Deadlock?");

ここで見たことのあるエラーメッセージが出ています。

スレッドが1つしか無いときQueue#popは失敗するのです。
Rubyのスレッドは賢いのです。

Queueを2つ待つ

エラーメッセージが変ですが、テストは失敗するのでとりあえず良しとしましょう。
一回のテスト実行で複数のテストを実行したいですよね。
テストを2つに増やします。

残念なことに、テストを2つ実行するとテストは固まります。

multi_thread_spec.rb
describe '子スレッドのテストについて' do
  it '失敗するとpushされないこと' do
    q = Queue.new

    Thread.new do
      expect(1).to eq(0)
      q.push ''
    end

    q.pop
  end

  it '失敗するとpushされないこと' do
    q = Queue.new

    Thread.new do
      expect(1).to eq(0)
      q.push ''
    end

    q.pop
  end
end

を、実行すると

rspec multi_thread_spec.rb
F

で固まります。
popした瞬間にスレッドがあれば、pop待ちに成功し、pushはされないため永久に待ち続けます。

もしかするとタイミングによっては再現しないかもしれません。
その場合もテストを増やしていけば、いつか固まるでしょう。

なぜ、expectが失敗した後はpushされないのでしょうか?

理由:RSpecはexpectの失敗を例外で伝える

RSpecはexpectに失敗すると次の順序で動きます。

  1. RSpecはexpectationは失敗するとnotify_failureを呼ぶ
  2. notify_failurenotify_failureを実行
  3. notify_failureはデフォルトではDEFAULT_FAILURE_NOTIFIER
DEFAULT_FAILURE_NOTIFIER = lambda { |failure, _opts| raise failure }

で、例外をあげています。

DEFAULT_FAILURE_NOTIFIERfailure_notifier= で置き換えれます。
RSpecを特に設定せずに使っている分にはそのまま使われます。

つまり子スレッド内で例外が起きています。
Rubyのスレッドは

class Thread (Ruby 2.4.0)

あるスレッドで例外が発生し、そのスレッド内で rescue で捕捉されなかった場合、通常はそのスレッドだけがなにも警告なしに終了されます。

スレッドが終了するため、expectより後ろの

q.push ''

は実行されません。
popしているスレッドは永久に待ちます。

子スレッドのexpect失敗を親スレッドに伝えるには

Thread#join

class Thread (Ruby 2.4.0)

その例外で終了するスレッドを Thread#join で待っている他の スレッドがある場合、その待っているスレッドに対して、同じ例外が再度 発生します。

Thread#joinを使えば、例外を親スレッドに伝えられます。

multi_thread_spec.rb
describe '子スレッドのexpect失敗について' do
  it 'Thread#joinすれば失敗が親スレッドに伝わること' do
    q = Queue.new

    t = Thread.new do
      begin
        expect(1).to eq(0)
      ensure
        q.push ''
      end
    end

    q.pop
    t.join
  end
end

実行すると

rspec multi_thread_spec.rb
F

Failures:

  1) 子スレッドのexpect失敗について Thread#joinすれば失敗が親スレッドに伝わること
     Failure/Error: expect(1).to eq(0)

       expected: 0
            got: 1

       (compared using ==)
     # ./multi_thread_spec.rb:7:in `block (3 levels) in <top (required)>'

Finished in 0.05656 seconds (files took 0.43611 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./multi_thread_spec.rb:2 # 子スレッドのexpect失敗について Thread#joinすれば失敗が親スレッドに伝わること

Thread#raise

あるいはメインスレッドに対してThread#raiseを呼び出します。

multi_thread_spec.rb
describe '子スレッドのexpect失敗について' do
  it 'Thread#raiseすれば失敗が親スレッドに伝わること' do
    q = Queue.new

    main = Thread.current
    Thread.new do
      begin
        expect(1).to eq(0)
      rescue RSpec::Expectations::ExpectationNotMetError => e
        main.raise e
      ensure
        q.push ''
      end
    end

    q.pop
  end
end

例外はRSpec::Expectations::ExpectationNotMetErrorを明示的に指定します。

RSpec 3 から RSpec::Expectations::ExpectationNotMetError がクラス名省略の rescue で捕まえられなくなった - Thanks Driven Life

実行すると

rspec multi_thread_spec.rb
F

Failures:

  1) 子スレッドのexpect失敗について Thread#raiseすれば失敗が親スレッドに伝わること
     Failure/Error: expect(1).to eq(0)

       expected: 0
            got: 1

       (compared using ==)
     # ./multi_thread_spec.rb:8:in `block (3 levels) in <top (required)>'

Finished in 0.01427 seconds (files took 0.1126 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./multi_thread_spec.rb:2 # 子スレッドのexpect失敗について Thread#raiseすれば失敗が親スレッドに伝わること

「子スレッドのexpect失敗を親スレッドに伝えるには」の結論

子スレッドのexpect失敗を親スレッドに伝えるにはスレッド操作が必要です。
これは嬉しくありません。

作戦1のデメリット プロダクトコードにスレッド操作を要求

作戦1のテスト実行時の動きは

  1. 非同期関数のコールバック関数内でexpectする
  2. RSpecのexpectは失敗で例外があがる
  3. 非同期関数(プロダクトコード)内でスレッド操作して、親スレッドに例外を伝える

ここでいう「スレッド操作」は

  • 作った子スレッドを関数呼び出し元に渡す
  • 親スレッドにThread#raiseする

のどちらかのことです。
確かにプロダクトコードも子スレッド内で起きた例外は何らかの形でハンドリングする必要があります。
ただ、要件に応じて

  • ログを出して静かに死ぬ
  • 親スレッドに例外を伝える

などの複数の選択が許されるはずです。

  1. テスティングフレームワークの都合でプロダクトコードの選択肢が狭まるのは嬉しくない
  2. プロダクトコードが壊れて「スレッド操作」が失われた時に失敗を検出できず、テストが止まるのは回帰テストとして嬉しくない
  3. 途中の壊れた「スレッド操作」抜きのコードを許容してくれないのは、サイクルが大きくなり、TDD的に嬉しくない

テスティングフレームワーク的に嬉しくないことばかりなので、作戦1は破棄します。:thumbsdown:

作戦2 Queueで結果を親スレッドに戻してexpect

子スレッドでexpectをするのが問題なので、親スレッドでexpectします。
すでにq.push ''まではしています。
空の情報の代わりに結果を詰めます。
親スレッドはpopで結果を取り出し、expectします。

イメージ

イメージはこうです。

q = Queue.new

Thread.new do
  q.push 1
end

expect(q.pop).to eq(1)

テスト全体では

multi_thread_spec.rb
describe '親スレッドに結果を渡すことについて' do
  it '親スレッドが失敗すること' do
    q = Queue.new

    Thread.new do
      q.push 1
    end

    expect(q.pop).to eq(0)
  end
end

極普通のRSpecのexpectです!これなら問題なく動くでしょう。
実行すると

rspec multi_thread_spec.rb
F

Failures:

  1) 親スレッドに結果を渡すことについて 親スレッドが失敗すること
     Failure/Error: expect(q.pop).to eq(0)

       expected: 0
            got: 1

       (compared using ==)
     # ./multi_thread_spec.rb:9:in `block (2 levels) in <top (required)>'

Finished in 0.05354 seconds (files took 0.413 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./multi_thread_spec.rb:2 # 親スレッドに結果を渡すことについて 親スレッドが失敗すること

:clap: :clap:

汎用的な関数に

ブロックを使って非同期処理を受け取る関数にします。

opening_spec.rb
def opening
  queue = Queue.new

  yield Proc.new{ |qresult| queue.push result }

  queue.pop
end

describe 'opening' do
  it '戻り値で非同期処理の結果が帰ること' do
    result = opening do |curtain|
      Thread.new do
        curtain.call result
      end
    end

    expect(result).to eq(1)
  end
end

実行すると

rspec opening_spec.rb
F

Failures:

  1) opening 戻り値で非同期処理の結果が帰ること
     Failure/Error: expect(result).to eq(0)

       expected: 0
            got: 1

       (compared using ==)
     # ./opening_spec.rb:17:in `block (2 levels) in <top (required)>'

Finished in 0.01449 seconds (files took 0.10845 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./opening_spec.rb:10 # opening 戻り値で非同期処理の結果が帰ること

expectの失敗を検知します。

使用イメージ

async_func_spec.rb
def opening
  queue = Queue.new

  yield -> (result) { queue.push result }

  queue.pop
end

def async_func
  Thread.new do
    # 長い処理

    yield 1
  end
end

describe 'async_func' do
  it '結果が0であること' do
    result = opening do |curtain|
      async_func do |r|
        curtain.call r
      end
    end

    expect(result).to eq(0)
  end
end

同期関数のテストコード

    result = sync_func
    expect(result).to eq(0)

に比べると実行部分は長くなります。
expect部分はほぼ手を入れずに、使い回せるのでこれで良いとしましょう。:tada:

実行すると

rspec async_func_spec.rb
F

Failures:

  1) async_func 結果が0であること
     Failure/Error: expect(result).to eq(0)

       expected: 0
            got: 1

       (compared using ==)
     # ./async_func_spec.rb:25:in `block (2 levels) in <top (required)>'

Finished in 0.01531 seconds (files took 0.1242 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./async_func_spec.rb:18 # async_func 結果が0であること

結果も同期関数のテストとなんら変わりません。:v:

補足

補足1 タイムアウト待ち

プロダクコトード内で例外をrescueしていなかったり

async_func.rb
def async_func
  Thread.new do
    # 長い処理
    rasie ''
    yield 1
  end
end

yieldを呼び忘れていたりすると

async_func.rb
def async_func
  Thread.new do
    # 長い処理
  end
end

永久に回答がこないので、テストが止まります。
(テストが1つの時はQueue#popがNo live threads left. Deadlock?エラーを出します。)

そこでQueueをタイムアウトつきのQueueに変えます。
Ruby Queue Pop with Timeout を参考にしました。

補足2 DBのトランザクションに気をつける

実際にテストコードを書いているとうっかりはまります。
RSpecのbefore(:each)でDBにデータを入れると、トランザクション内で実行されます。
テスト(1つのit)終了時にロールバックします。
これはテストの独立性を高める便利な機能です。
すごく自然に実行されるので、つい忘れてしまいます。
トランザクション内のデータを貼られると別スレッドから参照できません。

同期のテストコードを置き換えていると、うっかり忘れてアイエエエエ! フェイル!? フェイルナンデ!?となります。
before(:each)の代わりにbefore(:all)を使い、after(:all)でデータを削除します。

RSpecの設定を変えてトランザクションを使わないようにもできるようです。

扱うテストデータが少なかったので、before(:all)after(:all)で温かみのあるデータ作成・削除を実現しました。

Gem AsyncPlay

せっかくなのでGemにしておきました。

async_play | RubyGems.org | your community gem host

実績解除 Ruby Gems :trophy:

おまけコンテンツ

非同期Gemの雄、EventMachineはどうやってテストしているのか?

答え、RSpecを使っていない!

おわりに

きっと世の中にはもっと良い方法があるはず。
教えてください:bow:

10
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
6