Mocks and Elixir
Walk through example of testing in elixir using Mox. This examples walks through testing a module that makes API calls to a 3rd party client. It's a long tutorial.
We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
The blog post is a fast walk through on on how to setup Mox on Elixir and to start using mocking in your test suites. The community has many articles online for you to try out.
I really enjoyed the article on Mocks and explicit contracts. It explains the core idea around mocking in elixir and has a good walk through. I’m going to use it as the case study for this blog post.
You are going to have a Twitter API that will be used inside a controller for your phoenix application. This Twitter API needs to be mocked in order to prevent it from making 3rd party api calls during tests.
I would read up on the instructions from the GitHub repo. But if you are running the latest of elixir it should be as simple as.
def deps do
[
{:mox, "~> 1.0", only: :test}
]
end
Behaviours
within Elixir are similar to interfaces
in other OOP languages. The documentation has a write up and examples on how to think of them.
Our contract will state that in order to get a tweet we need to pass in a user_id
that is a string and return a tuple()
. In the future you can add more methods but for now keep it simple.
# lib/api/ap_behaviour.ex
defmodule MyApp.Api.ApiBehaviour do
@callback get_tweet(user_id :: String.t()) :: tuple()
end
In order to use the contract you need to place @behaviour MyApp.Api.ApiBehaviour
within your MyApp.Api.InMemory
and then @impl MyApp.Api.ApiBehaviour
just above the actual implementation of the callback. If you don’t do this Elixir will complain to you.
The MyApp.Api.InMemory
implementation can be used for testing and for development.
Within the get_tweet/1
it’s loading json
of the actual response that I downloaded and saved into my test directory. This let’s me also parse the information.
# lib/api/in_memory.ex
defmodule MyApp.Api.InMemory do
@behaviour MyApp.Api.ApiBehaviour
@moduledoc """
This module is for stubbing out the MyApp.Api module
Use for tests and development
"""
@impl MyApp.Api.ApiBehaviour
def get_tweet(_user_id) do
"test/fixtures/tweet.json"
|> File.read!()
# I created a module for parsing JSON into a tuple
|> MyApp.Api.TweetResponse.parse_tweet_from_json()
end
end
This is where you put the real implementation, where you need to deal with HTTP responses and the parsing of those responses.
# lib/api/client.ex
defmodule MyApp.Api.Client do
@behaviour MyApp.Api.ApiBehaviour
@moduledoc """
This module is the live implementation
"""
@impl MyApp.Api.ApiBehaviour
def get_tweet(user_id) do
# Make HTTP request
# get the response
# parse the response into a tuple
end
end
This is key. We are going to create the context or bound that will pull in the correct implementation depending on the environment we are in.
This is the file we are going to use in our controller and it’s implementation will change depending on the environment that the application is running in.
defmodule MyApp.Api do
@moduledoc """
This is the file we use to interact with Twitter
"""
def get_tweet(user_id), do: api_implementation().get_tweet(user_id)
defp api_implementation() do
Application.get_env(:my_app, :api) ||
raise "Missing configuration for Api. Do you want MyApp.Api.InMemory or MyApp.Api.Client"
end
end
We need to define in our environment which implementation do we want to be using. This is key. In this example we are going to use the
MyApp.Api.Client
for production and then for development and testing the MyApp.Api.InMemory
version.
# config/prod.exs
config :my_app, :api, MyApp.Api.Client
# config/dev.exs
config :my_app, :api, MyApp.Api.InMemory
# config/test.exs
config :my_app, :api, MyApp.Api.InMemory
Make this change within your test helpers. Take note that we are calling our mock module ApiMock
. Mox is autogenerated this module name for use and it will be available within our tests. It’s just a shell module.
# test/test_helper.exs
Mox.defmock(ApiMock, for: MyApp.Api.ApiBehaviour) # <- Add this
Application.put_env(:my_app, :api, ApiMock) # <- Add this
ExUnit.start()
At this point you can write your tests with Mox.expect
and write the response inline as shown below.
# test/api_test.exs
defmodule MyApp.ApiTest do
use ExUnit.Case
import Mox
setup :verify_on_exit!
describe "get_tweet/1" do
test "fetches tweet" do
expect(ApiMock, :get_tweet, fn args ->
assert args == "jack"
# here we decide what the mock returns
# if we decide to use the expect
{:ok, %{twitter: "some response here"}}
end)
assert {:ok, response} = MyApp.Api.get_tweet("jack")
assert response.twitter == "some response here" # you assert that certain values exist
end
end
end
You can also stub out the entire module with the MyApp.Api.InMemory
module. This module already contains all the JSON responses you would get from twitter so you can test this here. The setup will be slightly different as you will use Mox.stub_with/2
within setup.
# test/api_test.exs
defmodule MyApp.ApiTest do
use ExUnit.Case
# Mox will use the implementation of MyApp.Api.InMemory
# for this test. The test will look pretty straight forward
setup do
Mox.stub_with(ApiMock, MyApp.Api.InMemory)
:ok
end
describe "get_tweet/1" do
test "fetches tweet" do
assert {:ok, response} = MyApp.Api.get_tweet("jack")
assert response.twitter # you assert that certain values exist
end
end
end
You can then create another file and do an integration test where you are testing the live implementation. When doing this I would follow the recommendation from the article Mocks and explicit contracts where you should tag these test and avoid running with your other tests.
So you just write the following
# test/my_app_test.exs
defmodule MyApp.ApiTest do
use ExUnit.Case
# All tests will ping the twitter API
@moduletag :twitter_api
# Mox will use the implementation of MyApp.Api.Client
setup do
Mox.stub_with(ApiMock, MyApp.Api.Client)
:ok
end
describe "get_tweet/1" do
test "fetches tweet" do
assert {:ok, response} = MyApp.Api.get_tweet("jack")
assert response.twitter # you assert that certain values exist
end
end
end
# test/test_helper.exs
ExUnit.configure exclude: [:twitter_api]
But you can still run the whole suite with the tests tagged :twitter_api if desired:
mix test --include twitter_api
Or run only the tagged tests:
mix test --include twitter_api
So at this point we have mocked and testing our module. Now what happens when we use that module within a controller. How do we deal with that.
# test/my_app_web/controllers/api/tweet_controller_test.exs
defmodule MyAppWeb.Api.TweetController do
use MyAppWeb, :controller
action_fallback MyAppWeb.Api.FallbackController
def show(conn, params) do
with {:ok, result} <- MyApp.Api.get_tweet(params["user_id"]) do
render(conn, "show.json", user: result)
end
end
end
If you were to run this controller in development mode you could navigate to that page and would be getting the data response from your MyApp.Api.InMemory
module. This would allow you to start playing with the presentation of the page and figure out how to present stuff.
This is a controller test that has a module that we have already tested out earlier. Controller tests should validate very simple branching logic.
# test/my_app_web/controllers/api/tweet_controller_test.exs
defmodule MyAppWeb.Api.TweetControllerTest do
use MyAppWeb.ConnCase
import Mox
@api_mock ApiMock
setup :verify_on_exit!
describe "GET tweet/:user_id" do
test "success, return a user", %{conn: conn} do
expect(@api_mock, :get_tweet, fn args ->
# assert argument passed in
assert args == "jack"
# fake response
{:ok, %{user: "jack"}}
end)
conn = get(conn, "/api/jack")
assert conn.params["user_id"] == "jack"
assert {:ok, _} == json_response(conn, 200)
end
test "error, returns error for unknown user", %{conn: conn} do
expect(@api_mock, :get_tweet, fn args ->
# assert argument passed in
assert args == "00000000"
# fake response
{:error, :not_found}
end)
conn = get(conn, "/api/00000000")
assert conn.params["user_id"] == "00000000"
assert {:error, _} == json_response(conn, 200)
end
end
end
In this controller test we are stubbing out the entire module. As we have done in earlier example.
# test/my_app_web/controllers/api/tweet_controller_test.exs
defmodule MyAppWeb.Api.TweetControllerTest do
use MyAppWeb.ConnCase
setup do
Mox.stub_with(ApiMock, MyApp.Api.InMemory)
:ok
end
describe "GET tweet/:user_id" do
test "success, return a user", %{conn: conn} do
conn = get(conn, "/api/jack")
assert conn.params["user_id"] == "jack"
assert {:ok, _} == json_response(conn, 200)
end
test "error, returns error for unknown user", %{conn: conn} do
conn = get(conn, "/api/00000000")
assert conn.params["user_id"] == "00000000"
assert {:error, _} == json_response(conn, 200)
end
end
end
This is only the beginning, but mocking stuff with Mox and using this behaviour / contract approach makes testing very predictable. I do find the terminology confusing around mock, double, stubs. But I’ll leave my interpretation of terms and some links to excellent blog posts and books.
Mocks - Modules that register calls they receive. We can verify that params are passed in and expected actions were performed
Stub - Modules that hold predefined data and use it to answer calls during tests
Mocks and Explicit Contracts
Elixir Mox Introduction
Elixir and Mox
Mocking Behaviour in Child Process
Revert Mocked Module
TDD with Phoenix