What should you do when testing Elixir code that makes web requests to an external API? We don't want to let our code make those requests during a test run––we'll slow down our tests and potentially use up API rate limits. We could mock such requests with the help of a mocking library or use recorded requests with the help of recording library. We'll show you why you should avoid these approaches and instead roll your own mock server for your test environment.
Don't: Use Recorded Requests
While there are a number of web request recording libraries in Elixir, there are also a number of drawbacks to using them:
- You're adding a dependency to your test suite.
- You're bloating your file tree with potentially lots and lots of "cassette" files.
- It can be onerous to record a "first cassette" for each request you need to "playback".
- It can be onerous to re-record cassettes if your code changes the manner in which it hits an API endpoint or if the API itself changes.
- Imitating sad paths can be tricky, since you have to create real failed requests for every type of anticipated failure.
Don't: Mock Web Requests
You're convinced that you don't want to use recorded requests (great!). So how about mocking web requests instead?
Let's say you're building an application that talks to the GitHub API. You have a module, GithubClient
, that uses HTTPoison
to make web requests to that API. You could mock these web requests by mocking the function calls made against HTTPoison
. Let's say we are trying to test the following code:
defmodule GithubClient do
@base_url "https://api.github.com/v3"
def create_repo(params) do
HTTPoison.post(@base_url <> "/repos", params, headers)
|> handle_response
end
defp handle_response(resp) do
case resp do
{:ok, %{body: body, status_code: 200}} ->
%GithubRepo{id: body.id, name: body.name}
{:ok, %{body: body, status_code: 422}} ->
%GithubError{error_message: message}
end
end
end
defp headers do
...
end
end
Using the Elixir Mock library, your test would look something like this:
defmodule GithubClient.Test do
use ExUnit.Case, async: false
import GithubRepo
import Mock
@create_repo_params %{name: "my-new-repo"}
test_with_mock "create_repo when success", HTTPoison,
[post: fn("repos", @create_repo_params, _headers) ->
%HTTPoison.Response{body: %{name: "my-new-repo", id: 1}, headers: _list, request_url: _url, status_code: 200} end] do
response = GithubClient.create_repo(@create_repo_params)
assert %GithubRepo{name: "my-new-repo", id: 1} == response
end
test_with_mock "create_repo when failure", HTTPoison,
[post: fn("repos", @create_repo_params, _headers) ->
%HTTPoison.Response{body: %{message: "error message"}, headers: _list, request_url: _url, status_code: 422} end] do
response = GithubClient.create_repo(@create_repo_params)
assert %GithubError{} == response
end
end
So, what's so wrong here? Well, first of all, we've now coupled our tests directly to the HTTPoison
dependency. If we choose to switch HTTP clients at a later time, all of our tests would fail, even if our app still behaves correctly. Not fun.
Second, this makes for some really repetitious tests. Any tests that run code that hit the same API endpoints will require us to mock the requests all over again. Even these two tests for our happy and sad create_repo
paths feel repetitious and clunky.
The repetitious mocking code that now bloats our tests make them even harder to read. Instead of letting contexts and it
descriptors speak for themselves, anyone reading out tests has to parse the meaning of lots and lots of inline mocking code.
Lastly, this approach not only makes our lives more difficult but also represents a misuse of mocks. Jose Valim's article on this topic describes it best:
Mocks are simulated entities that mimic the behavior of real entities in controlled ways...I always consider “mock” to be a noun, never a verb.
By "mocking" (verb!) our API interactions, we make an implementation detail (the fact that we are using HTTPoison
) a first class citizen in our test environment, with the ability to break all of our tests, even when our app behaves successfully.
Instead of misusing mocks in this manner, let's build our very own mock (noun!) server and run it in our test environment.
Do: Build Your Own Mock Server
We won't be mocking calls to HTTPoison
functions. We'll let HTTPoison make real web requests. BUT! Instead of sending those requests to the GitHub API, we'll make the API URL configurable based on environment, and tell HTTPoison
to hit our own internally served "test endpoint" in the test environment.
We'll build a simple HTTP server in our Elixir app, using Cowboy and Plug. Then, we'll define a controller that knows how to respond to the requests that HTTPoison
will send in the course of the test run of our GithubClient
code. Lastly, we'll configure our app to run our server in the test environment only.
Let's get started!
Building the Mock HTTP Server with Cowboy and Plug
Our Github Client app is not a Phoenix application. So if we want to run a server, we need to build that server ourselves. Luckily, Cowboy and Plug make it pretty easy to set up a simple HTTP server in our Elixir app. Cowboy is a simple HTTP server for Erlang and Plug provides us with a connection adapter for that web server.
First things first, we'll add the Plug and Cowboy Plug dependencies to our app:
# mix.exs
defp deps do
[
{:dialyxir, "~> 0.5", only: [:dev], runtime: false},
{:httpoison, "~> 1.0"},
{:plug_cowboy, "~> 2.0"},
{:plug, "~> 1.0"}
]
end
We'll tell our application to run Cowboy and Plug in the test environment only:
# mix.exs
def application do
[
extra_applications: [:logger],
mod: {GithubClient.Application, [env: Mix.env]},
applications: applications(Mix.env)
]
end
defp applications(:test), do: applications(:default) ++ [:cowboy, :plug]
defp applications(_), do: [:httpoison]
Next up, we'll define our GithubClient.MockServer
module. We want our server to do a few things for us:
- Start up in a supervision tree when the application starts (in the test env only)
- Run and route requests to our
GithubClient.GithubApiMockController
(coming soon!)
In order to make sure we can start up our test server as part of our application's supervision tree, we'll tell it to use GensServer
and implement the init
and start_link
functions.
# lib/github_client/mock_server.ex
defmodule GithubClient.MockServer do
use GenServer
alias GithubClient.MockServer.GithubApiMockController
def init(args) do
{:ok, args}
end
def start_link(_) do
Plug.Cowboy.http(GithubApiMockController, [], port: 8081)
end
end
Our start_link
function runs the Cowboy server under HTTP, providing the GithubApiMockController
module as the interface for incoming web requests. We can also specify the port on which to run the server.
Let's tell our app to start GithubClient.MockServer
as part of the supervision tree now:
# application.ex
...
def start(_type, args) do
children = case args do
[env: :prod] -> []
[env: :test] -> [{GithubClient.MockServer, []}]
[_] -> []
end
opts = [strategy: :one_for_one, name: GithubClient.Supervisor]
Supervisor.start_link(children, opts)
end
Now that our server knows how to start up, let's build the GithubApiMockController
that is the interface for our web requests.
Building the Github API Mock Controller
Our GithubApiMockController
module is the interface for our web server. It needs to know how to route requests and respond to the specific requests that our GithubClient
will send in the course of a test run. GithubApiMockController
is the real mock entity––it will act as a stand in for the GitHub API, expect to receive all of the web requests that we would send to GitHub and respond accordingly.
In order for our controller to route web requests, we need to tell it to use Plug.Router
. This provides us the routing macros we need to match and respond to web requests.
Since our controller will be receiving JSON payloads (just like the GitHub API!), we'll also tell it to run requests through the Plug.Parsers
plug. This will parse the request body for us.
# lib/github_client/mock_server/github_api_mock_controller.ex
defmodule GithubClient.MockServer.GithubApiMockController do
use Plug.Router
plug Plug.Parsers, parsers: [:json],
pass: ["text/*"],
json_decoder: Poison
plug :match
plug :dispatch
end
Now we're ready to add our routes!
Defining Routes For Our Mock
Eventually, we'll need to add routes that know how to handle the happy and sad paths for any web requests sent in the course of a test run. For now, we'll revisit our earlier test example, which runs code that hits the POST /repos
GitHub API endpoint:
# lib/github_client/mock_server/github_api_mock_controller.ex
...
post "/repos" do
case conn.params do
%{"name" =>"success-repo"} ->
success(conn, %{"id" => 1234, "name" => "success-repo"})
%{"name" =>"failure-repo"} ->
failure(conn)
end
defp success(conn, body \\ "") do
conn
|> Plug.Conn.send_resp(200, Poison.encode!(body))
end
defp failure(conn) do
conn
|> Plug.Conn.send_resp(422, Poison.encode!(%{message: "error message"}))
end
end
Here we've defined a route POST /repos
that uses a case statement to introspect on some params and send a happy or sad response.
Now that our mock server's interface is defined to handle this request, let's refactor our tests.
Cleaner Tests with Our Mock Server
First, a quick refresher on the code we're testing. The GithubClient.create_repo
function does two things:
- Make the
POST
request to the/repos
endpoint - Handle the response to return a
GithubRepo
struct or aGithubError
struct.
Our code looks something like this:
defmodule GithubClient do
@base_url "https://api.github.com/v3"
def create_repo(params) do
HTTPoison.post(@base_url <> "/repos", params, headers)
|> handle_response
end
defp handle_response(resp) do
case resp do
{:ok, %{body: body, status_code: 200}} ->
%GithubRepo{id: body.id, name: body.name}
{:ok, %{body: body, status_code: 422}} ->
%GithubError{error_message: message}
end
end
end
defp headers do
...
end
end
We want to test that, when we successfully create a repo via the GitHub API, the function returns a GithubRepo
struct. When we don't successfully create a repo via the GitHub API, we return a GithubError
struct. Instead of defining complicated function mocks inside our tests, we'll write our nice clean tests with no awareness of any mocks.
In order for our tests to use our mock server, we need to make one simple change: tell the GithubClient
module to send requests to our internally hosted endpoint in the test environment, instead of to the GitHub API.
To do that, we'll stop hard-coding the value of the @base_url
module attribute and instead make it an environment-specific application variable:
defmodule GithubClient do
@base_url Application.get_env(:github_client, :api_base_url)
...
end
# config/test.exs
use Mix.Config
config :github_client, api_base_url: "http://localhost:8081"
# config/dev.exs
use Mix.Config
config :github_client, api_base_url: "https://api.github.com/v3"
# config/prod.exs
use Mix.Config
config :github_client, api_base_url: "https://api.github.com/v3"
# config/config.exs
use Mix.Config
import_config "#{Mix.env}.exs"
Now we can write tests that are totally agnostic of any mocking:
defmodule GithubClient.Test do
use ExUnit.Case, async: false
import GithubRepo
@success_repo_params %{name: "success-repo"}
@failure_repo_params %{name: "failed-repo"}
test "create_repo when success" do
response = GithubClient.create_repo(@success_repo_params)
assert %GithubRepo{name: "success-repo", id: 1} == response
end
test "create_repo when failure" do
response = GithubClient.create_repo(@failure_repo_params)
assert %GithubError{error_message: "error message"} == response
end
end
Ta-da!
Update: Cowboy2 Makes it Event Easier!
With the recent release of the newest version of Cowboy, we can start up our test server with even less code.
We can completely get rid of the GithubClient.MockServer
module. Instead, we can tell our application to start up the GithubClient.GithubApiMockController
directly, under the HTTP schema, running on a specific port, like this:
def start(_type, args) do
children = case args do
[env: :prod] -> []
[env: :test] -> [{Plug.Cowboy, scheme: :http, plug: GithubClient.GithubApiMockController, options: [port: 8081]}]
[env: :dev] -> []
[_] -> []
end
opts = [strategy: :one_for_one, name: GithubClient.Supervisor]
Supervisor.start_link(children, opts)
end
This has the same effect of calling Plug.Cowboy.http(GithubClient.GithubApiMockController, [], port: 8081)
, so we don't need to spin up a separate server to make that function call for us.
Conclusion
By writing our own mock server, we are able write tests that illustrate and test the contract or interface between our own code and the external GitHub API. We are testing that our code behaves as expected, given an expected response. This is different from mocking an HTTP client object, which requires us to simulate the behavior of an object that is not a necessary part of our app's successful communication with the API.
By remember that we should create mocks (noun!) instead of mocking (verb!), we end up with clean, readable tests that don't rely on implementation details to pass. So next time you're faced with testing code that makes external web requests, remember that a simple hand-rolled mock server is now part of your tool belt.