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


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.


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
    r, w = IO.pipe
    pid = Process.spawn(*cmd, {:out => w})
    [$?.exitstatus, r.read]

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]
      last_pid += 1
    allow(Process).to receive(:wait).exactly(2) { |pid|
      expect(pid).to eq(last_pid)
      set_last_exit_status 0

    demo = MyDemo.new

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})`

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.