petitviolet blog

    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.