A well-written test suite is a big part of any successful application. But let's say you rely on an external dependency for some parts of your app (for example, an external API for fetching user information). It then becomes important to mock that dependency in the test suite to prevent external API calls during testing or to test specific behavior.
Several frameworks help reduce the boilerplate and make mocking safe for Elixir tests. We will explore some of the major mocking tools available in this post.
Let's get started!
Test Without Mocking Tools in Elixir
Depending on the scope of your tests, you might not need to use any external mocking tools. You can use test stubs or roll your own server instead.
Test Stubs
The simplest way to stub/mock out some parts from a function call is to pass around modules that do the actual work and use a different implementation during the tests.
Let’s say you need to access the GitHub API to fetch a user’s profile with an implementation like this:
defmodule GithubAPI do
def fetch_user(username) do
case HTTPoison.get("https://api.github.com/users/#{username}") do
{:ok, response} ->
parse(response)
{:error, reason} ->
{:error, reason}
end
end
end
We know the implementation relies on HTTPoison to make actual calls to the GitHub API. To mock it during tests, we can update the implementation to pass around an http_client
and use a different one during tests.
Something like this:
defmodule GithubAPI do
def fetch_user(username, http_client \\ HTTPoison) do
case http_client.get("https://api.github.com/users/#{username}") do
#... handle results
end
end
end
This approach aligns with the Law of Demeter — a module should only have limited knowledge of the objects (in this case, the HTTP client) it works on. This approach is nice because it decouples the GithubAPI
module from the HTTP client implementation.
Our GithubAPI
users can continue using it in the same way.
But for unit-testing GithubAPI
, we can pass in our custom http_client
that returns just the results we need.
For example:
defmodule GithubAPITest do
defmodule GithubHTTPClientUser do
def get("https://api.github.com/users/octocat") do
{:ok, %HTTPoison.Response{status_code: 200, body: ~s<{"login": "octocat"}>}}
end
def get("https://api.github.com/users/unknown") do
{:error, %HTTPoison.Error{message: "Not Found"}}
end
end
test "can fetch a user" do
{:ok, %Github.User{login: "octocat"}} = GithubAPI.fetch_user("octocat", GithubHTTPClientUser)
end
test "handles errors when fetching a user" do
{:error, %HTTPoison.Error{}} = GithubAPI.fetch_user("unknown", GithubHTTPClientUser)
end
end
That works, and you have 100% test coverage! 🎉 But as you can already imagine, while this works well for small tests, it will become increasingly difficult to manage if our GithubAPI
class grows to include other features.
Another similar strategy is to use application configuration instead of passing around the client modules. This is especially useful when you need to mock out the API from all the tests, even when this API is called from other internal modules.
We can update our code to this:
# github_api.ex
defmodule GithubAPI do
@http_client Application.compile_env(:my_app, GithubAPI, []) |> Keyword.get(:http_client, HTTPoison)
def fetch_user(username) do
case @http_client.get("https://api.github.com/users/#{username}") do
#... handle results
end
end
end
# test/support/mock_github_http_client.ex
defmodule MockGithubHTTPClient do
# ... All mock implementations here
end
# config/test.exs
config :my_app, GithubAPI, http_client: MockGithubHTTPClient
The good thing is that you don’t need to worry about mocking out GithubAPI
calls in each test case by manually passing the HTTP client.
This is especially important when the actual API calls are nested inside other functions. For example, if your application automatically fetches the user from GitHub after a new account is created, you don’t need to mock GithubAPI
everywhere you create new users.
But it still has the same disadvantages as the previous strategy. Plus, the mocks must be generic enough to be used throughout the test suite.
Roll Your Own Server
This one is interesting. Since plug and cowboy make it really easy to roll out an HTTP server, instead of mocking out the HTTP Client, we can start our own server during tests and respond with stubs instead.
If you want to learn more, check out:
- Testing External Web Requests in Elixir? Roll Your Own Mock Server
- How to Test WebSocket Clients in Elixir with a Mock Server
In the strategies discussed above, quite a bit of boilerplate is involved in setting up the tests.
And if you need a way to validate that a specific function was called with specific arguments, you need to do additional work.
If you just have a small external dependency that you need to mock out, this might work well for you. But if there are complex edge cases and branches that you need to test out, mocking tools can help simplify the complexity of writing and maintaining those mocks in the long run.
Elixir Mocking Tools
Mock
Mock is the first result you will see when searching “Elixir Mock”, and is a wrapper around Erlang’s meck that provides easy mocking macros for Elixir.
With Mock
, you can:
- Replace any module at will during tests to change return values.
- Pass through to the original function.
- Validate calls to the mocked functions.
- Check the complete call history, including arguments and results for each call.
It is a very powerful tool, and the macro with_mock
makes mocking during tests really easy.
Let’s see how we can rewrite our test case to validate GithubAPI.fetch_user
:
defmodule GithubAPITest do
# This is important, Mock doesn't work with async tests!
use ExUnit.Case, async: false
import Mock
test "can fetch a user" do
with_mock HTTPoison, [get: fn _url -> {:ok, %{status_code: 200, body: ~s({"login": "octocat"})}} end] do
{:ok, %Github.User{login: "octocat"}} = GithubAPI.fetch_user("octocat")
assert_called HTTPotion.get("https://api.github.com/users/octocat")
end
end
end
Very sleek. There's no need to define and pass boilerplate modules around or fiddle with the config just for tests. Our implementation is completely isolated from the test code and needs no special changes to fit the tests. And if we need to validate that a function was called with specific arguments, we can do that as well with assert_called
.
One of the major drawbacks of this strategy is that you cannot use it with async
tests. It doesn’t prevent you from using Mock inside asynchronous tests, so this can lead to hard-to-track flaky tests.
In my opinion, Mock works well for certain types of tests.
I usually find myself reaching for it when we need to mock external libraries that we have no control over. For example, let’s say we use an external library to fetch a user’s GitHub profile instead of our custom GithubAPI
. Something like this:
def create_user(github_username) do
Tentacat.Users.find(Tentacat.Client.new(), github_username)
|> case do
{:ok, user} ->
# ... create user
{:error, error} ->
# ... handle error
end
end
Now, if we dig into the library’s documentation/code, we can see that it uses HTTPoison to make the eventual request to the API. But we don’t want to — or have a way to — customize that client just for tests. Here, with_mock
can easily help mock out that call so that we don’t request the API.
In fact, a much better approach, in this case, is to use with_mock
to simply mock out the Tentacat.Users.find
call altogether. That saves you from having to dig into the library's internal code (which can change with any update) and solely relying on mocking out its public interface.
Mox
We saw above how easy it is to mock some methods out and have our tests pass.
We sprinkle these calls in a few places to mock HTTPoison
, and we are done.
But what if we later decide that HTTPoison
isn’t fast enough and want to switch to another HTTP client implementation? As expected, all our tests will fail — we have to go back and fix them.
Even worse, what if the API for HTTPoison
changes, but since we mocked it out, our tests never failed, and we pushed something that didn’t work to production?
Mox helps get around these issues by ensuring explicit contracts.
Read Mocks and Explicit Contracts for more details.
Using our GithubAPI
example above, this is how we need to set up the tests with Mox.
Mock the External Client
We first convert our API client into a Behaviour
to define the explicit contract the API client should follow.
# lib/my_app/github/api.ex
defmodule MyApp.GithubAPI do
@callback fetch_user(String.t()) :: {:ok, Github.User.t()} | {:error, Github.Error.t()}
def fetch_user(username), do: impl().fetch_user(username) do
defp impl(), do: Application.get_env(:my_app, GithubAPI, GtihubAPI.HTTP)
end
Next, we define the API Client that makes the actual request to fetch the user.
# lib/my_app/github/http.ex
defmodule MyApp.GithubAPI.HTTP do
@behaviour MyApp.GithubAPI
@impl true
def fetch_user(username) do
case HTTPoison.get("https://api.github.com/users/#{username}") do
{:ok, response} ->
# Parse response here...
{:ok, user}
{:error, error} ->
# Handle error here...
{:error, reason}
end
end
end
Finally, in our test helper, we define a mock MyApp.GithubAPI.Mock
for the API and set it in our application environment.
# test/test_helper.exs
Mox.defmock(MyApp.GithubAPI.Mock, for: MyApp.GithubAPI)
Application.put_env(:my_app, GithubAPI, MyApp.GithubAPI.Mock)
Now we can refactor the test case to use Mox
for mocking out the call to the external API:
# test/my_app/github/api_test.exs
defmodule GithubAPITest do
use ExUnit.Case, async: true
import Mox
setup :verify_on_exit!
test "can fetch a user" do
MyApp.GithubAPI.Mock
|> expect(:fetch_user, fn "octocat" -> {:ok, %Github.User{login: "octocat"}} end)
assert {:ok, %Github.User{login: "octocat"}} = GithubAPI.fetch_user("octocat")
end
end
There are several interesting aspects to the above code. Let's break it down.
First, we use the expect
function to define an expectation on the API. We expect a single call to fetch_user
with the argument "octocat"
, and we mock it to return a {:ok, %Github.User{}}
tuple.
Now, when we call GithubAPI.fetch_user/1
in the test, it will reach for that mocked expectation instead of the default implementation. Thus, we can safely assert the result of the call without making calls to the actual API.
The verify_on_exit!
function as setup
ensures that all expectations defined with expect
are fulfilled when a test case finishes. So if we define an expectation on fetch_user
and it isn't actually called (e.g., if the implementation changes later), we see the test fail.
Finally, notice that we don't need to mark the test as non-async here. That’s because Mox supports mocking inside async tests. So you can define two completely different mocks in two test cases, and they will both pass, even if they run simultaneously.
You can also define a global stub to avoid providing mocks in every test. This is useful if your client is being used in several places and you don’t want to explicitly define and validate expectations everywhere.
To do this, just update your ExUnit case template:
defmodule MyApp.Case do
use ExUnit.CaseTemplate
setup _context do
Mox.stub_with(MyApp.GithubAPI.Mock, MyApp.GithubAPI.Stub)
end
end
And create a stub with some static results:
defmodule MyApp.GithubAPI.Stub do
@behaviour MyApp.GithubAPI
@impl true
def fetch_user("octocat"), do: {:ok, %Github.User{login: "octocat"}}
end
Now every time you use MyApp.Case
in a test case, you don't need to manually mock calls to GithubAPI
— they will automatically be forwarded to the stubbed module. This works well when you have calls to the GithubAPI
that you will hit across several test suites. With a stub, you can be sure that such calls always return a specific and stable response without having to mock them out manually.
Test the External Client
If you are following along closely, you will notice that we didn’t actually test the MyApp.Github.HTTP
module at all.
Don’t worry, we won’t leave it untested.
But the recommended way here is to do integration tests instead of using mocking at that level.
We will also configure it so that these tests don’t run when you run the full test suite.
# test/my_app/github/http_test.exs
defmodule MyApp.GithubAPI.HTTPTest do
use ExUnit.Case, async: true
# All tests will ping the API
@moduletag :github_api
# Write your tests here
end
# test/test_helper.exs
ExUnit.configure exclude: [:github_api]
The next step is to set up your CI pipeline to ensure that these tests run when required.
For example, you can configure it to run on all pull requests targeting main
to avoid reaching the external API on every commit. You can also check that your CI pipeline runs before anything is merged to the main branch.
To do this, use the include
command line flag when running mix test
.
mix test --include github_api
There are several pros to the above strategy. You are now testing all parts of the application, including the calls to the external API (this part is not exactly dependent on Mox, but since we mocked out a significant part of our HTTP client, we must test it separately). We can also define global stubs, specific mocks, and expectations only when required, which makes most of our test suite very clean and concise.
The major drawback is that it requires quite some setup — you need a new behaviour
with @callbacks
for each public method you want to mock out. You have to implement that behaviour
inside your module and finally set up Mox
to mock out that implementation during tests.
And since we are now reaching the external API, the tests can be flaky, depending on the API's availability.
Mimic
If you are used to Mocha for other languages, you can check out Mimic. It lets you define stubs and expectations during tests by keeping track of the stubbed module in an ETS table.
It also maintains separate mocks for each process, so you can continue using async tests. It’s a great alternative to Mock — but that also means the same caveat applies: be careful about what you mock.
Here’s how a sample test looks with Mimic:
# test_helper.exs
Mmimc.copy(HTTPoison)
ExUnit.start()
# github_api_test.exs
defmodule GithubAPITest do
use ExUnit.Case, async: true
use Mimic
test "can fetch a user" do
url = "https://api.github.com/users/octocat"
expect(HTTPoison, :get, fn ^url -> {:ok, %{status_code: 200, body: ~s({"login": "octocat"})}} end)
assert {:ok, %Github.User{login: "octocat"}} = GithubAPI.fetch_user("octocat")
end
end
expect/3
automatically verifies that the function is called.
And all expect
and stub
calls can be chained to make up clear mocking code.
Use Mocking Carefully for Your Elixir App
Mocking is an important and necessary part of any test suite.
But the thing to remember about mocking is that it is imperative to do it right.
For example, it might be tempting to mock out an internal API to quickly simulate something for a test. And yes, it works for quick unit tests. But then, someone else (or maybe you) comes along a while later and changes that something that you mocked earlier.
Your tests still pass since they use the mocked value. But in practice, the thing is now broken, and it will be caught much later in the feedback loop (or worse still, be shipped to the user as-is).
Wrap Up
In this post, we explored several mocking strategies you can use in Elixir tests. The safest (and the one you should consider using first) is Mox
, as it forces mocked modules to have a defined behavior. Mox can therefore catch issues that arise from API changes during compilation.
Reach out for Mimic
or Mock
when you need to mock external libraries you don't have any control over.
Until next time, happy mocking!
P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!
Top comments (0)