README
Copy MarkdownMoxinet
HTTP mocking server for Elixir that supports parallel testing — like mox, but at the HTTP layer.
HexDocs: https://hexdocs.pm/moxinet
Installation
Add moxinet to your list of dependencies in mix.exs:
def deps do
[
{:moxinet, "~> 0.8.0", only: :test}
]
endGetting started
1. Define a mock server
The mock server is a Plug router that forwards requests to mock modules:
# test/support/mock_server.ex
defmodule MyApp.MockServer do
use Moxinet.Server
forward("/github", to: GithubMock)
end2. Create a mock module
# test/support/mock_servers/github_mock.ex
defmodule GithubMock do
use Moxinet.Mock
end3. Start Moxinet in your test helper
Moxinet must be started before ExUnit.start():
# test/test_helper.exs
{:ok, _} = Moxinet.start(port: 4040, router: MyApp.MockServer)
ExUnit.start()4. Configure your API urls
Point API urls at the mock server in the test environment:
# config/config.exs
config :my_app, GithubAPI,
url: "https://api.github.com"
# config/test.exs
config :my_app, GithubAPI,
url: "http://localhost:4040/github"5. Configure the req adapter (recommended)
With req, configure the adapter in test.exs. This automatically injects the x-moxinet-ref header into all requests — no manual header management needed:
# config/test.exs
config :req, default_options: [
adapter: Moxinet.Adapters.ReqTestAdapter
]6. Write tests
Use expect/4 to define how your mocks should respond:
alias Moxinet.Response
describe "create_pr/1" do
test "creates a pull request" do
GithubMock.expect(:post, "/pull-requests/123", fn _payload ->
%Response{status: 202, body: %{id: "pull-request-id"}, headers: [{"X-Rate-Limit", 10}]}
end)
assert {:ok,
%{
status: 202,
body: %{"id" => "pull-request-id"},
headers: [
{"X-Rate-Limit", 10},
{"Content-Type", "application/json"}
]
}
} = GithubAPI.create_pr(title: "My PR")
end
endCore concepts
Moxinet.Response
Every expect callback must return a %Moxinet.Response{} struct:
%Moxinet.Response{
status: 200, # required, integer 100-600
body: %{key: "value"}, # map, list, or binary (maps/lists are JSON-encoded)
headers: [{"X-Rate-Limit", "100"}] # optional response headers
}The Content-Type: application/json header is added automatically when the body is a map or list.
expect/4 options
Pass options as the fifth argument:
times:— how many times the expectation can be matched (default:1)pid:— the owning pid (default:self())
GithubMock.expect(:get, "/events", fn _body ->
%Moxinet.Response{status: 200, body: []}
end, times: 3)Callbacks can be 1-arity (receives the request body) or 2-arity (receives the request body and headers).
allow/2
$callers propagation covers Task and most OTP processes automatically. For plain spawn/1, explicitly grant access:
test "spawned process uses parent mocks" do
parent = self()
GithubMock.expect(:get, "/users", fn _ ->
%Moxinet.Response{status: 200, body: []}
end)
spawn(fn ->
Moxinet.allow(parent, self())
MyHTTPClient.get("/users")
end)
endverify_usage!
Unused expectations raise Moxinet.UnusedExpectationsError at the end of each test. This is checked automatically via an on_exit callback registered by expect/4.
To verify explicitly:
setup :verify_usage!Error reference
| Error | Cause |
|---|---|
Moxinet.MissingMockError | No expectation registered for that pid/method/path |
Moxinet.ExceededUsageLimitError | Expectation called more times than its times: limit |
Moxinet.InvalidReferenceError | x-moxinet-ref header contained an unrecognised value |
Moxinet.UnusedExpectationsError | Test ended with expectations that were never called |
Using non-req HTTP clients
Without req, the x-moxinet-ref header must be added manually. Without it, Moxinet cannot match incoming requests to test processes.
Use Moxinet.build_mock_header/0 to get the header tuple. Only include it in the test environment:
defmodule GithubAPI do
def client do
Req.new([
# ...
])
|> add_moxinet_header()
end
defmacrop add_moxinet_header(req) do
if Mix.env() == :test do
quote do
{header_name, header_value} = Moxinet.build_mock_header()
Req.Request.put_new_header(unquote(req), header_name, header_value)
end
else
quote do
unquote(req)
end
end
end
endStatic fallbacks and plug composition
Mock modules are Plugs — extend them like any other.
Define static routes alongside dynamic expectations. Static routes match after dynamic expectations, so use them for responses that never vary across tests:
defmodule GithubMock do
use Moxinet.Mock
get "/pull-requests/closed" do
send_resp(conn, 200, Jason.encode!([%{id: "1", closed: true}]))
end
endCompose with other plugs for shared verification logic:
defmodule GithubMock do
use Moxinet.Mock
import Plug.BasicAuth
plug :basic_auth, username: "user", password: "s3cr3t"
endWhy not mox?
When testing external HTTP calls, mox guides you towards replacing the entire HTTP layer. A common pattern:
defmodule GithubAPI do
defmodule HTTPBehaviour do
@callback post(String.t(), Keyword.t()) :: {:ok, Map.t()} | {:error, :atom}
end
defmodule HTTP do
@behaviour GithubAPI.HTTPBehaviour
def post(url, opts) do
# Perform HTTP request
end
end
def create_pr(attrs) do
impl().post("/pull-requests", body: attrs)
end
defp impl, do: Application.get_env(:github_api_http_module, HTTP)
endThis works, but has drawbacks:
- The
HTTPmodule remains untested — the test suite never exercises it - HTTP client libraries (like Tesla) handle headers, authentication, and JSON encoding. Custom encoding logic can hide bugs — a
@derive {Jason, only: [...]}can cause a production bug that all tests miss because they verify data sent to the HTTP layer, not the wire
Moxinet fills those gaps while reducing the need for behaviours and mocks.
How it works
Moxinet works like mox, but for HTTP requests.
The test pid is registered in the mock registry. When a request arrives, the mock looks up the pid to find the matching expectation.
flowchart TD
TP[Test pid] --> MR[Mock registry]
GMS <--> MR
TP --> API[Github API]
API -.HTTP request.-> MS[Mock server]
MS --> GMS[Github Mock]
GMS -.HTTP response.-> API