This is a followup to my earlier attempt here
The difference I am experimenting with here is to see if swapping out entire modules rather than individual functions is less intrusive on the code under test.
The problem of separating determinism from nondeterminism has been solved for decades with dependency injection,
and until someone is able to point out a better way,
I am sticking with the theory of dependency injection for now.
The idea is to pass modules into the parameter list in production,
and make stubs and fakes look like modules for test.
This turned out to be much more difficult than I expected, but I am not sure if that is because it is not easy in Elixir, or if I am not aware of the proper language constructs.
I ended up dynamically naming my fake and stub modules to keep their state isolated from each other.
I refactored duplication with a macro.
I pasted the IOFake module to the end of test/test_helper.exs because I couldn’t figure out another way to get the other tests to see it.
Although I would prefer a solution that is more simple and less clever,
this does still seem, to me, to be an objectively superior way to test non-determinism in Elixir code than what I have seen so far in the ecosystem.
I can run my tests in parallel without worrying about colliding state.
I have taken complete control of all non-determinism.
The code under test does not need to change much (I could even leave the parameters capitalized, making them look like modules instead of parameters).
I don’t have to make excuses regarding why this or that is too hard to test, I just test everything.
I only did IO.puts here, but you can see how this can be extended to any non-determinism.
For those of you that are interested in this kind of thing, do you see any flaws in my logic?
Or perhaps I missed a better way because I don’t know elixir that well yet?
defmodule ConcurrentA do
def send_line(line, io) do
io.puts(line)
end
end
defmodule ConcurrentB do
def send_line(line, io) do
io.puts(line)
end
end
defmodule ConcurrentATest do
use ExUnit.Case, async: true
test "send line" do
Fake.with_io fn io ->
ConcurrentA.send_line("hello", io)
ConcurrentA.send_line("world", io)
actual = io.get_lines()
expected = ["hello", "world"]
assert expected == actual
end
end
end
defmodule ConcurrentBTest do
use ExUnit.Case, async: true
test "send line" do
Fake.with_io fn io ->
ConcurrentB.send_line("hello", io)
ConcurrentB.send_line("world", io)
actual = io.get_lines()
expected = ["hello", "world"]
assert expected == actual
end
end
end
# Pasted at the end of test_helper.exs
defmodule IOFake do
defmacro __using__(_) do
quote do
def loop(lines) do
receive do
{:get_lines, caller} ->
send(caller, Enum.reverse(lines))
loop(lines)
{:puts, line} ->
new_lines = [line | lines]
loop(new_lines)
{:stop, caller} ->
IO.puts("stopped #{__MODULE__}")
send(caller, :stopped)
x -> raise "unmatched pattern #{inspect x}"
end
end
def puts(line) do
send(__MODULE__, {:puts, line})
end
def get_lines() do
send(__MODULE__, {:get_lines, self()})
receive do x -> x end
end
def start() do
process = spawn_link(fn -> __MODULE__.loop([]) end)
Process.register(process, __MODULE__)
IO.puts("started #{__MODULE__}")
end
def stop() do
send(__MODULE__, {:stop, self()})
receive do x -> x end
end
end
end
end
defmodule Fake do
def with_io(f) do
id = System.unique_integer([:positive])
{_, io, _, _} = defmodule String.to_atom("IOFake#{id}") do
use IOFake
end
io.start()
result = f.(io)
io.stop()
result
end
end