β οΈ 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
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
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.
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- Migration Guide - Upgrading from v0.1 to v0.2
- ROADMAP.md - Development roadmap and v0.2 features
- DESIGN_SPEC.md - Complete design specification
- REQ_LLM_INTEGRATION.md - ReqLLM integration guide
- 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.