DEV Community

Nick Vernij
Nick Vernij

Posted on • Originally published at nickvernij.nl

Debugging and mocking third party services in Elixir with Mox

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

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.

  • Development: A behavior that helps us debug our business logic locally without depending on secrets or even an internet connection. In the case of push notifications we can simply log the user's name and the message they will be receiving to the Elixir Logger. For other use-cases, like object file storage, we can choose to write/modify/delete files on our local filesystem in a temporary folder instead of to for example Login.
  • Testing: For testing you want to define a "stub" that doesn't actually do anything with the data, and always acts as if anything you're trying to do is successful. You want to prevent calling your third party vendor hundreds of times while running tests. They may rate-limit you or it may incur costs. In those stubs you always want to return a succesful response, in our case that is :ok, acting as if the push notification was sent succesfully. We'll go over testing error responses from your behaviour later.
  • Production: This will call the third party service, which may be a library, rest API or whatever. Entirely up to you.

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

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

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

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

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

  • add import Mox so we can use Mox's expect functions later on
  • add setup :verify_on_exit! which will perform some checks about your mocks after a test has run.
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
Enter fullscreen mode Exit fullscreen mode

In our tests we want to assert two main things:

  • Are our send_push functions called with the right parameters
  • Does our application handle errors returned from send_push

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

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

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

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

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

  • Are useful when debugging your app's business logic without having to depend on a third party service on your local machine
  • Can be used to stub your calls to third party vendors, preventing you from calling them hundreds of times during tests.
  • Can be used together with Mox to assert whether your app behaves correctly with different responses from your third party vendors' APIs

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

Top comments (0)