Passby
A 100% Elixir, 0-dependency mock HTTP server designed for testing HTTP clients and integrations.
Passby is a lightweight, drop-in replacement for Bypass. It provides the exact same API and semantics without dragging in plug, plug_cowboy, cowboy, cowlib, ranch, or cowboy_telemetry.
Features
- 0 Runtime Dependencies: Built entirely on standard Erlang/OTP (
:gen_tcp,:inet,:erlang.decode_packet) and standard Elixir. - Drop-in Bypass API: Identical function signatures (
open/1,expect/2,4,expect_once/2,4,stub/4,pass/1,down/1,up/1). - Plug.Conn Compatibility:
Passby.Connimplements the same fields and helper functions (resp/3,send_resp/1,get_req_header/2,put_resp_header/3,fetch_query_params/1). - Params Parsing:
conn.query_paramsandconn.paramsare decoded exactly likeBypass/Plugdo, including bracket notation (filter[name]=Manuelbecomes%{"filter" => %{"name" => "Manuel"}}) and lists (tags[]=a&tags[]=b). - Route Patterns: paths accept
:paramsegments (/users/:id); captured values land inconn.path_paramsand are merged intoconn.params, just likeBypass. - Concurrent & Isolated: Each test can spin up its own instance on an ephemeral dynamic port.
- Outage Simulation: Easily simulate network disconnects and connection-refused errors with
Passby.down/1andPassby.up/1.
Installation
Add passby to your mix.exs dependencies for the test environment:
def deps do
[
{:passby, "~> 0.2.0", only: :test}
]
end
Quick Start
defmodule MyClientTest do
use ExUnit.Case, async: true
setup do
bypass = Passby.open()
{:ok, bypass: bypass}
end
test "fetches user profile successfully", %{bypass: bypass} do
Passby.expect_once(bypass, "GET", "/api/users/42", fn conn ->
conn
|> Passby.put_resp_header("content-type", "application/json")
|> Passby.resp(200, ~s({"id": 42, "name": "Alice"}))
end)
assert {:ok, %{"name" => "Alice"}} = MyClient.get_user("#{bypass.url}/api/users/42")
end
end
Migrating from Bypass
Migrating from Bypass to Passby requires zero changes to test logic:
- Replace
{:bypass, ...}with{:passby, "~> 0.2.0", only: :test}inmix.exs. - Replace
Bypass.calls withPassby.:
# Before (Bypass)
setup do
bypass = Bypass.open()
{:ok, bypass: bypass}
end
# After (Passby)
setup do
bypass = Passby.open()
{:ok, bypass: bypass}
end
Handlers receive a %Passby.Conn{} struct with query_params and params already
populated (bracket notation and lists decoded just like Bypass). Calling
Passby.Conn.fetch_query_params/1 again is a safe no-op, kept for Plug.Conn parity.
The struct works seamlessly with either Passby.Conn / Passby functions or Plug.Conn if you have Plug in your project:
Passby.expect(bypass, "POST", "/messages", fn conn ->
# Using Passby helpers:
conn
|> Passby.put_resp_header("content-type", "application/json")
|> Passby.resp(201, ~s({"status": "created"}))
# Or using Plug.Conn if available in your project:
# Plug.Conn.resp(conn, 201, ~s({"status": "created"}))
end)
Compatibility scope
test/bypass_compat_test.exs ports every scenario from the Bypass test suite.
The following API is supported with identical semantics:
open/1, expect/2, expect/4, expect_once/2, expect_once/4, stub/4,
pass/1, down/1, up/1, :param route patterns, conn.params /
conn.query_params / conn.path_params / conn.port, and route redefinition
(last definition wins).
The following Bypass behaviour is not yet implemented in Passby
(test/bypass_compat_test.exs covers each of these, asserting the current
behaviour):
| Bypass | Passby (current) |
|---|---|
Automatic verification on test exit (expect raises if never called, expect_once raises if called twice) | No automatic verification; an unmet expectation is ignored and an unexpected request returns 500 |
verify_expectations!/1 and the :test_framework / ESpec integration | Not implemented |
expect/3 and expect/5 with an exact expected request count | Not implemented (expect is "one or more", expect_once is "at most once") |
pass/1 marks the in-flight request as arrived | pass/1 clears every expectation and stub |
down/1 blocks until in-flight handlers finish | down/1 closes the listening socket immediately |
Usage Patterns
1. Specific Request Expectations (expect/4 and expect_once/4)
# Matches only GET requests to /health
Passby.expect(bypass, "GET", "/health", fn conn ->
Passby.resp(conn, 200, "OK")
end)
# Consumed after the first request
Passby.expect_once(bypass, "POST", "/checkout", fn conn ->
assert conn.req_body =~ "item_123"
Passby.resp(conn, 200, ~s({"order_id": 999}))
end)
# Route patterns: `:param` segments are captured into conn.path_params / conn.params
Passby.expect(bypass, "GET", "/users/:id", fn conn ->
assert conn.path_params == %{"id" => "42"}
Passby.resp(conn, 200, ~s({"id": #{conn.params["id"]}}))
end)
2. General Fallback Stubs (stub/4)
Passby.stub(bypass, "GET", "/config", fn conn ->
Passby.resp(conn, 200, ~s({"env": "test"}))
end)
3. Simulating Outages and Downtime (down/1 and up/1)
test "handles server outages gracefully", %{bypass: bypass} do
Passby.down(bypass)
url = Passby.url(bypass, "/api")
assert {:error, :econnrefused} = MyClient.get(url)
Passby.up(bypass)
Passby.expect(bypass, "GET", "/api", fn conn ->
Passby.resp(conn, 200, "recovered")
end)
assert {:ok, "recovered"} = MyClient.get(url)
end
Quality & Compliance
Passby is fully tested, typed, and documented:
- 100% Doctor documentation & spec coverage
- Zero Dialyzer warnings
- Strict Credo style checks
90% test coverage
To run the complete check suite locally:
mix check
License
MIT License. Copyright (c) 2026 Altenwald Solutions, S.L.