Search code examples
rubyminitest

Ruby MiniTest: Test running in an inifite loop


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.


Solution

  • Why You Should Do Test-First in Future

    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.

    The Simplest Thing That Can Possibly Work

    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.

    Make Things Simpler

    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!

    Simplify Your Method Logic

    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.