blog.petitviolet.net

Make Thread synchronous in Rspec

2021-09-25

Ruby

tl;dr

allow(Thread).to receive(:new).and_yield

What’s the problem?

In Ruby, Thread can be used for asynchronous processings that would take a while. However, the asynchronousity sometimes cause unexpected results in unit tests, like Rspec. Say, a class that uses Thread internally to run something in background thread.

class MyThread
  def initialize(&callback)
    @callback = callback
  end

  def run
    Thread.new { @callback.call }
  end
end

MyThread.new { do_somethings() }.run  # to run do_somethings() function asyncronously

How to test MyThread#run to make sure do_somethings function is executed as expected?

Kernel::sleep

Which is I think the easiest way to tackle with asynchronousity, but as you know it’s too primitive and makes the test unstable. A test with Kernel::sleep would look like the following snippet.

it 'can complete after sleep a while' do
  my_thread = MyThread.new { STDOUT.puts("hello") }

  expect(STDOUT).to receive(:puts).with("hello").once
  my_thread.run
  sleep 1 # required to wait for Thread.
end

Yes, it would work, but you have to wait a second for the test to be completed, which is too long. sleep 0.1 also may work, but how to guarantee that? Nobody knows the best duration to sleep.

Stub Thread

Here is the solution I propose.

before do
  allow(Thread).to receive(:new).and_yield
end

it 'runs asynchronously' do
  my_thread = MyThread.new { STDOUT.puts("hello") }

  expect(STDOUT).to receive(:puts).with("hello").once
  my_thread.run
end

This test must suceeed. and_yield to call a given proc immediately. In this case, { STDOUT.puts("hello") } is executed when Thread.new is called. See the document for the details. https://relishapp.com/rspec/rspec-mocks/docs/configuring-responses/yielding

Note that if you call Thread#join after calling Thread.new, it should raise an error because of the stub. Thread.new { do_somethings() } with allow(Thread).to receive(:new).and_yield returns not Thread but the result of do_somethings(), which obviously doesn’t have #join method. Hence, in such case, need to stub Thread#join or even wrap Thread#join with begin-rescue clause.