Search code examples
testingfunctional-programmingelixirex-unit

How do I fake IO input when testing with ExUnit?


I have an Elixir program I'd like to test which gets input from the user via IO.gets several times. How would I go about faking this input in a test?

Note: I would like to return a different value for each IO.gets


Solution

  • The preferred way to do it is to split your code into pure (no side effects) and impure (does the io). So if your code looks like this:

    IO.gets
    ...
    ...
    ...
    IO.gets
    ...
    ...
    

    try to extract the parts between IO.gets into functions that you can test in isolation from IO.gets:

    def fun_to_test do
      input1 = IO.gets
      fun1(input1)
      input2 = IO.gets
      fun2(input2)
    end
    

    and then you can test the functions in isolation. This isn't always the best thing to do, especially if the impure parts are deep inside if, case or cond statements.

    The alternative is to pass the IO as an explicit dependency:

    def fun_to_test(io \\ IO) do
      io.gets
      ...
      ...
      ...
      io.gets
      ...
      ...
    end
    

    This way you can use it from you production code without any modification, but in your test you can pass it some different module fun_to_test(FakeIO). If the prompts are different you can just pattern match on the gets argument.

    defmodule FakeIO do
      def gets("prompt1"), do: "value1"
      def gets("prompt2"), do: "value2"
    end
    

    If they are always the same you need to keep the state of how many times the gets was called:

    defmodule FakeIO do
      def start_link do
        Agent.start_link(fn -> 1 end, name: __MODULE__)
      end
    
      def gets(_prompt) do
        times_called = Agent.get_and_update(__MODULE__, fn state ->
          {state, state + 1}
        end)
        case times_called do
          1 -> "value1"
          2 -> "value2"
        end
      end
    end
    

    This last implementation is a fully working mock with its internal state. You need to call FakeIO.start_link before using it in the test. If this is what you need to do in many places you may consider some mocking library, but as you can see - this isn't too complicated. To make the FakeIO even better you can print the prompt. I skipped this detail here.