Unit Testing Elixir

Elixir

Tips & tricks for testing Elixir applications… there are tons of libraries for testing, each one with different syntactic sugar, runtime (sync vs. async) and implementation details but all we need for unit testing are these 3: ExUnit, Hammox and Mimic.

Anatomy of a test

  defmodule MathTest do
    # 1. use, require, import testing libraries
    # 2. setup
    # 3. run
    # 4. assert
    # 5. cleanup
  end

Tips & Tricks

1. Use, require, import test libraries

  ExUnit.start(autorun: false, colors: [enabled: false])
  defmodule UseTest do
    use ExUnit.Case
  end
  ExUnit.run([UseTest])
Finished in 0.00 seconds (0.00s async, 0.00s sync)
0 failures
%{total: 0, failures: 0, excluded: 0, skipped: 0}

2. Setup all (per test suite)

Defines a callback to be run before all tests in a case.

  defmodule SetupAllTest do
    use ExUnit.Case
    setup_all(_context) do
      IO.puts("setup_all runs only once before test suite")
      %{x: 1}
    end
    test "add x", %{x: x}, do: assert 2 = x + 1
    test "sub x", %{x: x}, do: assert 0 = x - 1
  end
  ExUnit.run([SetupAllTest])
setup_all runs only once before test suite
..
Finished in 0.00 seconds (0.00s async, 0.00s sync)
2 tests, 0 failures
%{total: 2, failures: 0, excluded: 0, skipped: 0}

2.1 Setup (per test)

Defines a callback to be run before each test in a case.

  defmodule SetupTest do
    use ExUnit.Case
    setup(_context) do # block setup
      IO.write("setup runs once before each test")
      %{x: 1}
    end
    test "add x", %{x: x}, do: assert 2 = x + 1
    test "sub x", %{x: x}, do: assert 0 = x - 1
  end
  ExUnit.run([SetupTest])
setup runs once before each test.setup runs once before each test.
Finished in 0.00 seconds (0.00s async, 0.00s sync)
2 tests, 0 failures
%{total: 2, failures: 0, excluded: 0, skipped: 0}

2.2 Setup functions (shared between test suites)

Setup functions receive the test context as argument and return a test context.

  defmodule Helpers do
    def setup_x(_context), do: %{x: 1}
    def setup_y(_context), do: %{y: 1}
  end
  defmodule SetupFunctionsAddTest do
    use ExUnit.Case
    import Helpers
    setup [:setup_x, :setup_y] # atoms naming imported functions
    test "add x", %{x: x, y: y}, do: assert 2 = x + y
  end
  defmodule SetupFunctionsSubTest do
    use ExUnit.Case
    setup [{Helpers, :setup_x}, {Helpers, :setup_y}] # list of {module, function} tuple
    test "sub x", %{x: x, y: y}, do: assert 0 = x - y
  end
  ExUnit.run([SetupFunctionsAddTest, SetupFunctionsSubTest])
..
Finished in 0.00 seconds (0.00s async, 0.00s sync)
2 tests, 0 failures
%{total: 2, failures: 0, excluded: 0, skipped: 0}

2.3 Setup tags (per test)

Test specific tags are merged into context and passed as arguments to setup functions.

  defmodule Helpers do
    def setup_x(%{type: :integer}), do: %{x: 1}
    def setup_x(%{type: :float}), do: %{x: 1.0}
  end
  defmodule SetupTagTest do
    use ExUnit.Case
    setup {Helpers, :setup_x}
    @tag type: :integer
    test "add x integer", %{x: x}, do: assert 2 = x + 1
    @tag type: :float
    test "add x float", %{x: x}, do: assert 2.0 = x + 1.0
  end
  ExUnit.run([SetupTagTest])
..
Finished in 0.00 seconds (0.00s async, 0.00s sync)
2 tests, 0 failures
%{total: 2, failures: 0, excluded: 0, skipped: 0}

2.4 Setup tags (per test group)

Tags are merged into each test of describe group.

  defmodule Helpers do
    def setup_x(%{type: :integer}), do: %{x: 1}
    def setup_x(%{type: :float}), do: %{x: 1.0}
  end
  defmodule SetupTagTest do
    use ExUnit.Case
    import Helpers
    setup :setup_x
    describe "add" do
      @describetag type: :integer
      test "add x integer", %{x: x}, do: assert 2 = x + 1
      test "sub x integer", %{x: x}, do: assert 0 = x - 1
    end
    describe "sub" do
      @describetag type: :float
      test "add x float", %{x: x}, do: assert 2.0 = x + 1.0
      test "sub x float", %{x: x}, do: assert 0.0 = x - 1.0
    end
  end
  ExUnit.run([SetupTagTest])
....
Finished in 0.00 seconds (0.00s async, 0.00s sync)
4 tests, 0 failures
%{total: 4, failures: 0, excluded: 0, skipped: 0}

2.5 Setup DRY using macros

Fine-tune setup functions with metaprogramming code injection.

  defmodule TestMacros do
    defmacro __using__(only: fixtures) do
      for fixture <- fixtures, is_atom(fixture), do: apply(__MODULE__, fixture, [])
    end
    def integers() do
      quote do
        def setup_x(%{type: :integer}), do: %{x: 1}
      end
    end
    def floats() do
      quote do
        def setup_x(%{type: :float}), do: %{x: 1.0}
      end
    end
  end
  defmodule DryMacrosTest do
    use ExUnit.Case
    use TestMacros, only: [:integers]
    setup :setup_x
    @tag type: :integer
    test "add x integer", %{x: x}, do: assert 2 = x + 1
  end
  ExUnit.run([DryMacrosTest])
.
Finished in 0.00 seconds (0.00s async, 0.00s sync)
1 test, 0 failures
%{total: 1, failures: 0, excluded: 0, skipped: 0}

2.5 Setup DRY using ExUnit.CaseTemplate

Use standard ExUnit's CaseTemplate to provide test support functions.

  defmodule TestCase do
    use ExUnit.CaseTemplate
    using(opts) do
      quote do
        import TestCase, unquote(opts)
      end
    end
    def setup_x(%{type: :integer}), do: %{x: 1}
    def setup_x(%{type: :float}), do: %{x: 1.0}
    def setup_y(%{}), do: %{y: 1}
  end
  defmodule DryTest do
    use TestCase, only: [setup_x: 1]
    setup [:setup_x]
    @tag type: :integer
    test "add x integer", %{x: x}, do: assert 2 = x + 1
    @tag type: :float
    test "add x float", %{x: x}, do: assert 2.0 = x + 1.0
  end
  ExUnit.run([DryTest])
..
Finished in 0.00 seconds (0.00s async, 0.00s sync)
2 tests, 0 failures
%{total: 2, failures: 0, excluded: 0, skipped: 0}

3.1 Run mocks with Hammox (Mox)

If you own the behaviour of the dependency module it is recommended to use Hammox (or Mox). The advantage of Hammox over Mox is that it also checks callback' typespecs.

  Mix.install([
    {:hammox, "~> 0.7"}
  ])
  ExUnit.start(autorun: false, colors: [enabled: false])

  defmodule Math do
    @callback pow(x :: number, y :: number) :: number
  end
  defmodule RealMath do
    @behaviour Math
    def pow(x, y), do: x**y
  end
  Mox.defmock(MockMath, for: Math)
  Application.put_env(:blog, :math, MockMath)

  defmodule HammoxMath do
    def work(x, y), do: math().pow(x, y)
    defp math(), do: Application.get_env(:blog, :math, RealMath)
  end
  defmodule HammoxMathTest do
    use ExUnit.Case, async: true
    import Mox
    test ".work/2" do
      expect MockMath, :pow, fn 1, 1 -> 99 end
      assert 99 = HammoxMath.work(1, 1)
    end
  end
  ExUnit.run([HammoxMathTest])
.
Finished in 0.00 seconds (0.00s async, 0.00s sync)
1 test, 0 failures
%{total: 1, failures: 0, excluded: 0, skipped: 0}

3.2 Run mocks with Mimic

If you do not own the behaviour of the dependency module (use an external library) then use Mimic which can check the typespecs as well with experimental type_check: true option.

Note: due to implementation details Mimic has to be initialized before ExUnit library.

  Mix.install([
    {:math, "~> 0.7"},
    {:mimic, "~> 1.11"}
  ])
  Mimic.copy(Math, type_check: true)
  ExUnit.start(autorun: false, colors: [enabled: false])

  defmodule MimicMath do
    def work(x, y), do: Math.pow(x, y)
  end
  defmodule MimicMathTest do
    use ExUnit.Case, async: true
    use Mimic
    test ".work/2" do
      expect Math, :pow, fn x, y -> 99 end
      assert 99 = MimicMath.work(1, 1)
    end
  end
  ExUnit.run([MimicMathTest])
.
Finished in 0.1 seconds (0.1s async, 0.00s sync)
1 test, 0 failures
%{total: 1, failures: 0, excluded: 0, skipped: 0}

4.1 Assert functions

Pros of Functions:

  • simpler to write and understand
  • runtime validation
  • can be passed as arguments
  • can use pattern matching
  defmodule AssertFunctionsTest do
    use ExUnit.Case
    test "assert functions" do
      assert_operator 2, 1, 1, &+/2, "hairy math"
      assert_operator 0, 1, 1, &-/2
    end
    defp assert_operator(r, a, b, operator, message \\ "") do
      assert ^r = operator.(a, b), message
    end
  end
  ExUnit.run([AssertFunctionsTest])
.
Finished in 0.00 seconds (0.00s async, 0.00s sync)
1 test, 0 failures
%{total: 1, failures: 0, excluded: 0, skipped: 0}

4.1 Assert macros

Pros of Macros:

  • better error messages with source code context
  • can capture the actual expression being tested
  • can manipulate AST before execution
  • access to compile-time information
  defmodule AssertMacrosTest do
    use ExUnit.Case
    defmacro assert_equal(result, expr) do
      quote do
        if unquote(result) != unquote(expr) do
          IO.puts """
          ASSERT_EQUAL FAILURE:
            Expected: "#{inspect unquote(result)}"
            to be equal to: #{inspect unquote(Macro.to_string(expr))}
          """
        end
        assert unquote(result) == unquote(expr)
      end
    end
    test "assert macros" do
      assert_equal 2, 1 + 1
      assert_equal 2, 1 - 1
    end
  end
  ExUnit.run([AssertMacrosTest])
ASSERT_EQUAL FAILURE:
  Expected: "2"
  to be equal to: "1 - 1"



  1) test assert macros (AssertMacrosTest)
     iex:15
     Assertion with == failed
     code:  assert 2 == 1 - 1
     left:  2
     right: 0
     stacktrace:
       iex:17: (test)


Finished in 0.00 seconds (0.00s async, 0.00s sync)
1 test, 1 failure
%{total: 1, failures: 1, excluded: 0, skipped: 0}

5.1 Cleanup (per test)

Cleanup workaround that runs once at test exit.

  defmodule CleanupAllTest do
    use ExUnit.Case
    setup(_context) do
      on_exit(fn ->
        IO.write("cleanup runs once after each test")
      end)
    end
    test "add", do: assert 2 = 1 + 1
    test "sub", do: assert 0 = 1 - 1
  end
  ExUnit.run([CleanupAllTest])
cleanup runs once after each test.cleanup runs once after each test.
Finished in 0.00 seconds (0.00s async, 0.00s sync)
2 tests, 0 failures
%{total: 2, failures: 0, excluded: 0, skipped: 0}

5.2 Cleanup all (per test suite)

Cleanup_all workaround that runs once per test suite.

#+begin_src elixir
  defmodule CleanupAllTest do
    use ExUnit.Case
    setup_all(_context) do
      on_exit(fn ->
        IO.write("cleanup_all runs once after test suite")
      end)
    end
    test "add", do: assert 2 = 1 + 1
    test "sub", do: assert 0 = 1 - 1
  end
  ExUnit.run([CleanupAllTest])
..cleanup_all runs once after test suite
Finished in 0.00 seconds (0.00s async, 0.00s sync)
2 tests, 0 failures
%{total: 2, failures: 0, excluded: 0, skipped: 0}

Happy testing…