We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
For a long time, I’ve depended on Bypass
to mock third-party services in Elixir. You only really need this when you have to test the HTTP client implementation, otherwise, you could (and probably should) just mock the HTTP client itself.
Assent
Assent
implements HTTP client adapters for :httpc
and Mint
.
:httpc
doesn’t validate TLS certificates out of the box. To enable validation you need to configure :httpc
. This means that to ensure the configuration works, I must test against an HTTPS endpoint. Unfortunately, Bypass
doesn’t, and won’t, support TLS. This also prevents testing HTTP/2 (and HTTP/3) connections.
To ensure that the :httpc
configuration worked, I added unit tests that send off requests to badssl.com. However, with CI depending on an external service, I might have to deal with network issues causing it to fail, plus added latency. Ideally, test suites run without any external dependencies.
Last year I fixed this by setting up a custom mock service. I had all I needed with HTTP/2 and TLS support!
JSON-RPC and GraphQL
A while later I had to mock a JSON-RPC third-party service. I discovered another flaw in Bypass
, which also existed in the custom mock service in Assent
.
JSON-RPC only has one endpoint path. Both Bypass
and the custom mock service in Assent
can only mock a path once. This would make it impossible to mock a GraphQL endpoint as well, if you have more than one request happening in a test.
With Bypass
, you could get around this by using the expect
function since it allows for any number of requests to the same path. But that doesn’t feel right. We want to know exactly how many requests to expect for a test. And it feels messy to deal with state inside the callback function to know how many requests have been received.
TestServer
Fed up with these issues I went to work. I wanted a flexible mocking library that could be used in most, if not all, third-party service scenarios. The result is TestServer
. A plug-based mocking library that doesn’t get in your way.
FIFO queue
Instead of using the path as a unique identifier to match, I built a FIFO queue of request expectations. Each request expectation can be customized to match any attributes for a request (i.e. path, method, any attributes in the %Plug.Conn{}
struct):
TestServer.add("/", via: :get, match: fn
%{params: %{"a" => 1}} = _conn -> true
_conn -> false
end)
It’s also possible to transform requests before it matches expectations:
TestServer.plug(fn conn ->
{:ok, body, _conn} = Plug.Conn.read_body(conn, [])
%{conn | body_params: Jason.decode!(body)}
end)
As TestServer
is plug based, you can also use any plugs in your app:
TestServer.plug(FetchBodyPlug)
TestServer.add("/", to: MyPlug)
TestServer
will fail if it receives an unexpected request:
1) test fails (Test)
test/test.exs:10
** (RuntimeError) TestServer.Instance #PID<0.350.0> received an unexpected GET request at /path.
Active routes:
#1: * /
test/test.exs:10: Test."test fails"/1
TestServer
also fails if request expectations went unmatched:
1) test fails (Test)
test/test.exs:10
** (RuntimeError) TestServer.Instance #PID<0.340.0> did not receive a request for these routes before the test ended:
#1: * /
test/test.exs:10: Test."test fails"/1
Self-signed certificate
To make it easier to test TLS, the server can automatically generate a self-signed certificate with the X509
library. The certificate can be passed onto the HTTP client:
{:ok, instance} = TestServer.start(scheme: :https)
assert {:ok, {{_, 200, _}, body}} =
:httpc.request(
:get,
{String.to_charlist(TestServer.url()), []},
ssl: [
verify: :verify_peer,
cacerts: TestServer.x509_suite().cacerts,
# ...
])
WebSocket
TestServer
can also mock WebSocket connections and handle bidirectional messages:
{:ok, socket} = TestServer.websocket_init("/ws")
:ok = TestServer.websocket_handle(
socket,
match: fn {:text, message}, _state ->
message == "hi"
end,
to: fn _frame, state ->
{:reply, {:text, "hello"}, state}
end)
:ok = TestServer.websocket_info(socket, fn state ->
{:reply, {:text, "hey!"}, state}
end)
HTTP Server
Under the hood, TestServer
uses Bandit
, Plug.Cowboy
, or built-in :httpd
, depending on which one is available in your project. When there’s a native Erlang/Elixir HTTP/3 server I’ll include that as well!
I’m now using TestServer
for Assent
and Premailex
, plus all private projects where I need to mock third-party services!
Hi, I'm Dan Schultzer, I write in this blog, work a lot in Elixir, maintain several open source projects, and help companies streamline their development process