New to elixir, looking for guidance regarding how to test things like IO, File, and System
Whenever I learn a new language, I always make sure I know how to test it by implementing the following “Hello, world!” application.
defmodule HelloApp do
def main() do
time_unit = :microsecond
microseconds_before = System.monotonic_time time_unit
target = System.argv |> List.first |> File.read!
IO.puts "Hello, #{target}!"
microseconds_after = System.monotonic_time time_unit
microseconds_duration = microseconds_after - microseconds_before
IO.puts "Took #{microseconds_duration} microseconds"
end
end
I intentionally choose the requirements for this application because they have the most extreme combination of easy-to-write yet hard-to-test that I can think of.
As it is in any language, the trick is to isolate the side effects behind an abstraction of some kind.
With Elixir this seems harder to do than normal, but perhaps that is simply because I don’t know the proper language constructs to hide the side effects behind an abstraction.
I don’t see a way to swap out System, IO, and File with stub versions because they are sitting in a global namespace.
I did look at ExUnit.CaptureIO, but rejected it for 2 reasons.
First reason is that as the capture is happening globally, I become vulnerable to one test affecting another.
Second reason is that CaptureIO is not generalizable to File and System.
Ideally I would like each test to validate the implementation in a sandbox independent of other tests with all the side-effecting modules swapped out.
I was able to get 100% test coverage by inverting the dependencies, so it looks like this may indeed be the right answer.
However, since this is my first ever Elixir program, I want to check with people with more Elixir experience than me to make sure I am on the right track.
What do you think of this solution to getting IO, File, and System under 100% test coverage?
Can you guide me to a better solution?
For the implementation, I invert each side-effecting dependency
defmodule HelloAppInverted1 do
def main(collaborators) do
system = collaborators.system
file = collaborators.file
io = collaborators.io
time_unit = :microsecond
microseconds_before = system.monotonic_time.(time_unit)
target = system.argv.() |> List.first |> file.read!.()
io.puts.("Hello, #{target}!")
microseconds_after = system.monotonic_time.(time_unit)
microseconds_duration = microseconds_after - microseconds_before
io.puts.("Took #{microseconds_duration} microseconds")
end
end
For production, I configure to use the real thing:
defmodule HelloAppEntry1 do
def main() do
system = %{
:monotonic_time => &System.monotonic_time/1,
:argv => &System.argv/0
}
file = %{
:read! => &File.read!/1
}
io = %{
:puts => &IO.puts/1
}
collaborators = %{
:system => system,
:file => file,
:io => io
}
HelloAppInverted1.main(collaborators)
end
end
For testing, I configure to use stubs:
defmodule HelloAppInverted1Test do
use ExUnit.Case
@moduletag timeout: 1_000
test "say hello to world" do
tester = create_tester(
%{
:command_line_arguments => ["configuration.txt"],
:remaining_monotonic_time_values => [1000, 1234],
:file_contents_by_name => %{
"configuration.txt" => "world"
},
:lines_emitted => []
}
)
tester.run.()
assert tester.lines_emitted.() == ["Hello, world!", "Took 234 microseconds"]
end
def create_tester(initial_state) do
state_process = create_process(initial_state)
monotonic_time = fn time_unit ->
send(state_process, {:get_monotonic_time, time_unit, self()})
receive do
x -> x
end
end
argv = fn ->
send(state_process, {:get_argv, self()})
receive do
x -> x
end
end
read! = fn file_name ->
send(state_process, {:get_file_contents, file_name, self()})
receive do
x -> x
end
end
puts = fn output_string ->
send(state_process, {:puts, output_string})
end
lines_emitted = fn ->
send(state_process, {:get_lines_emitted, self()})
receive do
x -> x
end
end
system = %{
:monotonic_time => monotonic_time,
:argv => argv
}
file = %{
:read! => read!
}
io = %{
:puts => puts
}
collaborators = %{
:system => system,
:file => file,
:io => io
}
run = fn ->
HelloAppInverted1.main(collaborators)
end
%{
:run => run,
:lines_emitted => lines_emitted
}
end
def create_process(state) do
spawn_link(fn -> loop(state) end)
end
def consume_monotonic_time(state) do
[time_value | remaining_time_values ] = state.remaining_monotonic_time_values
new_state = Map.replace(state, :remaining_monotonic_time_values, remaining_time_values)
{new_state, time_value}
end
def append_line(state, line) do
new_lines_emitted = [line | state.lines_emitted]
Map.replace(state, :lines_emitted, new_lines_emitted)
end
def loop(state)do
%{
:command_line_arguments => ["configuration.txt"],
:remaining_monotonic_time_values => [1000, 1234],
:file_contents_by_name => %{
"configuration.txt" => "world"
},
:lines_emitted => []
}
receive do
{:get_lines_emitted, caller} ->
send(caller, Enum.reverse(state.lines_emitted))
loop(state)
{:get_monotonic_time, :microsecond, caller} ->
{new_state, monotonic_time_value} = consume_monotonic_time(state)
send(caller, monotonic_time_value)
loop(new_state)
{:get_argv, caller} ->
send(caller, state.command_line_arguments)
loop(state)
{:get_file_contents, file_name, caller} ->
file_contents = state.file_contents_by_name[file_name]
send(caller, file_contents)
loop(state)
{:puts, line} ->
new_state = append_line(state, line)
loop(new_state)
x ->
raise "unmatched pattern #{inspect x}"
end
end
end
- edit, I said production twice, the last snippet was for testing, not production