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.
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
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.
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.
:ok
, acting as if the push notification was sent
succesfully. We'll go over testing error responses from your
behaviour later.
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
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
import Mox
so we can use Mox's
expect
functions later on
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:
send_push
functions called with the right
parameters
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
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
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!
Defining a behaviour and different implementations
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.
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