« Go Back

Debugging and mocking third party services in Elixir with Mox

May 19, 2021 · Written by Nick Vernij

Almost every backend application uses them, third party services. Whether it's S3 for object storage, Segment for analytics or Firebase for push notifications. Those services are super helpful and do a lot of heavy-lifting, but in my experience usually do make debugging business logic on your local machine a pain, and testing them isn't easy either.

There are some great solutions available in Elixir. In this post I'm sharing the approach I have been using for the past few years using Elixir. I'll be going over defining behaviours and using them for local debugging during development as well as writing tests.

Defining a behaviour

Interfaces should be familiar for anyone coming from typed object-oriented languages such as Java or TypeScript. Elixir's equivalent of this is called a behaviour. By using type-specs we can define a module's functions in a behaviour, which can be implemented by multiple modules, usually called "callbacks" in Elixir.

💡 If you are unfamiliar with type-specs, I highly recommend this comprehensive blog post by Kevin Peter

For this post's example we want to define a simple behaviour that can send a push notification to a user. We're going to write a behaviour module that will describe the function definitions we need to send push notifications.

In a behaviour, each @callback annotation defines the type-spec of a function that is required on any implementation of this behaviour. For our push notifications use-case the simple behaviour below will do; a single send_push function that takes a User struct as a recipient and a string as a title for the push notification.

defmodule MyApp.Infrastructure.PushBehaviour do
  # Sends a push notification to the given User with the given title.
  # Returns :ok if the message was sent or an :error tuple with an error message.
  @callback send_push(recipient :: MyApp.User.t(), title :: String.t()) ::
              :ok | {:error, String.t()}
end

Stub, Debug and Production implementations

Once we have determined what our behaviour looks like, we can start thinking about what our implementations may look like. I usually end up writing three implementations, one for each Mix environment.

Implementing a behaviour on a callback module is done by adding the @behaviour decorator as well as implementing all of its defined callbacks. Here are some example implementations for our Stub, Debug and Production implementation.

Our stub implementation will always return :ok, it's there mainly to not raise any errors while running tests or compiling your app.


defmodule MyApp.Infrastructure.StubPush do
  @moduledoc """
  Do nothing. Always succeed.
  """

  @behaviour MyApp.Infrastructure.PushBehaviour

  def send_push(_user, _title) do
    :ok
  end
end

Our debug implementation will use Logger to print the recipient's username and the title of the push notification. This is useful when writing your application locally and trying out your code without having to connect to your third party service.

defmodule MyApp.Infrastructure.DebugPush do
  @moduledoc """
  Print a push notification to our logger
  """

  @behaviour MyApp.Infrastructure.PushBehaviour

  require Logger

  def send_push(user, title) do
    Logger.info("Sending a notification to user @#{user.username} with title \"#{title}\"")
    :ok
  end
end

For a production example, I'm assuming we're using a fictional Firebase library for sending notifications. This can be any service or library for your use-case of course.

defmodule MyApp.Infrastructure.FirebasePush do
  @moduledoc """
  Send a push notification through firebase
  """

  @behaviour MyApp.Infrastructure.PushBehaviour

  def send_push(user, title) do
    SomeFirebaseLibrary.some_send_function(user.token, title)
  end
end

Mocking

The go-to library for working with mocks in Elixir is Mox, authored by José Valim. Follow the instructions on their docs to have it installed so we can jump straight into setting up testing for our application's push notification.

First we're going to head over to test_helper.exs in your project. Every Elixir project with tests will have this file generated by default.

We are going to add a mock with Mox.defmock. This generates a module based on the behaviour we pass it. It's important to define a "stub" as well. Mox doesn't know what to return for each function and will error when a function is called without having an implementation. Mox.stub_with will fill those functions with the always-succeeding callbacks we defined in our StubPush module.

# In test/test_helper.exs

# This will generate a module named MyApp.Infrastructure.MockPush
Mox.defmock(MyApp.Infrastructure.MockPush, for: MyApp.Infrastructure.PushBehavior)
# This will define all callbacks
Mox.stub_with(MyApp.Infrastructure.MockPush, MyApp.Infrastructure.StubPush)

ExUnit.start()

We are now ready to start writing the tests for our push notification business logic. To prepare a test module for dealing with mox we need to

defmodule MyApp.PushTest do
  use ExUnit.Case, async: true

  # 1. Import Mox
  import Mox
  # 2. setup fixtures
  setup :verify_on_exit!

  # Here go your tests...
end

In our tests we want to assert two main things:

In order to do this we have to overwrite our stub behavior with a function specific to said test case. Mox provides the expect function for this, which we imported earlier. expect changes the function body for that specific test. Other tests will still use the stubbed behavior. Let's start with asserting whether the right parameters were given to send_push.

💡 There's a cool blog-post by José Valim detailing the decisions that lead to the design of Mox. I'd recommend reading it if you want to dive a bit deeper.

When verifying parameters, you usually want to depend on pattern matching in Mox's case. With the pin operator (^) we can verify whether send_push was called with the same user as we passed into do_something_that_sends_push. Mox will ensure that your test fails when an expect is never called or when there is no matching function clause.

test "Succesfully sends push notification to right user" do
  # Tip: ExMachina is a great library that helps generate entities like this.
  user = insert(:user)

  MyApp.Infrastructure.MockPush
  # Check if the user on `send_push` is the same user as we passed thru our call below
  # If the user does not match, this will throw a MatchError
  |> expect(:send_push, fn ^user, _title -> :ok end)

  MyApp.do_something_that_sends_push(user)
end

We can also use expect to return an error for send_push and check whether our business logic handles this properly. Let's say we want our do_something_that_sends_push function to propagate the push error to its caller, its test will look something like this:

test "Succesfully propagates errors from push service" do
  # Tip: ExMachina is a great library that helps generate entities like this.
  user = insert(:user)

  MyApp.Infrastructure.MockPush
  # Check if the user on `send_push` is the same user as we passed thru our call below
  # If the user does not match, this will throw a MatchError
  |> expect(:send_push, fn _user, _title -> {:error, "This user does not have push enabled"} end)

  assert MyApp.do_something_that_sends_push(user) == {:error, "This user does not have push enabled"}
end

Tying it all together

These tests won't succeed yet. There is one last step before we can successfully try out our test and debug push implementations. We need to tell our application which implementation of our Push behavior it needs to call in certain environments. To do this we are going to use the Application module which comes with Elixir, and the config files automatically generated in your Elixir project:

# In config/dev.exs we use our push notification logger
config :my_app, :push_api, MyApp.Infrastructure.DebugPush

# In config/test.exs we use our Mock as named in Mox.defmock
config :my_app, :push_api, MyApp.Infrastructure.MockPush

# In config/prod.exs we want to use our Firebase client
config :my_app, :push_api, MyApp.Infrastructure.FirebasePush

Now in any case where you need to send a push notification, you should get the currently configured push implementation from the Application's environment before you call it. You can wrap this in a module if you use the implementation a lot.

# individually
Application.get_env(:my_app, :push_api).send_push(user, "title")

# wrapped in a module
defmodule MyApp.Infrastructure.Push do
  def send_push(user, title) do
    Application.get_env(:my_app, :push_api).send_push(user, title)
  end
end

You can now call MyApp.Infrastructure.Push.send_push, which will look up the PushBehaviour implementation in your Application's environment and call the defined module. Try it out by running iex -S mix and calling the function. For your dev environment it will log a message to the console!

In conclusion

Defining a behaviour and different implementations

Thanks for reading, feel free to hit me up on twitter if you have any questions or feedback.

A personal note: we're hiring!

If you enjoy this kind of stuff: We're hiring React Native and Elixir engineers at the startup where I work, Quest. I'd love to hear from you if that's you. You can read more about the position and our culture here, or feel free to email me at nick(at)cooper.app with any questions you have.

« Go Back