An Introduction to Mocking Tools for Elixir

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.

Mocking Tools in Elixir

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 a 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:

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 have just 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 to 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 relying solely 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? Suddenly, all our tests fail - we have to go back and fix them up. 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 requests 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 the 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 caveats apply - be careful about what you mock. Here’s what a sample test looks like 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, which makes up clear mocking code.

Use Mocking Right 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 one, and the one you should consider using first, is Mox since 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!

Published 7 Jun 2023

I build mobile and web applications. Full Stack, Rails, React, Typescript, Kotlin, Swift
Pulkit Goyal on Twitter