Mocking in Elixir: Comparison between Mox, Mockery, Mimic, Syringe, and Lightweight DI
Tags: elixir, testing, mock, compare
Published: 2020-05-02 15:58:52

Unit test in elixir is fantastic using ExUnit! It is simple and easy! However sometimes we need to test function/module which access outside resources our code, like third party services or API. 

These outside resource may 
- take time to be accessed (due to network) 
- require additional money (in case of pay per request API) 
- not return stable data (in case of live updating data) 

These make accessing outside resource directly when unit testing is not desirable, hence the need of **mock** these resources in our unit test so it can still be fast, cheap, and stable.

This article aims to explore 5 different ways of mocking in elixir: Mox, Mockery, Mimic, Syringe, and Lightweight DI

### Code to test
Here is our sample code to test, `corona_news`. `corona_news` is a simple application to display latest update regarding Corona Virus / COVID-19 by accessing https://api.covid19api.com. We would like to test mocking `CoronaNews.Gateway`

```elixir
defmodule CoronaNews do
  @moduledoc """
  Module for displaying Corona Virus (COVID-19) data update
  output of this module is expected to be readable by human user
  """

  @doc """
  Return human-readable text summary of corona update for a country

  ## Example
    iex> text_news_for(CoronaNews.Country.global)
    Total Case: 3340989
    Total Recovered: 1052510
    New Case: 86819
    Latest Update: 2020-05-02 12:50:40.760326Z
  """
  def text_news_for(country \\ CoronaNews.Country.global()) do
    result = CoronaNews.Gateway.fetch_data_for_country(country)
    case result do
      {:ok, data} ->
        """
        Total Case: #{data.total_case}
        Total Recovered: #{data.total_recovered}
        New Case: #{data.new_case}
        Latest Update: #{data.latest_update}
        """
      {:error, error} ->
        """
        Failed fetching data
        Error: #{inspect error}
        """
    end
  end

  @doc """
  Display human-readable text summary of corona update for a country
  """
  def display_news_for(country \\ CoronaNews.Country.global()) do
    IO.puts(text_news_for(country))
  end
end

defmodule CoronaNews.Country do
  @moduledoc """
  Module for storing constants identifier of each country
  Country Identifier is CountryCode from https://api.covid19api.com
  """

  @type t :: String.t()

  def global, do: "Global"
  def indonesia, do: "ID"
  def china, do: "CN"
  def united_states_of_america, do: "US"
end

defmodule CoronaNews.Gateway do
  @moduledoc """
  Module for contacting API at https://api.covid19api.com
  for latest data regarding corona
  """

  @base_url "https://api.covid19api.com"
  @summary_url @base_url <> "/summary"

  @typedoc """
  Result for fetch API,
  latest_update is always UTC
  example:
  %{
    latest_update: ~U[2020-05-02 12:41:23Z],
    new_case: 433,
    total_case: 10551,
    total_recovered: 1591
  }
  """
  @type result :: %{
    total_case: number(),
    total_recovered: number(),
    new_case: number(),
    latest_update: DateTime.t()
  }

  @doc """
  Fetch latest summary data for a country
  ## Example
    CoronaNews.Gateway.fetch_data_for_country(CoronaNews.Country.indonesia)
  """
  @spec fetch_data_for_country(CoronaNews.Country.t) :: {:ok, result} | {:error, any()}
  def fetch_data_for_country(country) do
    with {:request, {:ok, response}} <- {:request, HTTPoison.get(@summary_url)},
      {:status_code, %{status_code: 200}} <- {:status_code, response},
      {:body_decode, {:ok, json_map}, _response} <- {:body_decode, Jason.decode(response.body), response}
    do
      parse_summary_data_for_country(json_map, country)
    else
      {:request, failed} -> {:error, "Request failed, #{inspect failed}"}
      {:status_code, response} -> {:error, "Response code not 200, #{inspect response}"}
      {:body_decode, failed, response} -> {:error, "JSON decode failed, #{inspect response}, #{inspect failed}"}
    end
  end

  # parse JSON result for get summary data to result type for country
  defp parse_summary_data_for_country(json_api_data, country) do
    data = if country == CoronaNews.Country.global do
      json_api_data[CoronaNews.Country.global]
    else
      json_api_data["Countries"]
      |> Enum.find(fn slug -> slug["CountryCode"] == country end)
    end

    if data == nil do
      {:error, "Country data not found for #{inspect country}"}
    else
      latest_update = if data["Date"] == nil do
        DateTime.utc_now()
      else
        {:ok, datetime, _} = DateTime.from_iso8601(data["Date"])
        datetime
      end

      {:ok, %{
        total_case: data["TotalConfirmed"],
        total_recovered: data["TotalRecovered"],
        new_case: data["NewConfirmed"],
        latest_update: latest_update
      }}
    end
  end
end
```

Here is example of result of `corona_news`
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/sbe6ccu0tsogp5gfjhbo.png)

### Using Mox
[Mox](https://github.com/dashbitco/mox) is library for defining concurrent mocks in Elixir.

Mox principle is that **mock** should be an object and not a verb. You should create behaviour of module to be mocked, and then during test change using module (at compile time) to mocked module conforming to behaviour.

It is perhaps the most used library for **mock** in elixir, with  541015 recent download.

For using Mox to test `CoronaNews`, we need to:
- Define behaviour for `CoronaNews.Gateway` (`CoronaNews.Gateway.Behaviour`)
- add `mox` to our dependency
- Write the unit test

Here is the diff of code when i use mox for testing
```diff
# at test/corona_news_test.exs
+defmodule CoronaNewsTest do
+  use ExUnit.Case
+  import Mox
+
+  describe "CoronaNews" do
+    test "text_news_for/1 on Gateway Success" do
+      CoronaNews.GatewayMock
+      |> expect(:fetch_data_for_country, 1, fn country ->
+        assert country == CoronaNews.Country.indonesia()
+        {:ok, %{
+          total_case: 6,
+          total_recovered: 3,
+          new_case: 1,
+          latest_update: ~U[2020-05-02 13:37:37Z]
+        }}
+      end)
+
+      assert """
+      Total Case: 6
+      Total Recovered: 3
+      New Case: 1
+      Latest Update: 2020-05-02 13:37:37Z
+      """ == CoronaNews.text_news_for(CoronaNews.Country.indonesia())
+    end
+
+    test "text_news_for/1 on Gateway Failed" do
+      CoronaNews.GatewayMock
+      |> expect(:fetch_data_for_country, 1, fn country ->
+        assert country == CoronaNews.Country.indonesia()
+        {:error, "Error due to Mock"}
+      end)
+
+      assert """
+      Failed fetching data
+      Error: \"Error due to Mock\"
+      """ == CoronaNews.text_news_for(CoronaNews.Country.indonesia())
+    end
+  end
+end

# at test/test_helper.exs
+Application.put_env(:corona_news, :gateway, CoronaNews.GatewayMock)
+Mox.defmock(CoronaNews.GatewayMock, for: CoronaNews.Gateway.Behaviour)
+Application.ensure_started(:mox)
ExUnit.start()

# at mix.exs
  defp deps do
    [
      {:httpoison, "~> 1.6"},
      {:jason, "~> 1.2"},
+      {:mox, "~> 0.5", only: :test}
    ]
  end

# at lib/corona_news.ex
defmodule CoronaNews do
  @moduledoc """
  Module for displaying Corona Virus (COVID-19) data update
  output of this module is expected to be readable by human user
  """
- 
+  def gateway(), do: Application.get_env(:corona_news, :gateway, CoronaNews.Gateway)

  @doc """
  Return human-readable text summary of corona update for a country

  ## Example
    iex> text_news_for(CoronaNews.Country.global)
    Total Case: 3340989
    Total Recovered: 1052510
    New Case: 86819
    Latest Update: 2020-05-02 12:50:40.760326Z
  """
  def text_news_for(country \\ CoronaNews.Country.global()) do
-    result = CoronaNews.Gateway.fetch_data_for_country(country)
+    result = gateway().fetch_data_for_country(country)
...

+defmodule CoronaNews.Gateway.Behaviour do
+  @moduledoc """
+  Behaviour of Gateway (API data request module) for corona data
+  """
+  @type result :: %{
+    total_case: number(),
+    total_recovered: number(),
+    new_case: number(),
+    latest_update: DateTime.t()
+  }
+
+  @doc """
+  Fetch latest summary data for a country
+  """
+  @callback fetch_data_for_country(CoronaNews.Country.t) :: {:ok, result} | {:error, any()}
+end

defmodule CoronaNews.Gateway do
  @moduledoc """
  Module for contacting API at https://api.covid19api.com
  for latest data regarding corona
  """
- 
+  @behaviour CoronaNews.Gateway.Behaviour
...
``` 

### Using Mockery
[Mockery](https://hexdocs.pm/mockery/readme.html) is Simple mocking library for asynchronous testing in Elixir.

It define several type of mock, but what i am using is macro based one in which the macro will change behaviour of module at testing

to use Mockery, we need to:
- add mockery to depedency
- modify `CoronaNews` module to able to mock `CoronaNews.Gateway`
- write test

Here is the code diff for testing using Mockery
```diff
# at test/corona_news_test.exs
+defmodule CoronaNewsTest do
+  use ExUnit.Case
+  import Mockery
+
+  describe "CoronaNews" do
+    test "text_news_for/1 on Gateway Success" do
+      mock CoronaNews.Gateway, [fetch_data_for_country: 1], fn country ->
+        assert country == CoronaNews.Country.indonesia()
+        {:ok, %{
+          total_case: 6,
+          total_recovered: 3,
+          new_case: 1,
+          latest_update: ~U[2020-05-02 13:37:37Z]
+        }}
+      end
+
+      assert """
+      Total Case: 6
+      Total Recovered: 3
+      New Case: 1
+      Latest Update: 2020-05-02 13:37:37Z
+      """ == CoronaNews.text_news_for(CoronaNews.Country.indonesia())
+    end
+
+    test "text_news_for/1 on Gateway Failed" do
+      mock CoronaNews.Gateway, [fetch_data_for_country: 1], fn country ->
+        assert country == CoronaNews.Country.indonesia()
+        {:error, "Error due to Mock"}
+      end
+
+      assert """
+      Failed fetching data
+      Error: \"Error due to Mock\"
+      """ == CoronaNews.text_news_for(CoronaNews.Country.indonesia())
+    end
+  end
+end
+

# at mix.exs
  defp deps do
    [
      {:httpoison, "~> 1.6"},
      {:jason, "~> 1.2"},
-
+      {:mockery, "~> 2.3.0", runtime: false}
    ]
  end

# at lib/corona_news.ex
defmodule CoronaNews do
  @moduledoc """
  Module for displaying Corona Virus (COVID-19) data update
  output of this module is expected to be readable by human user
  """
-
+  import Mockery.Macro

  @doc """
  Return human-readable text summary of corona update for a country

  ## Example
    iex> text_news_for(CoronaNews.Country.global)
    Total Case: 3340989
    Total Recovered: 1052510
    New Case: 86819
    Latest Update: 2020-05-02 12:50:40.760326Z
  """
  def text_news_for(country \\ CoronaNews.Country.global()) do
-    result = CoronaNews.Gateway.fetch_data_for_country(country)
+    result = mockable(CoronaNews.Gateway).fetch_data_for_country(country)
#
```

### Using Mimic
[Mimic]() is A sane way of using mocks in Elixir.

It's usage is the most simple among other library in comparison due not needing any kind of change in codebase

to use Mimic, we need to:
- add Mimic to dependency
- add `Mimic.copy(CoronaNews.Gateway)` at `test/test_helper.exs`
- write test

Here is the code diff for testing using Mimic
```diff
# at test/corona_news_test.exs
+defmodule CoronaNewsTest do
+  use ExUnit.Case
+
+  describe "CoronaNews" do
+    test "text_news_for/1 on Gateway Success" do
+      Mimic.expect(CoronaNews.Gateway, :fetch_data_for_country, fn country ->
+        assert country == CoronaNews.Country.indonesia()
+        {:ok, %{
+          total_case: 6,
+          total_recovered: 3,
+          new_case: 1,
+          latest_update: ~U[2020-05-02 13:37:37Z]
+        }}
+      end)
+
+      assert """
+      Total Case: 6
+      Total Recovered: 3
+      New Case: 1
+      Latest Update: 2020-05-02 13:37:37Z
+      """ == CoronaNews.text_news_for(CoronaNews.Country.indonesia())
+    end
+
+    test "text_news_for/1 on Gateway Failed" do
+      Mimic.expect(CoronaNews.Gateway, :fetch_data_for_country, fn country ->
+        assert country == CoronaNews.Country.indonesia()
+        {:error, "Error due to Mock"}
+      end)
+
+      assert """
+      Failed fetching data
+      Error: \"Error due to Mock\"
+      """ == CoronaNews.text_news_for(CoronaNews.Country.indonesia())
+    end
+  end
+end

# at mix.exs
  defp deps do
    [
      {:httpoison, "~> 1.6"},
      {:jason, "~> 1.2"},
-
+      {:mimic, "~> 1.0.0", only: :test}
    ]
  end

# at test/test_helper.exs
-
+Mimic.copy(CoronaNews.Gateway)
ExUnit.start()

```

### Using Syringe
[Syringe](https://github.com/skylerparr/syringe) is a injection framework that also opens the opportunity for clearer mocking and to run mocked test asynchronously

to use Syringe, we need to:
- add Syringe to dependency
- add `config :syringe, injector_strategy: MockInjectingStrategy` to `config/test.exs`
- add `config :syringe, injector_strategy: AliasInjectingStrategy` to `config/config.exs`
- Reshuffle `CoronaNews.Gateway` to be above `CoronaNews` because `Syringe` rely on macro (hence need to define `CoronaNews.Gateway` before `CoronaNews`
- add Injector to `CoronaNews.Gateway` at `CoronaNews`
- add `Mocker.start_link()` at `test/test_helper.exs`
- write test

Here is code diff using syringe
```diff
# at test/corona_news_test.exs
+defmodule CoronaNewsTest do
+  use ExUnit.Case, async: true
+  import Mocker
+
+  describe "CoronaNews" do
+    test "text_news_for/1 on Gateway Success" do
+      mock(CoronaNews.Gateway)
+      intercept(CoronaNews.Gateway, :fetch_data_for_country, nil, with: fn country ->
+        assert country == CoronaNews.Country.indonesia()
+        {:ok, %{
+          total_case: 6,
+          total_recovered: 3,
+          new_case: 1,
+          latest_update: ~U[2020-05-02 13:37:37Z]
+        }}
+      end)
+
+      assert """
+      Total Case: 6
+      Total Recovered: 3
+      New Case: 1
+      Latest Update: 2020-05-02 13:37:37Z
+      """ == CoronaNews.text_news_for(CoronaNews.Country.indonesia())
+    end
+
+    test "text_news_for/1 on Gateway Failed" do
+      mock(CoronaNews.Gateway)
+      intercept(CoronaNews.Gateway, :fetch_data_for_country, nil, with: fn country ->
+        assert country == CoronaNews.Country.indonesia()
+        {:error, "Error due to Mock"}
+      end)
+
+      assert """
+      Failed fetching data
+      Error: \"Error due to Mock\"
+      """ == CoronaNews.text_news_for(CoronaNews.Country.indonesia())
+    end
+  end
+end
+

# at test/test_helper.exs
-
+Mocker.start_link()
ExUnit.start()

# at mix.exs
  defp deps do
    [
      {:httpoison, "~> 1.6"},
      {:jason, "~> 1.2"},
+      {:syringe, "~> 1.0"}
    ]
  end

# at lib/corona_news.ex
# Reshuffle CoronaNews.Gateway module to be above CoronaNews module
defmodule CoronaNews do
  @moduledoc """
  Module for displaying Corona Virus (COVID-19) data update
  output of this module is expected to be readable by human user
  """
+  use Injector
+
+  inject CoronaNews.Gateway, as: Gateway

  @doc """
  Return human-readable text summary of corona update for a country

  ## Example
    iex> text_news_for(CoronaNews.Country.global)
    Total Case: 3340989
    Total Recovered: 1052510
    New Case: 86819
    Latest Update: 2020-05-02 12:50:40.760326Z
  """
  def text_news_for(country \\ CoronaNews.Country.global()) do
-    result = CoronaNews.Gateway.fetch_data_for_country(country)
+    result = Gateway.fetch_data_for_country(country)

# at config/config.exs
+config :syringe, injector_strategy: MockInjectingStrategy

# at config/test.exs
+config :syringe, injector_strategy: MockInjectingStrategy
```

### Using Lightweight DI
Lightweight Depedency Injection is done by giving module/function as argument to called function.

on simple case this is easy, however it will become cumbersome on many nested function because each function need to pass the module/function to nested function after it

to use Lightweight DI, we need to:
- modify function `CoronaNews.text_news_for` to accept gateway module
- define mock module on test
- write test

Here is code diff using Lightweight DI
```diff
# at test/corona_news_test.exs
+defmodule CoronaNewsTest do
+  use ExUnit.Case, async: true
+
+  describe "CoronaNews" do
+    test "text_news_for/1 on Gateway Success" do
+
+      defmodule GatewayMock do
+        def fetch_data_for_country(country) do
+          assert country == CoronaNews.Country.indonesia()
+          {:ok, %{
+            total_case: 6,
+            total_recovered: 3,
+            new_case: 1,
+            latest_update: ~U[2020-05-02 13:37:37Z]
+          }}
+        end
+      end
+
+      assert """
+      Total Case: 6
+      Total Recovered: 3
+      New Case: 1
+      Latest Update: 2020-05-02 13:37:37Z
+      """ == CoronaNews.text_news_for(CoronaNews.Country.indonesia(), GatewayMock)
+    end
+
+    test "text_news_for/1 on Gateway Failed" do
+      defmodule GatewayMock do
+        def fetch_data_for_country(country) do
+          assert country == CoronaNews.Country.indonesia()
+          {:error, "Error due to Mock"}
+        end
+      end
+
+      assert """
+      Failed fetching data
+      Error: \"Error due to Mock\"
+      """ == CoronaNews.text_news_for(CoronaNews.Country.indonesia(), GatewayMock)
+    end
+  end
+end
+

# at lib/corona_news.ex
defmodule CoronaNews do
  @moduledoc """
  Module for displaying Corona Virus (COVID-19) data update
  output of this module is expected to be readable by human user
  """

  @doc """
  Return human-readable text summary of corona update for a country

  ## Example
    iex> text_news_for(CoronaNews.Country.global)
    Total Case: 3340989
    Total Recovered: 1052510
    New Case: 86819
    Latest Update: 2020-05-02 12:50:40.760326Z
  """
-  def text_news_for(country \\ CoronaNews.Country.global()) do
-    result = CoronaNews.Gateway.fetch_data_for_country(country)
+  def text_news_for(country \\ CoronaNews.Country.global(), gateway \\ CoronaNews.Gateway) do
+    result = gateway.fetch_data_for_country(country)
```

### Conclusion
You have now see multitude of way to **mock** in elixir, personally my favorite is `Mimic` due to simplicity of setup and doesn't need to change tested codebase. Do go out and try, your mileage may vary!