Bypass
When testing our applications there are often times we need to make requests to external services. We may even want to simulate different situations like unexpected server errors. Handling this in an efficient way doesn’t come easy in Elixir without a little help.
In this lesson we’re going to explore how bypass can help us quickly and easily handle these requests in our tests
What is Bypass?
Bypass is described as “a quick way to create a custom plug that can be put in place instead of an actual HTTP server to return prebaked responses to client requests.”
What does that mean? Under-the-hood Bypass is an OTP application that masquerades as an external server listening for and responding to requests. By responding with pre-defined responses we can test any number of possibilities like unexpected service outages and errors along with the expected scenarios we’ll encounter, all without making a single external request.
Using Bypass
To better illustrate the features of Bypass we’ll be building a simple utility application to ping a list of domains and ensure they’re online. To do this we’ll create new supervisor project and a GenServer to check the domains on a configurable interval. By leveraging Bypass in our tests we’ll be able to verify our application will work in many different scenarios.
Note: If you wish to skip ahead to the final code, head over to the Elixir School repo Clinic and have a look.
By this point we should be comfortable creating new Mix projects and adding our dependencies so we’ll focus instead of the pieces of code we’ll be testing. If you do need a quick refresher, refer to the New Projects section of our Mix lesson.
Let’s start by creating a new module that will handle making the requests to our domains.
With HTTPoison let’s create a function, ping/1
, that takes a URL and returns {:ok, body}
for HTTP 200 requests and {:error, reason}
for all others:
defmodule Clinic.HealthCheck do
def ping(urls) when is_list(urls), do: Enum.map(urls, &ping/1)
def ping(url) do
url
|> HTTPoison.get()
|> response()
end
defp response({:ok, %{status_code: 200, body: body}}), do: {:ok, body}
defp response({:ok, %{status_code: status_code}}), do: {:error, "HTTP Status #{status_code}"}
defp response({:error, %{reason: reason}}), do: {:error, reason}
end
You’ll notice we are not making a GenServer and that’s for good reason: By separating our functionality (and concerns) from the GenServer, we are able to test our code without the added hurdle of concurrency.
With our code in place we need to start on our tests.
Before we can use Bypass we’ll need to ensure it’s running.
To do that, let’s update test/test_helper.exs
look like this:
ExUnit.start()
Application.ensure_all_started(:bypass)
Now that we know Bypass will be running during our tests, let’s head over to test/clinic/health_check_test.exs
and finish our setup.
To prepare Bypass for accepting requests we need to open the connect with Bypass.open/1
, which can be done in our test setup callback:
defmodule Clinic.HealthCheckTests do
use ExUnit.Case
setup do
bypass = Bypass.open()
{:ok, bypass: bypass}
end
end
For now we’ll rely on Bypass using it’s default port but if we needed to change it (which we’ll be doing in a later section), we can supply Bypass.open/1
with the :port
option and a value like Bypass.open(port: 1337)
.
Now we’re ready to put Bypass to work.
We’ll start with a successful request first:
defmodule Clinic.HealthCheckTests do
use ExUnit.Case
alias Clinic.HealthCheck
setup do
bypass = Bypass.open()
{:ok, bypass: bypass}
end
test "request with HTTP 200 response", %{bypass: bypass} do
Bypass.expect(bypass, fn conn ->
Plug.Conn.resp(conn, 200, "pong")
end)
assert {:ok, "pong"} = HealthCheck.ping("http://localhost:#{bypass.port}")
end
end
Our test is simple enough and if we run it we’ll see it passes but let’s dig in and see what each portion is doing.
The first thing we see in our test is the Bypass.expect/2
function:
Bypass.expect(bypass, fn conn ->
Plug.Conn.resp(conn, 200, "pong")
end)
Bypass.expect/2
takes our Bypass connection and a single arity function which is expected to modify a connection and return it, this is also an opportunity to make assertions on the request to verify it’s as we expect.
Let’s update our test url to include /ping
and assert both the request path and HTTP method:
test "request with HTTP 200 response", %{bypass: bypass} do
Bypass.expect(bypass, fn conn ->
assert "GET" == conn.method
assert "/ping" == conn.request_path
Plug.Conn.resp(conn, 200, "pong")
end)
assert {:ok, "pong"} = HealthCheck.ping("http://localhost:#{bypass.port}/ping")
end
The last part of our test we use HealthCheck.ping/1
and assert the response is as expected, but what’s bypass.port
all about?
Bypass is actually listening to a local port and intercepting those requests, we’re using bypass.port
to retrieve the default port since we didn’t provide one in Bypass.open/1
.
Next up is adding test cases for errors.
We can start with a test much like our first with some minor changes: returning 500 as the status code and assert the {:error, reason}
tuple is returned:
test "request with HTTP 500 response", %{bypass: bypass} do
Bypass.expect(bypass, fn conn ->
Plug.Conn.resp(conn, 500, "Server Error")
end)
assert {:error, "HTTP Status 500"} = HealthCheck.ping("http://localhost:#{bypass.port}")
end
There’s nothing special to this test case so let’s move on to the next: unexpected server outages.
Τhese are the requests we’re most concerned with.
To accomplish this we won’t be using Bypass.expect/2
, instead we’re going to rely on Bypass.down/1
to shut down the connection:
test "request with unexpected outage", %{bypass: bypass} do
Bypass.down(bypass)
assert {:error, :econnrefused} = HealthCheck.ping("http://localhost:#{bypass.port}")
end
If we run our new tests we’ll see everything passes as expected!
With our HealthCheck
module tested we can move on to testing it together with our GenServer based-scheduler.
Multiple external hosts
For our project we’ll keep the scheduler barebones and rely on Process.send_after/3
to power our reoccuring checks, for more on the Process
module take a look at the documentation.
Our scheduler requires three options: the collection of sites, the interval of our checks, and the module that implements ping/1
.
By passing in our module we further decouple our functionality and our GenServer, enabling us to better test each in isolation:
def init(opts) do
sites = Keyword.fetch!(opts, :sites)
interval = Keyword.fetch!(opts, :interval)
health_check = Keyword.get(opts, :health_check, HealthCheck)
Process.send_after(self(), :check, interval)
{:ok, {health_check, sites}}
end
Now we need to define the handle_info/2
function for the :check
message sent send_after/2
.
To keep things simple we’ll pass our sites to HealthCheck.ping/1
and log our results to either Logger.info
or in the case of errors Logger.error
.
We’ll setup our code in a way that will enable us to improve the reporting capabilities at a later time:
def handle_info(:check, {health_check, sites}) do
sites
|> health_check.ping()
|> Enum.each(&report/1)
{:noreply, {health_check, sites}}
end
defp report({:ok, body}), do: Logger.info(body)
defp report({:error, reason}) do
reason
|> to_string()
|> Logger.error()
end
As discussed we pass our sites to HealthCheck.ping/1
then iterate the results with Enum.each/2
applying our report/1
function against each.
With these functions in place our scheduler is done and we can focus on testing it.
We won’t focus too much on unit testing the schedulers since that won’t require Bypass, so we can skip to the final code:
defmodule Clinic.SchedulerTest do
use ExUnit.Case
import ExUnit.CaptureLog
alias Clinic.Scheduler
defmodule TestCheck do
def ping(_sites), do: [{:ok, "pong"}, {:error, "HTTP Status 404"}]
end
test "health checks are run and results logged" do
opts = [health_check: TestCheck, interval: 1, sites: ["http://example.com", "http://example.org"]]
output =
capture_log(fn ->
{:ok, _pid} = GenServer.start_link(Scheduler, opts)
:timer.sleep(10)
end)
assert output =~ "pong"
assert output =~ "HTTP Status 404"
end
end
We rely on a test implementation of our health checks with TestCheck
alongside CaptureLog.capture_log/1
to assert that the appropriate messages are logged.
Now we have working Scheduler
and HealthCheck
modules, let’s write an integration test to verify everything works together.
We’ll need Bypass for this test and we’ll have to handle multiple Bypass requests per test, let’s see how we do that.
Remember the bypass.port
from earlier? When we need to mimic multiple sites, the :port
option comes in handy.
As you’ve probably guessed, we can create multiple Bypass connections with different ports to simulate independent sites.
We’ll start by reviewing our updated test/clinic_test.exs
file:
defmodule ClinicTest do
use ExUnit.Case
import ExUnit.CaptureLog
alias Clinic.Scheduler
test "sites are checked and results logged" do
bypass_one = Bypass.open(port: 1234)
bypass_two = Bypass.open(port: 1337)
Bypass.expect(bypass_one, fn conn ->
Plug.Conn.resp(conn, 500, "Server Error")
end)
Bypass.expect(bypass_two, fn conn ->
Plug.Conn.resp(conn, 200, "pong")
end)
opts = [interval: 1, sites: ["http://localhost:1234", "http://localhost:1337"]]
output =
capture_log(fn ->
{:ok, _pid} = GenServer.start_link(Scheduler, opts)
:timer.sleep(10)
end)
assert output =~ "[info] pong"
assert output =~ "[error] HTTP Status 500"
end
end
There shouldn’t be anything too surprising in the above test.
Instead of creating a single Bypass connection in setup
, we’re creating two within our test and specifying their ports as 1234 and 1337.
Next we see our Bypass.expect/2
calls and finally the same code we have in SchedulerTest
to start the scheduler and assert we log the appropriate messages.
That’s it! We’ve built a utility to keep us informed if there are any issues with our domains and we’ve learned how to employ Bypass to write better tests with external services.
Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!