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
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 mockCoronaNews.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)
attest/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
toconfig/test.exs
- add
config :syringe, injector_strategy: AliasInjectingStrategy
toconfig/config.exs
- Reshuffle
CoronaNews.Gateway
to be aboveCoronaNews
becauseSyringe
rely on macro (hence need to defineCoronaNews.Gateway
beforeCoronaNews
- add Injector to
CoronaNews.Gateway
atCoronaNews
- add
Mocker.start_link()
attest/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!
Top comments (1)
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