Introduction
I’m on my journey to learn the Elixir programming language and I’m really enjoying the process so far.
Elixir is a dynamic, functional language designed for building scalable and maintainable applications.
Switching from a Object-Oriented approach to a functional approach makes me appreciate the benefits of immutable data and function composability.
I decided to document my progress in my blog, specifically I would like to write about any non-trivial issue that I encounter during this learning path.
Unit Tests
I’m not going to outline the benefits of testing your own code, whatever programming language you’re using. If you’re not writing tests you’re purposely making your life harder, therefore I highly suggest to take your time and change your habits.
The official unit testing framework in Elixir is ExUnit. It’s not part of the core Elixir language, but it’s maintained by the Elixir team, so it’s most likely the preferred choice for any Elixir developer. Moreover I’m not aware of any other existing framework at the moment.
If you come from any other unit test framework you don’t have to learn new concepts, because they are almost a de-facto standard in the industry (think at the Arrange, Act and Assert pattern for example). The only thing that changes of course is the syntax.
Hands on: testing a File Parser
I’m learning Elixir by reading the awsome book Elixir in Action by Saša Jurić. The book also contains smaller exercises for the reader in order to practice the concepts outlined in each chapter. One of these exercies involves writing a FileParser
module that can read a text file and return some information about its content, such as the length of each line or the longest line.
The module is presented below. Moduledoc declarations have been removed for the sake of brevity.
defmodule FileParser do
def filter_lines!(path) do
path
|> File.stream!()
|> Stream.map(&String.replace(&1, "\n", ""))
end
def large_lines!(path) do
path
|> filter_lines!()
|> Enum.filter(&(String.length(&1) > 80))
end
def lines_length!(path) do
path
|> filter_lines!()
|> Enum.map(&(String.length(&1)))
end
def longest_line_length!(path) do
path
|> lines_length!()
|> Enum.max()
end
def longest_line!(path) do
path
|> filter_lines!()
|> Enum.max_by(&(String.length(&1)))
end
def words_per_line!(path) do
path
|> filter_lines!()
|> Enum.map(&(length(String.split(&1))))
end
end
I’m not digging into the details of how this works because explaining the Elixir syntax is outside the scope of this article.
Add tests
Now let’s try to add a few tests for the large_lines!/1
function, which returns the lines in the file which are longer than 80 characters.
In the file_parser_test.exs
file we can write this code
# Require the file parser module
Code.require_file("file_parser.exs", __DIR__)
# Start and configure ExUnit
ExUnit.start()
ExUnit.configure(exclude: :pending, trace: true)
defmodule FileParserTest do
use ExUnit.Case, async: true
test "the large_lines! function should return 0 if there are no lines with more than 80 characters" do
{:ok, file} = File.open("test.txt", [:write])
IO.binwrite(file, "hello\nworld")
assert Enum.count(FileParser.large_lines!("test.txt")) === 0
File.rm("test.txt")
end
test "the large_lines! function should return the number of lines with more than 80 characters" do
{:ok, file} = File.open("test.txt", [:write])
IO.binwrite(file, String.duplicate("a", 81))
assert Enum.count(FileParser.large_lines!("test.txt")) === 1
File.rm("test.txt")
end
end
The first thing to notice here is the async: true
option for ExUnit.Case
. This option instructs ExUnit to run tests in this module concurrently with tests in other modules. Tests in the same module never run concurrently. If we would have had another module testing some code using the same test.txt
file we should have removed the async
option in order to avoid issues with concurrency.
Setup
Next we can see that we have some duplicate code in these tests: we create a new empty file at the beginning and we remove such file at the end of each test. It would be nice to use some kind of setup function to lay down the stage for our tests and eventually to clean it up afterwards. PHPUnit for example provides the setUp
and tearDown
functions for this purpose, but pretty much any other testing framework provides equivalent functions.
ExUnit makes no exception. It provides a handful of callback functions that may be used to
define test fixtures and run any initialization code which help bring the system into a known state
For example let’s see how we can use the setup/1
function to run code before each test in a case:
defmodule FileParserTest do
use ExUnit.Case, async: true
setup do
{:ok, file} = File.open("test.txt", [:write])
{:ok, %{f: file}}
end
test "the large_lines! function should return 0 if there are nolines with more than 80 characters", context do
IO.binwrite(context.f, "hello\nworld")
assert Enum.count(FileParser.large_lines!("test.txt")) === 0
File.rm("test.txt")
end
end
As you can see in the setup
function we’re returning a Map
with a reference to the test file. This data, called context
, is then shared across our tests in the same file.
As you can see I’m returning a Map
where the key that holds the file is called f
. It’s not called file
for a specific reason: file
in context
is a reserved word, therefore trying to override it will cause a runtime error.
See https://github.com/elixir-lang/elixir/issues/4236 for more information.
There’s also a setup_all
function that is invoked only once per module, before any test is run.
It’s important to mention that a test module can define multiple setup
and setup_all
callbacks, and they are invoked in order of appearance. Each one of these callbacks can optionally receive the context and extend it.
Teardown
We can use the setup
function to also define the on_exit/2
callback to undo the action performed during the setup phase. In our case we use this function to safely remove the test file from the filesystem at the end of each test. on_exit/2
gets executed in a blocking fashion, in a different process, after a test exits and before running the next test.
defmodule FileParserTest do
use ExUnit.Case, async: true
setup do
{:ok, file} = File.open("test.txt", [:write])
on_exit(fn ->
File.rm("test.txt")
end)
{:ok, %{f: file}}
end
test "the large_lines! function should return 0 if there are nolines with more than 80 characters", context do
IO.binwrite(context.f, "hello\nworld")
assert Enum.count(FileParser.large_lines!("test.txt")) === 0
end
end