Published on

TestServer - mock third-party services

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