HttpDouble

CIHex.pmDocumentation

HttpDouble is a controllable, HTTP/1.1 dummy server for integration testing in Elixir. It speaks real HTTP over TCP on a configurable (or random) port and gives you a programmatic API to stub responses, record requests, and inject faults (timeouts, connection drops, partial responses). The library’s HTTP control API (expectations, verification, reset, and related paths) is compatible with MockServer for the supported subset—see MockServer compatibility. It is designed for testing systems that call external HTTP endpoints—e.g. webhooks, REST APIs callbacks—without hitting the real network.


What does HttpDouble do?

HttpDouble is a test double for HTTP: a fake server your tests can start and control.

  1. Starts a real TCP server that speaks HTTP/1.1 on a port you choose (or 0 for a random free port).
  2. You define behaviour via static routes and/or dynamic stubs/expectations: status, headers, body, JSON, delays, timeouts, connection closes.
  3. Your application (or the system under test) is configured to call http://host:port/... instead of the real service.
  4. You assert on the requests HttpDouble received using HttpDouble.calls(server).

It is not a production web server. It is a test tool with predictable behaviour, fast startup, and support for parallel tests.


Features


MockServer compatibility

HttpDouble exposes an HTTP control plane on the same port as the mock traffic. Clients that already speak MockServer’s REST API (e.g. test helpers using RestClient, :httpc, or curl) can target HttpDouble without a separate JVM process.

This is an intentional subset of MockServer’s full API—not every endpoint, header, or JSON field behaves exactly like the reference implementation.

Supported control endpoints

MethodPath(s)Purpose
PUT/expectation, /mockserver/expectationRegister a dynamic expectation (stub rule)
PUT/verify, /mockserver/verifyAssert how many times a request was received
PUT/clear, /mockserver/clearRemove expectation rules (all or selective)
PUT/reset, /mockserver/resetClear all expectation rules and call history

Path aliases (/clear vs /mockserver/clear, etc.) exist because different codebases in the wild use different prefixes.

Important: MockServer clear / reset affect only dynamic expectation rules and call history. Static routes configured at startup (:routes, set_routes/2, …) are unchanged. To wipe routes as well, use HttpDouble.reset!/1 from Elixir.

Registering expectations (PUT /expectation)

Body shape (JSON) follows MockServer: httpRequest, httpResponse or httpError, optional times (unlimited, remainingTimes).

{
"httpRequest": {
"method": "GET",
"path": "/api/rest/v1/employees/42/permissions"
},
"httpResponse": {
"statusCode": 200,
"body": {
"type": "JSON",
"json": "{\"total_items\": 1}",
"contentType": "application/json"
}
},
"times": { "unlimited": true }
}

Expectation rules are evaluated in :mock_first mode before static routes; in :mock_only they are the only matchers besides 404.

Verifying calls (PUT /verify)

{
"httpRequest": { "method": "GET", "path": "/api/example" },
"times": { "atLeast": 1, "atMost": 1 }
}

Returns HTTP 202 when the match count satisfies times, otherwise 417. Call history is read-only for verify; use clear to reset counts between scenarios.

Clearing expectations (PUT /clear)

Clear all — empty body or {} removes every dynamic rule and clears call history:

curl -X PUT "http://127.0.0.1:PORT/mockserver/clear" \
-H "Content-Type: application/json" \
-d '{}'

Selective clear (since 1.0.2) — only rules matching the request matcher are removed; other expectations and static routes stay:

{
"httpRequest": {
"pathRegex": "^/api/rest/v1/context-access"
}
}

Also supported:

Matching uses rule method + path (exact or prefix under a concrete path) or pathRegex against the rule’s path. Rules registered with a path regex in the expectation are matched against the clear regex when applicable.

Use selective clear when long-lived stubs (e.g. permissions for a fixed setup_all account) must survive per-test cleanup of another API surface.

Reset vs Elixir reset!/1

MechanismDynamic rulesCall historyStatic routes
PUT /mockserver/clear{}clearedclearedkept
PUT /mockserver/clear selectivematching onlyclearedkept
PUT /mockserver/resetclearedclearedkept
HttpDouble.reset!/1clearedclearedcleared

Installation

Add HttpDouble as a dependency only in test (it is not for production).

{:http_double, "~> 1.0.2", only: :test}

Quick start

1. Start a server with a static route

routes = [
%{method: "GET", path: "/health", response: %{status: 200, body: "ok"}}
]
{:ok, server} = HttpDouble.start_link(port: 0, routes: routes, mode: :routes_only)
%{host: host, port: port, base_url: base_url} = HttpDouble.endpoint(server)
# base_url is e.g. "http://127.0.0.1:12345"
# Your app would GET base_url <> "/health" and receive 200 with body "ok"
HttpDouble.stop(server)
defmodule MyApp.WebhookTest do
use HttpDouble.Case, async: true
test "webhook receives POST", %{http_server: server, http_endpoint: endpoint} do
{:ok, _rule} =
HttpDouble.stub(server, %{method: :post, path: "/webhook"}, %{
status: 200,
json: %{result: "ok"}
})
# Point your app at endpoint.base_url <> "/webhook" and trigger the flow...
# Then assert:
calls = HttpDouble.calls(server)
assert Enum.any?(calls, fn %{request: req} ->
req.method == "POST" and req.path == "/webhook"
end)
end
end

Usage examples

Starting the server

# Random port (good for parallel tests)
{:ok, server} = HttpDouble.start_link(port: 0, mode: :mock_only)
# Fixed port
{:ok, server} = HttpDouble.start_link(port: 8081, routes: [], mode: :mock_only)
# With static routes and routes-only mode
routes = [
%{method: "GET", path: "/health", response: %{status: 200, body: "ok"}},
%{method: "POST", path: "/api/events", response: %{status: 201, json: %{id: "1"}}}
]
{:ok, server} = HttpDouble.start_link(port: 0, routes: routes, mode: :routes_only)

Options:

Getting the endpoint URL

%{host: host, port: port, base_url: base_url} = HttpDouble.endpoint(server)
# Full URL for a path
url = HttpDouble.url(server, "/api/events")
# or with segments
url = HttpDouble.url(server, ["api", "events"])

Use base_url or url(server, path) when configuring the system under test (e.g. webhook URL for ejabberd).

Stubbing a single response

{:ok, server} = HttpDouble.start_link(port: 0, mode: :mock_only)
# Always 200 "ok" for GET /health
{:ok, _rule} =
HttpDouble.stub(server, %{method: "GET", path: "/health"}, %{status: 200, body: "ok"})
# 201 with JSON for POST
{:ok, _rule} =
HttpDouble.stub(server, %{method: :post, path: "/api/messages"}, %{
status: 201,
json: %{result: "accepted"}
})

Sequential responses (list)

# First call -> 200 "ok", second call -> 503 "down"
{:ok, _rule} =
HttpDouble.stub(server, %{method: :get, path: "/health"}, [
%{status: 200, body: "ok"},
%{status: 503, body: "down"}
])

Fault injection: delay, timeout, close

# 1st: 200 after 500 ms, 2nd: no reply (timeout), 3rd: close connection
{:ok, _rule} =
HttpDouble.stub(server, %{method: :get, path: "/ping"}, [
{:delay, 500, %{status: 200, body: "SLOW"}},
:timeout,
:close
])

Expectations (same as stub, use for “must be called” assertions)

{:ok, _rule} =
HttpDouble.expect(server, %{method: :post, path: "/events"}, %{status: 202})
# Later: assert with HttpDouble.calls(server)

Asserting on received requests (call history)

calls = HttpDouble.calls(server)
# Each element: %{conn_id: _, request: %Request{}, timestamp: _, rule_id: _, route_id: _, action: _}
assert length(calls) >= 1
[%{request: req} | _] = calls
assert req.method == "POST"
assert req.path == "/webhook"
assert req.body =~ "payload"
assert {"content-type", _} in req.headers

Dynamic routes at runtime

{:ok, server} = HttpDouble.start_link(port: 0, mode: :routes_only)
HttpDouble.set_routes(server, [
%{method: "GET", path: "/foo", response: %{status: 200, body: "foo"}}
])
# Update one route later
HttpDouble.update_route(server, %{method: "GET", path: "/foo", response: %{status: 200, body: "bar"}})
# Add / delete
HttpDouble.add_route(server, %{method: "GET", path: "/baz", response: %{status: 200, body: "baz"}})
HttpDouble.delete_route(server, %{method: "GET", path: "/foo"})

Resetting state between tests

From Elixir (full wipe including static routes):

HttpDouble.reset!(server)
# Clears static routes, mock/expectation rules, and call history

From HTTP (MockServer clients) — only dynamic rules and history; routes defined at start_link remain:

# Via :httpc, Req, RestClient, etc. on the same base_url as the app under test:
# PUT base_url <> "/mockserver/clear" body: "{}"
# PUT base_url <> "/mockserver/reset"

Prefer selectivePUT /mockserver/clear with path / pathRegex when shared stubs must outlive a single test (see Clearing expectations).

Using with ExUnit.Case context

When you use HttpDouble.Case, the context has http_server and http_endpoint. You can use the helper macros that take the context:

test "stub via context", %{http_server: server, http_endpoint: _endpoint} do
stub(server, %{method: "GET", path: "/ping"}, %{status: 200, body: "pong"})
# or: HttpDouble.stub(server, ...)
end

Public API summary

FunctionDescription
start_link/1Start a new HTTP dummy server
child_spec/1For use under a supervisor
stub/2, stub/3Add a stub rule (matcher + response or keyword list)
expect/3Add an expectation rule (same shape as stub)
add_route/2, add_routes/2, set_routes/2, update_route/2, delete_route/2Manage static routes
calls/1List of all recorded requests
reset!/1Clear routes, rules, and call history
host/1, port/1, endpoint/1, url/2Server address and URLs
stop/1Stop the server

Matchers can be a map (%{method: ..., path: ...}), or :any, {:method, m}, {:path, p}, {:prefix, p}, {:path_regex, re}, {:route, method, path}, {:fn, fun}. Response specs can be maps (with :status, :body, :json, :headers), {:delay, ms, spec}, :timeout, :close, {:close, spec}, {:raw, iodata}, {:partial, [iodata]}, {:fun, fun}, or a list of specs for sequential responses. For full API and types, see HexDocs or run mix docs locally and open doc/index.html.


Modes


Architecture (overview)


Limitations

HttpDouble is a test double, not a full HTTP stack:

For heavy real-world behaviour use a real server in system tests; use HttpDouble for deterministic integration tests and fault injection.

Repository and license

You can use, copy, modify, merge, publish, distribute, sublicense, and sell copies of the software under the terms of the MIT License.