Elixir
Mocking Requests in Elixir Tests
When writing code that relies heavily on the results of external API calls, we need to be thinking about how we're going to test how our program reacts to different responses. Making an actual request would make our test suite extremely slow, so this is a good time to mock our responses.
Here's where we're at: we're working though an OAuth 2.0 Authorization flow. Without going into too much detail, the first step is to request an authorization code from the API by sending over encoded credentials. The next step, and the one we're using for this example, is the user authentication. After successful authorization the external API will hit our authentication endpoint with the authorization code in the params. That's all we really need to know here.
defmodule Authentication do
def authenticate(conn, %{"code" => code}) do
if get_refresh_cookie(conn) do
AuthenticationClient.post(conn, refresh_body_params(conn))
else
AuthenticationClient.post(conn, body_params(code))
end
end
# ... functions and stuff
end
defmodule AuthenticationClient do
@url "http://www.an-awesome-api.com/authenticate"
def post(conn, params) do
case HTTPoison.post(@url, params, AnAwesomeApi.headers) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
{ :ok, response } = Poison.decode(body)
cookies = get_cookies_from_response(response)
conn = set_cookies(conn, cookies)
{ :ok, conn }
{:error, %HTTPoison.Error{reason: reason}} ->
{ :error, reason, conn }
end
end
end
defmodule OauthAuthorizationFlowTest do
use ExUnit.Case
use Plug.Test
describe "authentication" do
test "a successful attempt sets the cookies" do
conn = conn(:post, "/authenticate", %{"code" => "valid"})
|> Plug.Conn.fetch_cookies
assert { :ok, new_conn } = Authentication.authenticate(conn, conn.params)
assert new_conn.cookies["spotify_access_token"] == "access_token"
assert new_conn.cookies["spotify_refresh_token"] == "refresh_token"
end
end
end
Our Authentication
module uses the AuthenticationClient
to make a POST
request for authentication. In the above test, we will end up hitting our client
and making an actual http call. This is not what we want. In order to mock this
request, we need to pull out our http call into its own function.
defmodule AuthenticationClient do
def post(conn, params) do
case AuthRequest.post(params) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
{ :ok, response } = Poison.decode(body)
cookies = get_cookies_from_response(response)
conn = set_cookies(conn, cookies)
{ :ok, conn }
{:error, %HTTPoison.Error{reason: reason}} ->
{ :error, reason, conn }
end
end
end
defmodule AuthRequest do
@url "https://www.an-awesome-api.com/api/token"
def post(params) do
HTTPoison.post(@url, params, AnAwesomeApi.headers)
end
end
Not only did we pull it into it's own function, we pulled it into it's own module. Why?
We will be using Mock, a wrapper for the Erlang
mocking library Meck. When Mock stubs a function, it creates it's own version
of the module containing that function with the stubbed function available, and
nothing else. If we moved the call to HTTPoison
to another function within the
same module, post/2
would be undefined.
Let's tell the test to use a mock api client:
The with_mock
macro is documented in the library, linked above.
defmodule OauthAuthorizationFlow do
use ExUnit.Case
use Plug.Test
import Mock
describe "authentication" do
test "a successful attemp sets the cookies" do
with_mock AuthRequest, [post: fn(params) -> AuthenticationClientMock.post(params) end] do
conn = conn(:post, "/authenticate", %{"code" => "valid"})
|> Plug.Conn.fetch_cookies
assert { :ok, new_conn } = Authentication.authenticate(conn, conn.params)
assert new_conn.cookies["access_token"] == "a_valid_access_token"
assert new_conn.cookies["refresh_token"] == "a_valid_refresh_token"
end
end
end
end
Now, our call to AuthRequest.post/1
will be sent to
AuthenticationClientMock.post/1
, which doesn't exist yet.
We can throw a debugger into our actual call using a catch all at the top of our
case statement to get a copy/pastable response to use in our mock. (If we did
things in this exact order we'd also need to comment out the with mock
line
so we can hit the actual request.)
defmodule AuthenticationClient do
def post(conn, params) do
case AuthRequest.post(params) do
{:ok, response } -> require IEx; IEx.pry
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
{ :ok, response } = Poison.decode(body)
cookies = get_cookies_from_response(response)
conn = set_cookies(conn, cookies)
{ :ok, conn }
{:error, %HTTPoison.Error{reason: reason}} ->
{ :error, reason, conn }
end
end
end
response
will be the struct returned by HTTPoison
. We'll paste it into our
mock module (and replace some lengthly values with "foo") in
successful_response/0
, then remove our catchall clause and debugger.
#test/mocks/authentication_client_mock.exs
defmodule HTTPoison.Response do
defstruct body: nil, headers: nil, status_code: nil
end
defmodule AuthenticationClientMock do
def post(params) do
{ :ok, successful_response }
end
defp successful_response do
%HTTPoison.Response{
body: "{\"access_token\":\"a_valid_access_token\",\"token_type\":\"Bearer\",
\"expires_in\":3600,\"a_valid_refresh_token\":\"refresh_token\"}",
headers: [{"Server", "nginx"}, {"Date", "Thu, 21 Jul 2016 16:52:38 GMT"},
{"Content-Type", "application/json"}, {"Content-Length", "397"},
{"Connection", "keep-alive"}, {"Keep-Alive", "timeout=10"},
{"Vary", "Accept-Encoding"}, {"Vary", "Accept-Encoding"},
{"X-UA-Compatible", "IE=edge"}, {"X-Frame-Options", "deny"},
{"Content-Security-Policy",
"default-src 'self'; script-src 'self' foo"},
{"X-Content-Security-Policy",
"default-src 'self'; script-src 'self' foo"},
{"Cache-Control", "no-cache, no-store, must-revalidate"},
{"Pragma", "no-cache"}, {"X-Content-Type-Options", "nosniff"},
{"Strict-Transport-Security", "max-age=31536000;"}],
status_code: 200
}
end
end
With our mock in place it doesn't matter if we use a real response or a mock
response, our client post
function will still pattern match on the same struct
fields and behave the same. There would be more work if we wanted to mock out
failed responses and other outcomes, but you get the idea.
Let's take another look at the line that sets up our mock:
with_mock AuthRequest, [post: fn(params) -> AuthenticationClientMock.post(params) end] do
There's a case for cleaning up this line of code. Since we've kept the involved modules small there likely won't be any different mock setups. That and the fact that we're going to need to use the same setup for more test cases, this is most likely a good candidate for being wrapped up in a macro.
I say probably because it's very easy to find yourself writing macros in any language that allows you to do so; however if anything can be done with simple functions, we should refrain from writing a macro. This is a good candidate for a macro in my opinion, so we'll go with it.
Now we can clean up our mocks like so:
defmodule OauthAuthorizationFlow do
use ExUnit.Case
use Plug.Test
import Mock
defmacro with_auth_mock(block) do
quote do
with_mock AuthRequest, [post: fn(params) -> AuthenticationClientMock.post(params) end] do
unquote(block)
end
end
end
describe "authentication" do
test "a successful attempt sets the cookies" do
with_auth_mock do
conn = conn(:post, "/authenticate", %{"code" => "valid"})
|> Plug.Conn.fetch_cookies
assert { :ok, new_conn } = Authentication.authenticate(conn, conn.params)
assert new_conn.cookies["aaa_access_token"] == "access_token"
assert new_conn.cookies["aaa_refresh_token"] == "refresh_token"
end
end
end
end
If you want to see real world application of this, I took this from concept from a Spotify wrapper I'm working on.
If you're interested in your next project being written in Elixir, we're going nuts over Elixir and Phoenix at Hashrocket and we want to work with you. Get in touch today!