I would like to test the following code, using MiniTest. The code tests if the user has provided a valid input.
Code being tested:
def player_input(min, max)
loop do
user_input = gets.chomp
verified_number = verify_input(min, max, user_input.to_i) if user_input.match?(/^\d+$/)
return verified_number if verified_number
puts "Input error! Please enter a number between #{min} or #{max}."
end
end
def verify_input(min, max, input)
return input if input.between?(min, max)
end
MiniTest code: The test code stubs the :gets method, with invalid input. And then asserts that the output of the player_input method is an error message.
def test_when_user_inputs_an_incorrect_value_runs_loop_once
invalid_input = '11'
@game_input.stub(:gets, invalid_input) do
min = @game_input.instance_variable_get(:@minimum)
max = @game_input.instance_variable_get(:@maximum)
error_message = "Input error! Please enter a number between #{min} or #{max}."
assert_output(error_message, "") { @game_input.player_input(min, max) }
end
end
The issue: The issue is because I have stubbed the :gets method the test runs in an infinite loop. How do I test a simple loop that gets a Users input in MiniTest?
Thanks
I tried to change the code to STDERR.puts to make sure that the code returns a standard error that can be asserted.
This sort of problem is why you should write your tests first. It's much easier to test code that was designed for testing, rather than having to retrofit your code or test framework after the fact. Learn from this.
Instead of fixing your code or your tests, just assert that you get a Timeout::Error exception from the standard library's Timeout module if you get stuck in your loop for any reason. For example:
require 'timeout'
assert_raises(Timeout::Error) do
Timeout::timeout(0.1) { @game_input.player_input(min, max) }
end
Granted that this isn't validating a specific error message, it solves the actual problem you're facing with your loop without requiring changing your existing logic. Go this route if you just want to stop getting hung inside this particular test and don't care why.
Assuming you want to fix your code, and not just avoid getting stuck inside your loop, you should look to simplify your code. It just seems like an inherently bad idea to stub Kernel#gets, even if you do it inside a block. MiniTest allows you to scope a stub to a block, but there's a much easier way to test your existing code without contorting yourself: just add a method argument to allow you to do something different during testing!
You don't show how you're instantiating your class, and it's generally a bad idea to reuse a modified object for more than one test since that creates stateful and order-dependent tests. So, you could instantiate a new instance of your class and then have your method perform differently when ENV['APP_ENV'] == 'test'
and you've passed some explicit test arguments. Consider the following:
def player_input(min, max, test_input: nil)
loop do
user_input = ENV['APP_ENV'].eql?('test') ? test_input : gets.chomp
verified_number = verify_input(min, max, user_input.to_i) if user_input.match?(/^\d+$/)
return verified_number if verified_number
puts "Input error! Please enter a number between #{min} or #{max}."
end
end
Next, ensure your test suite sets ENV['APP_ENV'] = 'test'
if your test environment doesn't do that for you. You should probably make GameInput expose accessors for your test variables too, rather than trying to use #instance_variable_get. And finally, just call #player_input with a keyword argument for your test value. For example, consider the following as scaffolding to which you may need to make adjustments based on portions of your code you didn't share.
# add the accessor directly to your class,
# or reopen the class & add a read accessor
# in your test suite; the first option is
# better
class GetInput
attr_reader :min, :max
end
# get a clean copy of your class for the test
def test_when_user_inputs_an_incorrect_value_runs_loop_once
# this should really be in your environment,
# but you could set it inside your test setup
# if you really need to
ENV['APP_ENV'] ||= 'test'
game_input = GameInput.new
game_input.player_input game_input.min, game_input.max, test_input: 11
# call your assertions
end
Yes, this requires a bit of code refactoring, but you'll be better off in the long run. There are likely even better ways to refactor so that you're not intrinsically testing #loop or #gets in #player_input at all since you're really trying to test #verify_input, which already takes an extra testable argument!
You can simplify your code a lot by using the extract-method pattern, moving the error message to your validator, and slimming down #player_input. Consider this alternative, which will work code-wise (not necessarily with your existing tests) as long as @min and @max are greater than zero.
def ask_for_number
print "Number: "
gets.to_i
end
def verify_input min, max, input
return true if input.between?(min, max)
msg = "Error: enter number between #{min} and #{max}."
p msg
end
def player_input min, max
loop do
number = ask_for_number
break number if number.positive? && verify_input(min, max, number)
end
end
This logic works fine for me in Ruby 3.2.2, and the simpler methods are easier to test since you can basically ignore #ask_for_number since you should generally assume that built-in methods like #print and #gets just work. If they don't, you have larger issues.
These smaller methods are easier to test and debug, and a lot simpler to reason about. In addition, using Kernel#p instead of Kernel#puts (which always returns nil) means that you have a better way to test your output as a return value rather than trying to capture output. While it's probably more correct to call $stderr.puts msg
than p msg
, using #p prevents you from having to assert something on STDERR rather than just looking a returned String.
You might still need to do something inside your loop, such as adding an additional break or return statement, if you want to exit early when receiving a particular value while under test. Your issue there isn't really the return value or even the loop per se; it's the fact that Ruby is sitting there waiting for input, and re-prompting the user when not under test is probably the right thing to do. There's really not much point in running your input code inside a loop if you only want to run it once under all circumstances, after all.