# HttpDouble
[](https://github.com/aszymanskiit/http_double/actions/workflows/ci.yml)
[](https://hex.pm/packages/http_double)
[](https://hexdocs.pm/http_double)
**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](https://www.mock-server.com/)** for the supported subset—see [MockServer compatibility](#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](https://hex.pm/packages/http_double)
- **API docs (HexDocs):** [hexdocs.pm/http_double](https://hexdocs.pm/http_double)
- **Repository:** [github.com/aszymanskiit/http_double](https://github.com/aszymanskiit/http_double)
- **License:** MIT (see [LICENSE](LICENSE))
- **Changelog:** [CHANGELOG.md](CHANGELOG.md)
- **MockServer:** implementation includes a [MockServer-compatible](#mockserver-compatibility) control API ([mock-server.com](https://www.mock-server.com/))
---
## 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
- **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 `0` for 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.Case` gives you a fresh server per test and `http_server` / `http_endpoint` in 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 correct `Content-Type` for `body.type: JSON` (see [MockServer compatibility](#mockserver-compatibility)).
---
## MockServer compatibility
HttpDouble exposes an HTTP **control plane** on the **same port** as the mock traffic. Clients that already speak [MockServer](https://www.mock-server.com/)’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](https://www.mock-server.com/)
- **MockServer (source):** [github.com/mock-server/mockserver](https://github.com/mock-server/mockserver)
### Supported control endpoints
| 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.
### Registering expectations (`PUT /expectation`)
Body shape (JSON) follows MockServer: `httpRequest`, `httpResponse` or `httpError`, optional `times` (`unlimited`, `remainingTimes`).
```json
{
"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"` with `json` as a **string** — response is sent with `Content-Type: application/json` (since 1.0.1).
- `body.type: "STRING"` with `string` — plain text; optional `contentType`.
- `httpError.dropConnection: true` — connection drop (`:close`), no HTTP response body.
- `httpResponse.delay` — optional delay before the response (MockServer-style `timeUnit` / `value`).
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`)
```json
{
"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:
```bash
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:
```json
{
"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.
### Reset vs Elixir `reset!/1`
| 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** |
---
## Installation
Add HttpDouble as a dependency **only in test** (it is not for production).
```elixir
{:http_double, "~> 1.0.2", only: :test}
```
---
## Quick start
### 1. Start a server with a static route
```elixir
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)
```
### 2. Use the ExUnit helper (recommended)
```elixir
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
```elixir
# 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 for `endpoint/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.
### Getting the endpoint URL
```elixir
%{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
```elixir
{: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)
```elixir
# 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
```elixir
# 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)
```elixir
{:ok, _rule} =
HttpDouble.expect(server, %{method: :post, path: "/events"}, %{status: 202})
# Later: assert with HttpDouble.calls(server)
```
### Asserting on received requests (call history)
```elixir
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
```elixir
{: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):
```elixir
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:
```elixir
# 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](#clearing-expectations-put-clear)).
### 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:
```elixir
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
| 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](https://hexdocs.pm/http_double) or run `mix docs` locally and open `doc/index.html`.
---
## Modes
- **`: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.
---
## Architecture (overview)
- **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_server` and `http_endpoint`.
---
## Limitations
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-Length` supported; 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.
## Repository and license
- **Source:** [github.com/aszymanskiit/http_double](https://github.com/aszymanskiit/http_double)
- **License:** MIT – see [LICENSE](LICENSE).
You can use, copy, modify, merge, publish, distribute, sublicense, and sell copies of the software under the terms of the MIT License.