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.
-
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
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
- add
import Mox
so we can use Mox'sexpect
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
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
.
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
- 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)