β οΈ Upgrading from v0.1? See the Migration Guide for breaking changes and upgrade instructions.
A VCR-style record-and-replay library for Elixir's Req HTTP client. Record HTTP responses to "cassettes" and replay them in tests for fast, deterministic, offline-capable testing.
Perfect for testing applications that use external APIs, especially LLM APIs like Anthropic's Claude!
- π¬ Record & Replay - Capture real HTTP responses and replay them instantly
- β‘ Async-Safe - Works with
async: truein ExUnit (unlike ExVCR) - π Built on Req.Test - Uses Req's native testing infrastructure (no global mocking)
- π€ ReqLLM Integration - Perfect for testing LLM applications (save money on API calls!)
- π Human-Readable - Pretty-printed JSON cassettes with native JSON objects
- π― Simple API - Use
with_cassettefor clean, functional testing - π Sensitive Data Filtering - Built-in support for redacting secrets
- ποΈ Multiple Recording Modes - Flexible control over when to record/replay
- π¦ Multiple Interactions - Store many request/response pairs in one cassette
- π Templating - Parameterized cassettes for dynamic values (IDs, timestamps, etc.)
import ReqCassette
test "fetches user data" do
with_cassette "github_user", fn plug ->
response = Req.get!("https://api.github.com/users/wojtekmach", plug: plug)
assert response.status == 200
assert response.body["login"] == "wojtekmach"
end
endFirst run: Records to test/cassettes/github_user.json Subsequent runs:
Replays instantly from cassette (no network!)
Add to your mix.exs:
def deps do
[
{:req, "~> 0.5.15"},
{:req_cassette, "~> 0.2.0"}
]
endimport ReqCassette
test "API integration" do
with_cassette "my_api_call", fn plug ->
response = Req.get!("https://api.example.com/data", plug: plug)
assert response.status == 200
end
end| Mode | When to Use | Cassette Behavior |
|---|---|---|
:record |
Default - use for most tests | Records new interactions, replays existing |
:replay |
CI/CD, deterministic testing | Only replays, errors if cassette missing |
:bypass |
Debugging, temporary disable | Ignores cassettes, always hits network |
# :record (default) - Record if cassette/interaction missing, otherwise replay
with_cassette "api_call", fn plug ->
Req.get!("https://api.example.com/data", plug: plug)
end
# :replay - Only replay from cassette, error if missing (great for CI)
with_cassette "api_call", [mode: :replay], fn plug ->
Req.get!("https://api.example.com/data", plug: plug)
end
# :bypass - Ignore cassettes entirely, always use network
with_cassette "api_call", [mode: :bypass], fn plug ->
Req.get!("https://api.example.com/data", plug: plug)
end
# To re-record a cassette: delete it first, then run with :record
File.rm!("test/cassettes/api_call.json")
with_cassette "api_call", fn plug ->
Req.get!("https://api.example.com/data", plug: plug)
endThe :record mode safely handles tests with multiple HTTP requests:
# β
All interactions are saved
with_cassette "agent_conversation", fn plug ->
response1 = Req.post!(url, json: %{msg: "Hello"}, plug: plug)
response2 = Req.post!(url, json: %{msg: "How are you?"}, plug: plug)
response3 = Req.post!(url, json: %{msg: "Goodbye"}, plug: plug)
end
# Result: All 3 interactions saved β
- Use
:recordby default - Safe for all test types (single or multi-request) - Use
:replayin CI - Ensures tests don't make unexpected API calls - Delete cassettes to re-record - Remove the cassette file to force a fresh recording
When a request doesn't match any stored interaction, ReqCassette provides detailed diagnostics to help you identify the problem:
** (RuntimeError) ReqCassette: No matching interaction found in cassette test/cassettes/api.json
Request: POST /api/users
Matching on: [:method, :uri, :query, :headers, :body]
This cassette exists but doesn't contain a matching interaction.
Either add the interaction to the cassette or use mode: :record.
π’ :method match
π΄ :uri NO match
π’ :query match
π’ :headers match
π’ :body match
π¬ :uri details
Record 1:
stored: "https://api.example.com/api/v1/users"
value: "https://api.example.com/api/v2/users"
The diagnostics show:
- Summary - Which matchers matched (π’) and which didn't (π΄)
- Details - For mismatched fields, the stored vs incoming values for each record
This makes it easy to identify why a cassette isn't matching - whether it's a changed URL, different headers, modified request body, etc.
with_cassette "auth",
[
filter_request_headers: ["authorization", "x-api-key", "cookie"],
filter_response_headers: ["set-cookie"],
filter_sensitive_data: [
{~r/api_key=[\w-]+/, "api_key=<REDACTED>"},
{~r/"token":"[^"]+"/, ~s("token":"<REDACTED>")}
]
],
fn plug ->
Req.post!("https://api.example.com/login",
json: %{username: "user", password: "secret"},
plug: plug)
endπ See the Sensitive Data Filtering Guide for comprehensive documentation on protecting secrets, common patterns, and best practices.
Parameterized cassettes for testing APIs with dynamic values. One cassette can handle multiple requests with different IDs, timestamps, or other varying data.
# One cassette handles ALL product SKUs!
test "product lookup with any SKU" do
with_cassette "product_lookup",
[
template: [
patterns: [sku: ~r/\d{4}-\d{4}/]
]
],
fn plug ->
# First call: Records
response1 = Req.get!("https://api.example.com/products/1234-5678", plug: plug)
assert response1.body["sku"] == "1234-5678"
# Second call: Replays with DIFFERENT SKU!
response2 = Req.get!("https://api.example.com/products/9999-8888", plug: plug)
assert response2.body["sku"] == "9999-8888" # β
Substituted!
assert response2.body["name"] == "Widget" # β
Same static data
end
end- Extract dynamic values using regex patterns (
1234-5678) - Template request/response with markers (
{{sku.0}}) - Match on structure, not values
- Substitute new values during replay
- E-commerce APIs - Product SKUs, order IDs
- User management - User IDs, email addresses
- LLM APIs - Conversation IDs, timestamps, request IDs
- Pagination - Cursor tokens, page numbers
- Time-sensitive APIs - ISO timestamps, date ranges
template: [
patterns: [
# Product SKUs
sku: ~r/\d{4}-\d{4}/,
# Order IDs
order_id: ~r/ORD-\d+/,
# UUIDs
uuid: ~r/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i,
# Timestamps
timestamp: ~r/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/,
# Conversation IDs (LLM APIs)
conversation_id: ~r/conv_[a-zA-Z0-9]+/
]
]test "LLM chat with varying conversation IDs" do
with_cassette "llm_chat",
[
filter_request_headers: ["authorization"], # Security first!
template: [
patterns: [
conversation_id: ~r/conv_[a-zA-Z0-9]+/,
message_id: ~r/msg_[a-zA-Z0-9]+/
]
]
],
fn plug ->
# Different conversation IDs - same cassette!
{:ok, response} = ReqLLM.generate_text(
"anthropic:claude-sonnet-4-20250514",
"Explain recursion",
conversation_id: "conv_xyz789", # Works with any ID
req_http_options: [plug: plug]
)
assert response.choices[0].message.content =~ "function calls itself"
end
endπ See the Templating Guide for comprehensive documentation, advanced patterns, debugging tips, and best practices.
Control which requests match which cassette interactions:
# Match only on method and URI (ignore headers, query params, body)
with_cassette "flexible",
[match_requests_on: [:method, :uri]],
fn plug ->
Req.post!("https://api.example.com/data",
json: %{timestamp: DateTime.utc_now()},
plug: plug)
end
# Match on method, URI, and query params (but not body)
with_cassette "search",
[match_requests_on: [:method, :uri, :query]],
fn plug ->
Req.get!("https://api.example.com/search?q=elixir", plug: plug)
endPerfect for passing plug to reusable functions:
defmodule MyApp.API do
def fetch_user(id, opts \\ []) do
Req.get!("https://api.example.com/users/#{id}", plug: opts[:plug])
end
def create_user(data, opts \\ []) do
Req.post!("https://api.example.com/users", json: data, plug: opts[:plug])
end
end
test "user operations" do
with_cassette "user_workflow", fn plug ->
user = MyApp.API.fetch_user(1, plug: plug)
assert user.body["id"] == 1
new_user = MyApp.API.create_user(%{name: "Bob"}, plug: plug)
assert new_user.status == 201
end
endSave money on LLM API calls during testing:
import ReqCassette
test "LLM generation" do
with_cassette "claude_recursion", fn plug ->
{:ok, response} = ReqLLM.generate_text(
"anthropic:claude-sonnet-4-20250514",
"Explain recursion in one sentence",
max_tokens: 100,
req_http_options: [plug: plug]
)
assert response.choices[0].message.content =~ "function calls itself"
end
endFirst run: Costs money (real API call) Subsequent runs: FREE (replays from cassette)
See docs/REQ_LLM_INTEGRATION.md for detailed ReqLLM integration guide.
Cassettes are stored as pretty-printed JSON with native JSON objects:
{
"version": "1.0",
"interactions": [
{
"request": {
"method": "GET",
"uri": "https://api.example.com/users/1",
"query_string": "",
"headers": {
"accept": ["application/json"]
},
"body_type": "text",
"body": ""
},
"response": {
"status": 200,
"headers": {
"content-type": ["application/json"]
},
"body_type": "json",
"body_json": {
"id": 1,
"name": "Alice"
}
},
"recorded_at": "2025-10-16T12:00:00Z"
}
]
}ReqCassette automatically detects and handles three body types:
json- Stored as native JSON objects (pretty-printed, readable)text- Plain text (HTML, XML, CSV, etc.)blob- Binary data (images, PDFs) stored as base64
with_cassette "example",
[
cassette_dir: "test/cassettes", # Where to store cassettes
mode: :record, # Recording mode
match_requests_on: [:method, :uri, :body], # Request matching criteria
filter_sensitive_data: [ # Regex-based redaction
{~r/api_key=[\w-]+/, "api_key=<REDACTED>"}
],
filter_request_headers: ["authorization"], # Headers to remove from requests
filter_response_headers: ["set-cookie"], # Headers to remove from responses
before_record: fn interaction -> # Custom filtering callback
# Modify interaction before saving
interaction
end
],
fn plug ->
# Your code here
end| Feature | ReqCassette | ExVCR |
|---|---|---|
| Async-safe | β Yes | β No |
| HTTP client | Req only | hackney, finch, etc. |
| Implementation | Req.Test + Plug | :meck (global) |
| Pretty-printed cassettes | β Yes (native JSON objects) | β No (escaped strings) |
| Multiple interactions | β Yes (one file per test) | β No (one file per req) |
| Sensitive data filtering | β Built-in | |
| Recording modes | β 3 modes | |
| Maintenance | Low | High |
# Development workflow
mix precommit # Format, check, test (run before commit)
mix ci # CI checks (read-only format check)# Run all tests (82 tests)
mix test
# Run specific test suite
mix test test/req_cassette/with_cassette_test.exs
# Run demos
mix run examples/httpbin_demo.exs
ANTHROPIC_API_KEY=sk-... mix run examples/req_llm_demo.exs- Templating Guide - Parameterized cassettes for dynamic values
- Sensitive Data Filtering Guide - Protect API keys and secrets
- ReqLLM Integration Guide - Testing LLM applications
- Migration Guide - Upgrading from v0.1 to v0.2
- ROADMAP.md - Development roadmap and v0.2 features
- DESIGN_SPEC.md - Complete design specification
- DEVELOPMENT.md - Development guide
defmodule MyApp.APITest do
use ExUnit.Case, async: true
import ReqCassette
@cassette_dir "test/fixtures/cassettes"
test "fetches user data" do
with_cassette "github_user", [cassette_dir: @cassette_dir], fn plug ->
response = Req.get!("https://api.github.com/users/wojtekmach", plug: plug)
assert response.status == 200
assert response.body["login"] == "wojtekmach"
assert response.body["public_repos"] > 0
end
end
test "handles API errors gracefully" do
with_cassette "not_found", [cassette_dir: @cassette_dir], fn plug ->
response = Req.get!("https://api.github.com/users/nonexistent-user-xyz",
plug: plug,
retry: false
)
assert response.status == 404
end
end
endThis project is licensed under the MIT License - see the LICENSE file for details.
Contributions welcome! Please open an issue or PR.
See ROADMAP.md for planned features and development priorities.