Reqord (reqord v0.4.0)
View SourceVCR-style record/replay for HTTP when using Req, integrating with Req.Test.
Reqord allows you to record HTTP interactions to cassette files and replay them in your tests without requiring any application code changes.
Installation
Add reqord to your list of dependencies in mix.exs:
def deps do
[
{:req, "~> 0.5"},
{:reqord, "~> 0.1.0"}
]
endQuick Start
1. Configure Req to use Req.Test in your test environment
In config/test.exs:
config :my_app,
req_options: [plug: {Req.Test, MyApp.ReqStub}]2. Use Reqord.Case in your tests
defmodule MyApp.APITest do
use Reqord.Case
# Specify your Req.Test stub name
defp default_stub_name, do: MyApp.ReqStub
test "fetches user data" do
client = Req.new(Application.get_env(:my_app, :req_options, []))
{:ok, response} = Req.get(client, url: "https://api.example.com/users/123")
assert response.status == 200
assert response.body["name"] == "John Doe"
end
end3. Record cassettes on first run
# Record mode - hits live API and saves responses
REQORD=record API_TOKEN=xxx mix test
# Subsequent runs use replay mode (default) - no network calls
mix testModes
Control the VCR mode via the REQORD environment variable:
:replay(default) - Only replay from cassettes, raise on misses:record- Always forward to live network and record:auto- Replay if match found, otherwise record via live proxy
Examples
Basic Usage with Reqord.Case
defmodule MyApp.WeatherAPITest do
use Reqord.Case
defp default_stub_name, do: MyApp.ReqStub
# Creates cassette: test/support/cassettes/WeatherAPI/fetches_forecast.jsonl
test "fetches forecast" do
client = Req.new(plug: {Req.Test, MyApp.ReqStub})
{:ok, response} = Req.get(client, url: "https://api.weather.com/forecast")
assert response.status == 200
assert is_list(response.body["forecast"])
end
# Custom cassette name
@tag vcr: "weather/special_forecast"
test "with custom cassette" do
client = Req.new(plug: {Req.Test, MyApp.ReqStub})
{:ok, response} = Req.get(client, url: "https://api.weather.com/special")
assert response.status == 200
end
endManual Installation
For more control, you can install Reqord manually:
defmodule MyApp.CustomTest do
use ExUnit.Case
setup do
Req.Test.set_req_test_to_private()
Req.Test.set_req_test_from_context(%{async: true})
Reqord.install!(
name: MyApp.ReqStub,
cassette: "my_custom_cassette",
mode: :replay
)
:ok
end
test "custom setup" do
client = Req.new(plug: {Req.Test, MyApp.ReqStub})
{:ok, response} = Req.get(client, url: "https://api.example.com/data")
assert response.status == 200
end
endWorking with Spawned Processes
If your test spawns processes that make HTTP requests:
test "with spawned task" do
client = Req.new(plug: {Req.Test, MyApp.ReqStub})
task = Task.async(fn ->
Req.get(client, url: "https://api.example.com/data")
end)
# Allow the task's process to use the stub
Reqord.allow(MyApp.ReqStub, self(), task.pid)
{:ok, response} = Task.await(task)
assert response.status == 200
endPOST/PUT/PATCH Requests
Reqord distinguishes requests with different bodies:
test "creates users" do
client = Req.new(plug: {Req.Test, MyApp.ReqStub})
# These will create separate cassette entries
{:ok, resp1} = Req.post(client,
url: "https://api.example.com/users",
json: %{name: "Alice"}
)
{:ok, resp2} = Req.post(client,
url: "https://api.example.com/users",
json: %{name: "Bob"}
)
assert resp1.body["name"] == "Alice"
assert resp2.body["name"] == "Bob"
endHow It Works
Request Matching
Reqord matches requests using a deterministic key:
METHOD NORMALIZED_URL BODY_HASH- Method: HTTP method (GET, POST, etc.)
- Normalized URL: Query parameters sorted, auth params removed
- Body Hash: SHA-256 hash for POST/PUT/PATCH,
-for others
Automatic Redaction
Sensitive data is automatically redacted:
Headers (set to <REDACTED>):
authorization
Query parameters (set to <REDACTED>):
token,apikey,api_key
Volatile response headers (removed):
date,server,set-cookie,request-id,x-request-id,x-amzn-trace-id
Cassette Format
Cassettes are stored as JSONL files in test/support/cassettes/:
{"key":"GET https://api.example.com/users -","req":{...},"resp":{...}}
{"key":"POST https://api.example.com/users abc123...","req":{...},"resp":{...}}Workflow
# 1. Write tests using Reqord.Case
# 2. Record cassettes (hits live API)
REQORD=record API_TOKEN=xxx mix test
# 3. Commit cassettes to git
git add test/support/cassettes/
git commit -m "Add API cassettes"
# 4. Run tests in replay mode (no network calls)
mix test
# 5. Update cassettes when API changes
REQORD=record API_TOKEN=xxx mix testIntegration with Req.Test
Reqord works alongside your existing Req.Test stubs:
test "with mixed stubs" do
# Add a high-priority stub for specific URL
Req.Test.stub(MyApp.ReqStub, fn
%{request_path: "/special"} = conn ->
Req.Test.json(conn, %{special: true})
end)
client = Req.new(plug: {Req.Test, MyApp.ReqStub})
# This hits your stub
{:ok, resp1} = Req.get(client, url: "https://api.example.com/special")
assert resp1.body["special"] == true
# This falls through to VCR
{:ok, resp2} = Req.get(client, url: "https://api.example.com/other")
end
Summary
Functions
Allows a spawned process to use the VCR stub.
Cleans up cassette state for a given cassette.
Cleans up cassette state for a given cassette with specific mode handling.
Clears all registered custom matchers.
Installs a VCR stub that handles cassette replay and recording.
Registers a custom matcher function.
Types
@type matcher() :: :method | :uri | :host | :path | :headers | :body | atom()
@type matcher_fun() :: (Plug.Conn.t(), map() -> boolean())
@type mode() :: :once | :new_episodes | :all | :none
Functions
Allows a spawned process to use the VCR stub.
Examples
test "with spawned process" do
task = Task.async(fn ->
Reqord.allow(MyApp.ReqStub, self(), Task.async(fn -> ... end).pid)
# spawned process can now make requests
end)
Task.await(task)
end
@spec cleanup(String.t()) :: :ok
Cleans up cassette state for a given cassette.
This is useful for cleaning up global state after tests complete, especially when using :all mode with concurrent requests.
For :all mode, this function replaces the entire cassette with accumulated entries, ensuring safe atomic replacement.
Examples
on_exit(fn ->
Reqord.cleanup("my_cassette")
end)
Cleans up cassette state for a given cassette with specific mode handling.
Parameters
cassette- The cassette namemode- The VCR mode used during the test
@spec clear_matchers() :: :ok
Clears all registered custom matchers.
Useful for test cleanup.
@spec install!(keyword()) :: :ok
Installs a VCR stub that handles cassette replay and recording.
Options
:name- Required. The name of theReq.Teststub to use:cassette- Required. The cassette file name (without extension):mode- The VCR record mode. Defaults to:once:match_on- List of matchers to use. Defaults to[:method, :uri]
Record Modes
Reqord supports Ruby VCR-style record modes:
:once- Use existing cassette, raise on new requests (strict replay):new_episodes- Use existing cassette, record new requests (append mode):all- Always hit live network and re-record everything:none- Never record, never hit network (must have complete cassette, default)
Request Matching
Built-in matchers:
:method- Match HTTP method (GET, POST, etc.):uri- Match full normalized URI (default with :method):host- Match only the host:path- Match only the path (without query string):headers- Match request headers:body- Match request body content
You can also register custom matchers with register_matcher/2.
Examples
# Default matching (method + uri)
Reqord.install!(
name: MyApp.ReqStub,
cassette: "my_test",
mode: :once
)
# Match on method, path, and body (useful for APIs with changing query params)
Reqord.install!(
name: MyApp.ReqStub,
cassette: "my_test",
match_on: [:method, :path, :body]
)
# Match only on method and host (ignores path and query)
Reqord.install!(
name: MyApp.ReqStub,
cassette: "my_test",
match_on: [:method, :host]
)
# Use custom matcher
Reqord.register_matcher(:api_version, fn conn, entry ->
Plug.Conn.get_req_header(conn, "x-api-version") ==
[get_in(entry, ["req", "headers", "x-api-version"])]
end)
Reqord.install!(
name: MyApp.ReqStub,
cassette: "my_test",
match_on: [:method, :uri, :api_version]
)
@spec register_matcher(atom(), matcher_fun()) :: :ok
Registers a custom matcher function.
Custom matchers receive the incoming Plug.Conn and a cassette entry,
and return true if they match.
Examples
# Register a custom matcher that checks request ID header
Reqord.register_matcher(:request_id, fn conn, entry ->
req_id = Plug.Conn.get_req_header(conn, "x-request-id") |> List.first()
req_id == get_in(entry, ["req", "headers", "x-request-id"])
end)
# Use the custom matcher
Reqord.install!(
name: MyApp.ReqStub,
cassette: "my_test",
match_on: [:method, :uri, :request_id]
)