DEV Community

Cover image for An Introduction to Mocking Tools for Elixir
Pulkit Goyal for AppSignal

Posted on • Originally published at blog.appsignal.com

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.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)