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!