Testing console IO in Ruby

Writing unit tests can get tricky when we reach the ‘edge’ of our software, the point where it has to impact something in the outside world. This could be updating a database, calling an api via the web or in the case of my tic-tac-toe game outputting to the console.

In Ruby there are a few options for dealing with this. For testing output only RSpec offers matchers that temporarily monkey patch the normal $stdout / $stderror to verify input:

expect { print('foo') }.to output('foo').to_stdout }

This is simple but ultimately feels a little unsafe. Hijacking global variables is the sort of thing that can come back to bite you eventually. It also does not help deal with input. What if we want to prompt a user and get their input?

def get_space_choice(max_space)
print "Choose space (1-#{max_space}): "
input_string = gets.chomp
begin
space = Integer(input_string)
if space >= 1 && space <= max_space
return space
end
rescue ArgumentError, TypeError
end
get_space_choice(max_space)
end

A classic answer for dealing with tricky externalities is to use dependency injection. We ‘inject’ the functionality to read and write information as input parameters to the class or function under test.

def get_space_choice(max_space, input, output)
output.print "Choose space (1-#{max_space}): "
input_string = input.gets.chomp
begin
space = Integer(input_string)
if space >= 1 && space <= max_space
return space
end
rescue ArgumentError, TypeError
end
get_space_choice(max_space)
end

In production code the input and output parameters can be $stdin / $stdout while in tests we can pass in anything that provides gets and print functions. A reasonable candidate is StringIO which can be setup to provide input and tested for output.

RSpec.describe "get_space_choice" do
let(:input) { StringIO.new("5\n") }
let(:output) { StringIO.new }
let(:max_space) { 9 }
it "prompts for space" do
get_space_choice(max_space, input, output)
expect(output.string).to include "Choose space (1-9): "
end

context "when input space is valid" do
it "returns input space" do
expect(get_space_choice(max_space, input, output)).to eq 5
end
end
end

Coming more from a statically typed language background the ability to pass in any kind of test double without having to define interfaces or inheritance relationships is definitely a plus!

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.