DEV Community

loading...

Mocking in Elixir: Comparison between Mox, Mockery, Mimic, Syringe, and Lightweight DI

calvinsadewa
A junior dev, dabbling on elixir.
・8 min read

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

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

Using Mox

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

# 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 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

# 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

# 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 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

# 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

# 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!

Discussion (1)

Collapse
stephanos profile image
Stephan Behnke

This might be of interest to readers: I developed a new dependency injection library, rewire, to allow you to inject any mock you like into the module under test. It requires zero changes to your production code. Check it out: github.com/stephanos/rewire

Forem Open with the Forem app