Live to Code

code to live

Testing Infinite Loops with RSpec


05 Mar 2014 | ,

Recently I had to make a minor modification to a job scheduler; a ruby process that runs an infinite loop on startup, which polls the database for jobs that need processing every so often.

The code was easy to change but for a while I was stumped on how I could test my changes, since they were inside the loop.

class Scheduling::Daemon
  def run
    # ... (code to set up polling period and calculate next tick)
    loop do
      if JobScheduler::PropertiesControl &&
        java.lang.System.getProperties['jobScheduler.mode'] == 'stop'
        # ... (code to write to log etc)
        break
      end

      # ... (code to sleep for calculated period of time)
      JobScheduler::process_jobs
    end
  end
end

One option was to refactor the contents of the loop into a separate method and just test that, but I wanted to be able to test the entire run method, including the termination path. I also didn't want to make huge changes to this class as it's currently used in production by a lot of different systems and is a critical part of the system.

I realised that if I refactored the exit condition to be a method, I could stub it and get it to return false on its first invocation then true on its second invocation; ensuring the loop only gets executed once and then terminates.

Refactored class:

class Scheduling::Daemon
  def run
    # ... (code to set up polling period and calculate next tick)
    loop do
      if daemon_received_stop_signal?
        # ... (code to write to log etc)
        break
      end

      # ... (code to sleep for calculated period of time)
      JobScheduler::process_jobs
    end
  end

  private

  def daemon_received_stop_signal?
    JobScheduler::PropertiesControl &&
      java.lang.System.getProperties['jobScheduler.mode'] == 'stop'
  end
end

Now the spec is easy to write and run:

describe Scheduling::Daemon do
  describe "#run" do
    before do
      Scheduling::Daemon.should_receive(:daemon_received_stop_signal?).
        and_return(false, true)  # execute loop once then exit
    end

    it "calls process_jobs" do
      JobScheduler.should_receive(:process_jobs)
      Scheduling::Daemon.run
    end
  end
end