Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

like image 871
al_ Avatar asked Jun 09 '16 02:06

al_


2 Answers

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.

like image 123
tkowal Avatar answered Sep 24 '22 22:09

tkowal


Found the FakeIO solution in the accepted answer very helpful. Wished to add another clear example, and also point out delegation from FakeIO to the real IO when needed

Here, I have a simple requirement to write an App that does a little IO, reading a name from STDIN and replying on STDOUT.

Example Output

What is your name? Elixir

Hello, Elixir, nice to meet you!

Below is the "app", a single module called Ex1:

defmodule Ex1 do

  def sayHello(io \\ IO) do
    "What is your name? "
    |> input(io)
    |> reply
    |> output(io)
  end

  def input(message, io \\ IO) do
    io.gets(message) |> String.trim
  end

  def reply(name) do
   "Hello, #{name}, nice to meet you!"
  end

  def output(message, io \\ IO) do
    io.puts(message)
  end

end

And associated tests:

defmodule FakeIO do
  defdelegate puts(message), to: IO
  def gets("What is your name? "), do: "Elixir "
  def gets(value), do: raise ArgumentError, message: "invalid argument #{value}"
end

defmodule Ex1Test do
  use ExUnit.Case
  import ExUnit.CaptureIO
  doctest Ex1

  @tag runnable: true
  test "input" do
    assert Ex1.input("What is your name? ", FakeIO) == "Elixir"
  end

  @tag runnable: true
  test "reply" do
    assert Ex1.reply("Elixir") == "Hello, Elixir, nice to meet you!"
  end

  @tag runnable: true
  test "output" do
    assert capture_io(fn ->
      Ex1.output("Hello, Elixir, nice to meet you!", FakeIO)
    end) == "Hello, Elixir, nice to meet you!\n"
  end

  @tag runnable: true
  test "sayHello" do
    assert capture_io(fn ->
      Ex1.sayHello(FakeIO)
    end) == "Hello, Elixir, nice to meet you!\n"
  end

end

The interesting part is the usage of FakeIO in conjunction with parameter pattern matching and defdelegate to delegate to the real IO.puts call. There is a "catchall" pattern on gets to raise an ArgumentError if anything other than the expected gets param is passed to FakeIO.

 defmodule FakeIO do
   defdelegate puts(message), to: IO
   def gets("What is your name? "), do: "Elixir "
   def gets(value), do: raise ArgumentError, message: "invalid argument #{value}"
 end

Anyhow, hope this offers some insights in regards to FakeIO usage.

like image 45
arcseldon Avatar answered Sep 26 '22 22:09

arcseldon