Use context in ExUnit tests

 Reading time ~5 minutes

Heads up: this article is over a year old. Some information might be out of date, as I don't always update older articles.

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
comments powered by Disqus