I will share with you an approach I've used to mock various 3rd party libraries using Mox
when they don't define their own behaviour.
First: Define your own Elixir behaviour that defines callbacks corresponding to the functions you are using from that library. For example, if you are calling :hackney.get/2
then define a callback in your behaviour for that function. Defining a behaviour that you might not actually be using in your code may seem pointless, but it does 2 important things for you:
- it helps you document which functions you are using from a given module and therefore it is the first step in making an abstraction for that functionality (think how you might need to refactor things if you suddenly had to swap out the HTTP client library used).
- it satisfies Mox's need to have a behaviour that it can inspect via reflection.
E.g.
defmodule MyClientBehaviour do
@callback get(url :: binary()) :: {:ok, any()} | {:error, any()}
end
Note you can define the behaviour in your test/
directory if it is indeed only used for testing.
Second: Adjust the code that you would like to test so that you can provide the module at run-time. There are 2 common ways to do this:
- make the value overridable via a supplied
opt
, OR
- resolve the module from configuration.
For instance, if your code is using :hackney
to make calls, e.g.
def get(url, opts \\ []) do
:hackney.get(url)
end
You can instead supply an option for overrides, e.g.
def get(url, opts \\ []) do
client = Keyword.get(opts, :client, :hackney)
client.get(url)
end
This way, you can keep the default implementation (:hackney
in your case), and you can now pass in the mock during tests. Typically, your test_helper.exs
will declare the mocks in use, e.g.
# test_helper.exs
ExUnit.start()
Mox.Server.start_link([])
Mox.defmock(HTTPClientMock, for: MyClientBehaviour)
defmodule YourTest do
use ExUnit.Case
import Mox
setup :verify_on_exit!
test ":ok something" do
client =
HTTPClientMock
|> expect(:get, fn _ ->
{:ok, "some response here"}
end)
assert {:ok, _} = YourModule.get("http://example.com", client: client)
end
end
If the places where you are calling :hackey
are too deeply embedded to allow for the kind of opts
injection, it can be useful to define this as a configurable module, e.g.
def get(url, opts \\ []) do
client = Application.get_env(:my_app, :http_client, :hackney)
client.get(url)
end
In this case, you can put the value in your Application config prior to running the test (or you can add it in the test.exs
config). This is best done in a setup
block to ensure that it gets done prior to the test and make sure to set it back when finished.
setup do
http_client = Application.get_env(:my_app, :http_client)
on_exit(fn ->
Application.put_env(:my_app, :http_client, http_client)
end)
end