Mocking HTTP calls in Elixir and Phoenix

A simple way of mocking HTTP calls in your controller integration tests

Motivation

It’s a common scenario, when you execute an HTTP request to API, and during it’s processing in your server application, there are other HTTP requests triggered to some third party services. In terms of testing in Phoenix, usually such scenarios are covered by controller integration tests. And in order to keep your tests FIRST, you don’t want to run these third party calls during tests execution. When I first encountered this problem and after brief exploration of Elixir ecosystem, it turned out there’s no such a library that’s made specifically to mock HTTP calls (something like nock in Node.js world, that’s mocking HTTP calls on socket level). But there’s still a way, and it’s deadly simple.

Mocking library

Since Elixir and functional programming in general is all about composing functions, and doing an HTTP call is just calling a function on some module, we can stick to this level of abstraction and just mock the function itself. At the very lowest level (application level) you are using some HTTP library to do HTTP requests. One of the most widely used ones in Elixir is HTTPoison. So the idea will be just mocking HTTPoison’s respective functions, that you use to do HTTP calls. Missing piece of the puzzle here is how do we actually mock functions in Elixir. There’s a handy library for that (among others similar), called Mock, and we going to be using it.

Code and tests

In our example we have an endpoint that updates user’s time availability. That endpoint accepts a request and under the hood it’s using Timekit API to persist that data to Timekit.

user_controller.ex

defmodule MyApp.UserController do

...

def update_availability(conn, params) do
slots = params["slots"]
current_user = conn.assigns[:current_user]
timekit_resource_id = current_user.timekit_resource_id

case Timekit.create_availability(current_user, timekit_resource_id, params["timezone"], slots) do
{:ok, timekit_resource_id} ->
conn
|> update_timekit_resource_id(current_user, timekit_resource_id)
|> send_resp(204, "")
{:error, :not_found} ->
conn
|> update_timekit_resource_id(current_user, nil)
|> put_status(400)
|> render("error_timekit.json", %{error: "Resource with id #{timekit_resource_id} is not found."})
{:error, error} when is_bitstring(error) ->
conn
|> put_status(400)
|> render("error_timekit.json", %{error: error})
{:error, error} ->
conn
|> put_status(422)
|> render("error.json", %{error: error})
end
end
end

Nothing special, logic in controller action above simply decorates Timekit API by calling a separate Timekit module implemented by us. In this case, we could get by with just mocking create_availability function in our Timekit module, but we don't really want to write separate tests for Timekit module and we are writing controller integration tests, so we need to get down to the lowest possible level and mock it - thus we'll be testing not only controller action's code, but all the logic underneath it too. Obviously, this level is HTTP level where we execute a particular HTTP request to Timekit API inside of Timekit module. And again, we are using HTTPoison library for this, so we just going to be mocking functions on HTTPoison module.

user_controller_test.exs

defmodule MyApp.UserControlllerTest do

@timekit_resource_id "uuid"
@timekit_resources_endpoint "https://api.timekit.io/v2/resources"

test "updates availability for a user", %{conn: conn, user: user} do
timezone = "America/Los Angeles"
request_body = %{
"timezone" => timezone,
"slots" => [
%{
"start" => "2018-06-01 09:00:00",
"end" => "2018-06-30 17:00:00"
}
]
}
json = Poison.encode!(request_body)

mock_post = fn (@timekit_resources_endpoint, body, _headers) ->
assert Poison.decode!(body) == %{"timezone" => timezone, "name" => user.name}
body = Poison.encode! %{"data" => %{id: @timekit_resource_id}}
{:ok, %HTTPoison.Response{status_code: 201, body: body}}
end

mock_put = fn (_put_url, body, _headers) ->
slot = Enum.at request_body["slots"], 0
assert Poison.decode!(body) == %{
"availability_constraints" => [%{"allow_period" => slot}]
}
{:ok, %HTTPoison.Response{status_code: 200, body: nil}}
end

with_mocks([
{HTTPoison,
[],
[post: mock_post]},
{HTTPoison,
[],
[put: mock_put]}
]) do
conn = put(conn, user_path(conn, :update_availability, user), json)

user = Repo.get(User, user.id)

assert user.timekit_resource_id == @timekit_resource_id
assert conn.status == 204
end
end
end

The logic in Timekit.create_availability is simple: if we are setting availability for the user for the first time, we first need to create a particular resource that represents that user in Timekit, and then we can set availability on the newly created Timekit's resource. The test above covers this successful scenario, and as we can see we are mocking two requests there:

  1. mock_post - mocks POST request that creates a resource in Timekit
  2. mock_put - mocks PUT request that updates resource availability

Thanks to pattern matching, we can put already bound variables as function’s argument (e. g. fn (@timekit_resources_endpoint, body, _headers)) and expect our test to fail if it doesn't match to what's passed, plus we can put asserts in body of a mocked function if we need to do some more comprehensive checking, where using pattern matching would be tedious or even impossible. And we just return some result, e. g. {:ok, %HTTPoison.Response{status_code: 200, body: nil}}.

For %HTTPoison.Response struct we set only values we are interested in (in this test case we don't care about body field that much, thus we set it to nil, but we do care about HTTP status, hence we return it) - again it's all possible thanks to wonderful Elixir's pattern matching and the dynamic nature of the language. Mock library isn't required to have some fancy matchers in order to achieve this.

Conclusion

Writing integration tests in Elixir and Phoenix is a soft touch thanks to dynamic nature of the language, it’s functional features, and tools provided by Phoenix framework. You can always boil it down to primitives, and just use them to achieve what you need. KISS