Reqord (reqord v0.4.0)

View Source

VCR-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"}
  ]
end

Quick 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
end

3. 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 test

Modes

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
end

Manual 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
end

Working 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
end

POST/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"
end

How 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 test

Integration 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

matcher()

@type matcher() :: :method | :uri | :host | :path | :headers | :body | atom()

matcher_fun()

@type matcher_fun() :: (Plug.Conn.t(), map() -> boolean())

mode()

@type mode() :: :once | :new_episodes | :all | :none

Functions

allow(name, owner_pid, allowed_pid)

@spec allow(atom(), pid(), pid()) :: :ok

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

cleanup(cassette)

@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)

cleanup(cassette, mode)

@spec cleanup(String.t(), mode()) :: :ok

Cleans up cassette state for a given cassette with specific mode handling.

Parameters

  • cassette - The cassette name
  • mode - The VCR mode used during the test

clear_matchers()

@spec clear_matchers() :: :ok

Clears all registered custom matchers.

Useful for test cleanup.

install!(opts)

@spec install!(keyword()) :: :ok

Installs a VCR stub that handles cassette replay and recording.

Options

  • :name - Required. The name of the Req.Test stub 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]
)

register_matcher(name, fun)

@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]
)