HomeAboutGitlab

How to mock Process::Spawn (and $?) on ruby

Intro

I've been writing a new tool to create and syncrhonize snapshots for my zfs setup (yes, I know about the existing ones). However, because most tools will interact with the zfs binaries to manipulate the filesystem, when writing tests, you need a way to mock the code that will actually run the zfs command. In my case, I was using ruby's Process.spawn.

Mocking

Let's first look at our code. If you spawn a process internally to execute an external process in your code, it might look something like this.

module Runner
  def self.run_external_process(needs_sudo, command, *args)
    cmd = [command, *args].flatten
    if needs_sudo
      cmd.unshift SUDO_PATH
    end
    r, w = IO.pipe
    pid = Process.spawn(*cmd, {:out => w})
    w.close
    Process.wait(pid)
    [$?.exitstatus, r.read]
  end
end

So, what we want to do, is to test this code and try to avoid excesive dependency injection.

My first approach was to actually use dependency injection and create a TestRunner that would implement a mock run_process. You could even use rspec-mocks and create a double that would only expect the run_process method to be called. That last approach worked just fine for me, but after a bit, the coverage report started to mark that section as "not tested". It should be fine but it was still a very sensitive section. Besides, I decide to use the dependency injection to switch to another runner in some other parts of the code (a remote runner). That would've break setting the runner for testing.

I decided to use the same rspec-mocks but this time to stub the methods spawn and wait inside the Process module. The most complicated part is to mock wait, because it internally modifies the $? global variable, which in ruby is read only. Doing it this way, simplifies my testing strategy and the specs remain with the same flexibility.

Ok, now with a more complete background, let's try this. Let's see how the specs might look like.

RSpec.describe MyDemo do
  it "should mock Process.spawn properly" do
    my_fake_output = generate_fake_data(...)
    last_pid = 0
    allow(Process).to receive(:spawn).exactly(2) { |*cmd, opts|
      # here you can check that cmd is what you expect
      expect(cmd.size).to eq(4)
      expect(cmd.first).to eq("/usr/bin/zfs")
      # write to stdout
      opts[:out].write my_fake_output[last_pid]
      opts[:out].close
      last_pid += 1
    }
    allow(Process).to receive(:wait).exactly(2) { |pid|
      expect(pid).to eq(last_pid)
      set_last_exit_status 0
      last_pid
    }

    demo = MyDemo.new
    demo.do_something_that_will_call_run_process
  end
end

That should cover how to "spawn" a process, return the proper output and a sequential pid. The missing part here is that wait should set the status of the fake process. We need to find a way to create another dummy process that will set $? for us. The easiest way seems to spawn a shell process and just execute exit (this is inspired by this SO answer).

def set_exit_status(status)
  `(exit #{status})`
end

With that, you have a fully closed loop that you can use to test your code that will call Process.spawn and Process.wait :)

Happy coding & testing!

Copyright © 2012-2018 Rolando Abarca - Powered by orgmode

Creative Commons License

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License unless otherwise noted.