I recently saw a post on Reddit where the OP asked how to test a method which involved
gets. The example the OP posted looked like the following (which I’ve edited very slightly for clarity):
class Example def ask_for_number puts "Input an integer 5 or above" loop do input = gets.to_i return true if input >= 5 puts "Invalid. Try again:" end end end
What makes the
ask_for_number method challenging to test is a dependency. Most methods can be tested by saying, “When I pass in argument X, I expect return value Y.” This one isn’t so straightforward though. This is more like “When the user sees output X and then enters value V, expect subsequent output O.”
Instead of accepting arguments, this method gets its value from user input. And instead of necessarily returning a value, this method sometimes simply outputs more text.
How can we give this method the values it needs, and how can we observe the way the method behaves when we give it these values?
A solution using dependency injection (thanks to Myron Marston)
Originally, I had written my own solution to this problem, but then Myron Marson, co-author of Effective Testing with RSpec 3, supplied an answer of his own which was a lot better than mine. Here it is.
I comment on this solution some more below, but you can see in the
initialize method that the
output dependencies are being injected into the class. By default, we use
$stdout, and under test, we use something else for easier testability.
class Example def initialize(input: $stdin, output: $stdout) @input = input @output = output end def ask_for_number @output.puts "Input an integer 5 or above" loop do input = @input.gets.to_i return true if input >= 5 @output.puts "Invalid. Try again:" end end end require 'stringio' RSpec.describe Example do context 'with input greater than 5' do it 'asks for input only once' do output = ask_for_number_with_input(6) expect(output).to eq "Input an integer 5 or above\n" end end context 'with input equal to 5' do it 'asks for input only once' do output = ask_for_number_with_input(5) expect(output).to eq "Input an integer 5 or above\n" end end context 'with input less than 5' do it 'asks repeatedly, until a number 5 or greater is provided' do output = ask_for_number_with_input(2, 3, 6) expect(output).to eq <<~OUTPUT Input an integer 5 or above Invalid. Try again: Invalid. Try again: OUTPUT end end def ask_for_number_with_input(*input_numbers) input = StringIO.new(input_numbers.join("\n") + "\n") output = StringIO.new example = Example.new(input: input, output: output) expect(example.ask_for_number).to be true output.string end end
Under test, an instance of
StringIO can be used instead of
$stdout, thus making the messages sent to
@output visible and testable. That’s how we can “see inside” the
Example class and test what at first glance appears to be a difficult-to-test piece of code.