Owner-isolated HTTP fixtures for HTTP Family, with real Promise and acknowledged
Stream contracts. Add {:http_test, "~> 1.0", only: :test} to your dependencies
(use the exact prerelease version while testing release candidates).
setup do
HTTP.Test.verify_on_exit!()
end
test "loads JSON" do
HTTP.Test.expect(:service, fn request ->
assert request.url.path == "/items"
HTTP.Test.json(%{items: [1, 2]})
end)
response = HTTP.fetch("https://service.test/items", adapter: {HTTP.Test, :service})
|> HTTP.Promise.await()
assert HTTP.Response.json(response) == {:ok, %{"items" => [1, 2]}}
endstub(name, callback) repeats a fixture; expect(name, count, callback) claims
exactly count calls, atomically and in registration order. The count defaults
to one. A name cannot mix stub and expectation modes. Consumed expectations never
fall back to a stub. A claimed call remains consumed on cancellation.
Names are local to their registering owner. Concurrent tests can reuse the same
name. Tasks inherit the caller chain. For an independent GenServer, register
HTTP.Test.allow(name, owner_pid, server_pid) before it makes a request. Prepare
captures this authority in the original Fetch caller. Missing fixtures,
unauthorized callers and excess calls fail without network fallback. Explicit
production pool and transport_scope options are rejected by this adapter.
verify!() freezes claims, settles resources and verifies counts and callback
failures. Its final verification result remains available to the registered exit
verifier. verify_on_exit!() retains an opaque verification token after the test
process exits, performs the same finite teardown, and removes all records even
when verification raises. Without an exit verifier, owner death cancels resources
and removes records automatically. Callback assertions, exceptions and abnormal
exits are recorded with their cause and stacktrace; ignoring or catching a Promise
exit cannot hide the fixture failure. An external untrappable kill retains its
exit reason (there is no exception stacktrace for that signal).
Responses and errors
json/2, text/2, html/2, status/1, response/3 and headers/2 construct
ordinary HTTP.Response values. JSON helpers emit encoded bytes. Ordered,
duplicate headers are retained. transport_error(reason) intentionally returns
{:error, reason}; Fetch applies its raw or structured error mode once.
The adapter simulates one request attempt. Default options work for nonredirect
responses. A redirect candidate has status 301/302/303/307/308 and a binary
Location. redirect: :manual exposes it, :error returns {:error, :redirect},
and :follow returns {:error, {:unsupported_test_policy, :redirect_follow}}
before exposing any response body. It does not claim a second fixture. A 302
without Location or a 304 is ordinary. Compression, wire encoding, retries,
protocol negotiation and redirect chains are not simulated.
Streams, gates and cleanup
Create streams inside callbacks:
HTTP.Test.expect(:download, fn _ ->
HTTP.Test.stream(["one", {:gate, test_pid, :next}, "two"],
trailers: [{"x-checksum", "value"}], observer: test_pid)
end)The body and stream alias contain the same real HTTP.Stream PID. Each chunk
waits for consumer ACK before producer progress. Gates send
{:http_test_gate, tag, producer, ref} and wait for {:http_test_release, ref}.
{:error, reason} produces a stream error after preceding chunks are acknowledged.
Trailers preserve order and arrive immediately before stream end. The optional
observer receives {:http_test_stream, stream, producer}. Each chunk and gate has
a finite timeout: (default 5 seconds). gate(observer, tag, callback) also gates
response headers and cooperates with cancellation.
The adapter retains actual workers, upload streams, response streams and producers. AbortController stays attached to their guardian after the Promise worker exits. The finite request timeout also bounds simulated resource lifetime. Owner teardown waits at most one second for cleanup; noncooperating callbacks receive abort, then are forcibly terminated after 100 ms. An unexplained or forced request-worker death can truthfully leave public completion unconfirmed.
A Promise wait timeout does not cancel. Retain the original
HTTP.Promise.completion(promise) before chaining. Manual/error modes use the
actual lifecycle tracker; default follow retains unsupported completion, even
though owner teardown still settles its resources. Successful simulated cleanup
proves these processes ended; it provides no evidence about real sockets, TLS or
pool capacity.
Optional Plug bridge
Plug is optional. Core HTTP.Test compiles and works without Plug installed.
Calling HTTP.Test.plug/3 without it raises a clear error. Install Plug explicitly
to use a buffered bridge:
HTTP.Test.stub(:service, fn request ->
HTTP.Test.plug(request, {MyPlug, []}, body_limit: 1_048_576)
end)A one-argument conn function is also supported. The finite positive body limit (default 1 MiB) applies separately to request and response bytes. Stream uploads and chunked Plug responses are rejected; use stream fixtures for streaming. The bridge buffers Plug's response and does not impose a memory bound on arbitrary code executed inside the Plug itself.