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.
- Package: hex.pm/packages/http_double
- API docs (HexDocs): hexdocs.pm/http_double
- Repository: github.com/aszymanskiit/http_double
- License: MIT (see LICENSE)
- Changelog: CHANGELOG.md
- MockServer: implementation includes a MockServer-compatible control API (mock-server.com)
HttpDouble is a test double for HTTP: a fake server your tests can start and control.
- Starts a real TCP server that speaks HTTP/1.1 on a port you choose (or
0for a random free port). - You define behaviour via static routes and/or dynamic stubs/expectations: status, headers, body, JSON, delays, timeouts, connection closes.
- Your application (or the system under test) is configured to call
http://host:port/...instead of the real service. - 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.
- Real TCP, real HTTP/1.1 – works with any HTTP client (HTTPoison, Req,
:httpc, raw:gen_tcp, Plug/Cowboy in front). - Configurable or random port – bind to
0for an OS-assigned port, ideal for parallel tests. - Static routes – define method + path (and optional host, query, headers, body); support prefix and regex path matching; responses as maps, structs, or callbacks.
- Mock/stub engine – match by method, path, host, query, headers, body, regex, call index, connection id; respond with constants, callbacks, lists (sequential responses), delays, timeouts, connection close, partial/raw bytes.
- Full call history – every request is recorded; assert on method, path, headers, body.
- ExUnit integration –
use HttpDouble.Casegives you a fresh server per test andhttp_server/http_endpointin context. - Multiple modes – mock-first (stubs override routes), routes-only, or mock-only.
- Fast and isolated – one OTP process per server; safe for async tests.
- MockServer-compatible control API –
PUT /mockserver/expectation,/verify,/clear(full or selective by matcher),/reset; JSON bodies with correctContent-Typeforbody.type: JSON(see 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.
- MockServer (documentation & product): mock-server.com
- MockServer (source): github.com/mock-server/mockserver
| Method | Path(s) | Purpose |
|---|---|---|
PUT |
/expectation, /mockserver/expectation |
Register a dynamic expectation (stub rule) |
PUT |
/verify, /mockserver/verify |
Assert how many times a request was received |
PUT |
/clear, /mockserver/clear |
Remove expectation rules (all or selective) |
PUT |
/reset, /mockserver/reset |
Clear 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.
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 }
}body.type: "JSON"withjsonas a string — response is sent withContent-Type: application/json(since 1.0.1).body.type: "STRING"withstring— plain text; optionalcontentType.httpError.dropConnection: true— connection drop (:close), no HTTP response body.httpResponse.delay— optional delay before the response (MockServer-styletimeUnit/value).
Expectation rules are evaluated in :mock_first mode before static routes; in :mock_only they are the only matchers besides 404.
{
"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.
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:
- Wrapper form:
{ "httpRequest": { "method": "GET", "path": "/api/foo" } } - Top-level matcher (no wrapper):
{ "method": "GET", "path": "/api/foo" }— same as some existing test utilities send to/mockserver/clear
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.
| Mechanism | Dynamic rules | Call history | Static routes |
|---|---|---|---|
PUT /mockserver/clear {} |
cleared | cleared | kept |
PUT /mockserver/clear selective |
matching only | cleared | kept |
PUT /mockserver/reset |
cleared | cleared | kept |
HttpDouble.reset!/1 |
cleared | cleared | cleared |
Add HttpDouble as a dependency only in test (it is not for production).
{:http_double, "~> 1.0.2", only: :test}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# 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:
:port– TCP port (0= random).:host– host string forendpoint/1(default"127.0.0.1").:routes– list of static route maps.:mode–:mock_first(default),:routes_only, or:mock_only.:name– optional registered name.
%{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).
{: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"}
})# 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"}
])# 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
]){:ok, _rule} =
HttpDouble.expect(server, %{method: :post, path: "/events"}, %{status: 202})
# Later: assert with HttpDouble.calls(server)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{: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"})From Elixir (full wipe including static routes):
HttpDouble.reset!(server)
# Clears static routes, mock/expectation rules, and call historyFrom 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 selective PUT /mockserver/clear with path / pathRegex when shared stubs must outlive a single test (see Clearing expectations).
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| Function | Description |
|---|---|
start_link/1 |
Start a new HTTP dummy server |
child_spec/1 |
For use under a supervisor |
stub/2, stub/3 |
Add a stub rule (matcher + response or keyword list) |
expect/3 |
Add an expectation rule (same shape as stub) |
add_route/2, add_routes/2, set_routes/2, update_route/2, delete_route/2 |
Manage static routes |
calls/1 |
List of all recorded requests |
reset!/1 |
Clear routes, rules, and call history |
host/1, port/1, endpoint/1, url/2 |
Server address and URLs |
stop/1 |
Stop 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.
:mock_first(default) – Evaluate mock/stub rules first; if none match, fall back to static routes.:routes_only– Only static routes; mock rules are ignored.:mock_only– Only mock rules; no static routes. Unmatched requests get 404.
- HttpDouble.Application – Registry for optional server names.
- HttpDouble.Server – GenServer: TCP listener, connections, static routes, mock rules, call history.
- HttpDouble.Connection – One process per TCP connection: parse HTTP/1.1, apply response specs (including delay/close/partial).
- HttpDouble.Request / Response – Request and response representation and encoding.
- HttpDouble.Route – Static route matching.
- HttpDouble.MockRule / MockEngine – Rule representation and application.
- HttpDouble.Case – ExUnit case template that starts a server per test and injects
http_serverandhttp_endpoint.
HttpDouble is a test double, not a full HTTP stack:
- HTTP/1.1 over TCP only (no TLS, no HTTP/2/3).
- Request bodies:
Content-Lengthsupported; chunked request bodies are not. - Keep-alive and pipelining work for typical clients; edge cases may differ.
- No automatic compression or advanced features.
For heavy real-world behaviour use a real server in system tests; use HttpDouble for deterministic integration tests and fault injection.
- Source: github.com/aszymanskiit/http_double
- License: MIT – see LICENSE.
You can use, copy, modify, merge, publish, distribute, sublicense, and sell copies of the software under the terms of the MIT License.