Gaoxy.Test
Real, isolated HTTP fixtures with atomic expectations and ordinary Plug handlers. Install the aligned package as a test-only dependency:
{:gaoxy_test, "~> 0.12.2", only: :test}
The application starts fixture supervision, but starts no listener until open/1.
Do not add runtime: false. The helper never starts ExUnit for its consumer.
test "fetches a resource" do
fixture = Gaoxy.Test.open()
Gaoxy.Test.expect_once(fixture, :get, "/resource", fn conn ->
assert conn.query_string == "format=text"
Plug.Conn.send_resp(conn, 200, "resource")
end)
# Exercise your HTTP client with Gaoxy.Test.url(fixture, "/resource?format=text").
# The per-test on_exit hook verifies expectations and closes the fixture.
end
Automatic mode belongs in a test body or per-test setup, not setup_all.
Shared fixtures require explicit manual ownership, verification and closure:
fixture = Gaoxy.Test.open(mode: :manual, profile: :https)
Gaoxy.Test.expect(fixture, :post, "/upload", fn conn ->
{:ok, body, conn} = Plug.Conn.read_body(conn)
Plug.Conn.send_resp(conn, 201, body)
end, times: 2)
info = Gaoxy.Test.info(fixture)
# Configure the client to trust info.tls.cacertfile; retain hostname verification.
# Exercise Gaoxy.Test.url(fixture, "/upload") twice.
:ok = Gaoxy.Test.verify_and_close!(fixture)
expect/5 requires at least one admission, times: n requires exactly n, and
stub/4 has no count obligation. Duplicate exact routes are rejected. Methods
normalize to uppercase and paths exclude queries. Reservation is atomic across
concurrent streams; callbacks execute outside the coordinator. Assertions,
exceptions, throws and exits remain verification failures even when a client
accepts the resulting error response. Ordinary Plug body reads stay lazy.
requests/1 returns a bounded journal with truncation accounting; journal eviction
does not discard failures or obligations. capture_body: bytes opts into bounded
body observation as the callback reads. await_request/3 accepts an exact
%{method: ..., path: ..., after: cursor} matcher and returns {:ok, entry} or
{:error, :timeout}. verify!/2 checks a snapshot without closing the fixture.
close/2 returns a final report; verify_and_close!/2 raises on failures.
Terminal options accept a finite positive timeout: in milliseconds.
Profiles and trust
| Profile | Actual wire protocol | Streaming/SSE | WebSocket helper |
|---|---|---|---|
:http1 (default) |
TCP HTTP/1.1 | Finite and persistent SSE | H1 |
:https |
TLS HTTP/1.1, ALPN http/1.1 |
Finite and persistent SSE | H1 over TLS |
:h2c |
TCP HTTP/2 prior knowledge | Finite and persistent SSE | Unsupported |
:http2 |
TLS HTTP/2, ALPN h2 |
Finite and persistent SSE | Unsupported |
:http3 |
Optional UDP/QUIC HTTP/3, ALPN h3 |
Finite and persistent SSE | Unsupported |
Profiles never fall back. Addresses are loopback and ports are OS-assigned.
info/1 exposes the actual bound endpoints, selected profile, readiness,
capabilities and TLS trust, plus opaque fixture identity. H3 uses an ordinary
https:// URL with its UDP endpoint; configure the client to require HTTP/3.
Explicit H3 requires an additional aligned gaoxy_http3 dependency. Ordinary
H1/H2 consumers need no H3/QUIC/ExSSL, proxy, standalone, HTTPFetch or WebSocket
client packages. An unavailable H3 dependency fails preflight before listener
allocation. Production consumers should use only: :test to exclude the helper.
TLS assets live in packaged priv/tls and resolve through the OTP application
directory. The dedicated test CA signs one leaf for DNS localhost and IP
127.0.0.1, with serverAuth purpose and approximately twenty-year validity
(through October 2046). The leaf private key is intentionally public test data;
the CA private key is not shipped. These assets are exclusively for test fixtures.
Use info.tls.cacertfile with normal peer and hostname verification. No runtime
certificate generation or machine trust-store installation is needed.
Streaming and observations
Gaoxy.Test.expect_once(fixture, :get, "/chunks", fn conn ->
Gaoxy.Test.Response.stream(conn, 200, ["one", "two"], headers: [{"x-test", "yes"}])
end)
Gaoxy.Test.expect_once(fixture, :get, "/events", fn conn ->
Gaoxy.Test.SSE.stream(conn, [%{event: "update", id: "7", data: "first\nsecond"}, "last"])
end)
Response.send/4 supplies optional headers; Response.redirect/3 supplies a
location and optional status. Finite streams accept a list of iodata chunks.
SSE text and %{data: text, event: name, id: id, retry: milliseconds} events
encode multi-line data correctly; only :data is required.
For a persistent SSE response, use SSE.open(conn, initial_events, timeout: 30_000).
It emits headers and initial events before establishing the session. The session
PID is the request process. SSE.send_event(pid, event) emits another event and
SSE.finish(pid) completes normally. Its total lifetime has a finite timeout.
Only successfully established sessions may be cancelled by fixture shutdown;
ordinary blocked callbacks and failures before establishment still fail.
The fixture owner receives event-driven observations:
{:gaoxy_test_event, fixture_id, request_id, event}
Use Gaoxy.Test.info(fixture).fixture_id to select the fixture. Events also appear
in its bounded request journal. event.type includes :stream_headers,
:stream_chunk, :stream_complete, :sse_event, :sse_established, and
:session_cancelled. Establishment events contain :session; SSE event
observations contain :event. Wait on these messages for deterministic barriers.
Cancellation and failure observations remain distinct from successful completion.
H1 WebSocket
Gaoxy.Test.expect_once(fixture, :get, "/socket", fn conn ->
Gaoxy.Test.WebSocket.upgrade(conn, echo: true)
end)
The helper implements the public WebSock callbacks through Plug's public upgrade
API. Echo is enabled by default; echo: false observes incoming messages without
echoing. WebSock wire options such as timeout, callback_timeout, close_timeout
and max_lifetime pass through the server's normal finite validation.
:websocket_established is emitted only from WebSock initialization after the
successful wire handshake. Incoming/outgoing observations are
:websocket_received/:websocket_sent, with :opcode and :payload.
WebSocket.send(session, :text | :binary, payload) queues an outgoing message;
its observation describes the handler push, not proof of client receipt.
WebSocket.close(session, 1000, "bye") starts a controlled close. Termination
emits :websocket_closed; abnormal termination records a session failure.
Extended CONNECT over H2/H3 is outside this helper's scope.
Ownership and public integration
Each fixture owns one public Gaoxy runtime. Normal close, owner exit and final
verification share bounded finalization. Successfully established persistent
sessions receive {:gaoxy_test_cancel, :fixture_close}; pre-establishment failures
and ordinary callbacks retain their obligations. The server independently monitors
the coordinator, and its runtime monitors its owner, so abrupt coordinator loss
cannot restart an empty fixture or leave its sockets running. Cleanup errors
remain errors. A finite retained report supports automatic ExUnit teardown and
immediate repeated manual close; expired or missing state fails verification.
The adapter uses Gaoxy.HTTP.service_spec/1, optional
Gaoxy.HTTP.HTTP3.service_spec/1, and public Gaoxy.info/2/Gaoxy.stop/2.
Gaoxy 0.8's declarative ListenerSet.plan/1 deliberately rejects PIDs and
references, while the dispatch bridge requires a per-fixture coordinator PID and
reference. The direct service APIs preserve that identity without a registry or
another listener abstraction. Independent raw/negative peers remain useful for
malformed framing and exact reset tests; this package does not replace them.
Local verification
From the umbrella root, after installing normal dependencies and independent
Python peers (h2 and aioquic):
GAOXY_QUIC_PYTHON="$PWD/.audit/h2peer/bin/python" MIX_ENV=test \
mix test apps/gaoxy_test/test/protocol_test.exs apps/gaoxy_test/test/helper_test.exs
The network tests require those peers and fail explicitly if unavailable. They exercise actual protocol negotiation, verified TLS, multiplexing, uploads, finite responses/SSE, persistent cancellation, WebSocket interaction, and socket cleanup with finite deadlines. Isolated staged/Hex consumers and production closure checks are separate integration gates.