Skip to content

lostbean/req_cassette

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

33 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

ReqCassette

Hex.pm Hex Docs GitHub CI License: MIT

⚠️ 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!

Features

  • 🎬 Record & Replay - Capture real HTTP responses and replay them instantly
  • ⚑ Async-Safe - Works with async: true in 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_cassette for 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.)

Quick Start

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
end

First run: Records to test/cassettes/github_user.json Subsequent runs: Replays instantly from cassette (no network!)

Installation

Add to your mix.exs:

def deps do
  [
    {:req, "~> 0.5.15"},
    {:req_cassette, "~> 0.2.0"}
  ]
end

Usage

Basic Usage with with_cassette

import 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

Recording Modes

Quick Reference

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

Examples

# :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)
end

Multiple Requests Per Cassette

The :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 βœ…

Best Practices

  1. Use :record by default - Safe for all test types (single or multi-request)
  2. Use :replay in CI - Ensures tests don't make unexpected API calls
  3. Delete cassettes to re-record - Remove the cassette file to force a fresh recording

Mismatch Diagnostics

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.

Sensitive Data Filtering

⚠️ Critical for LLM APIs: Always filter authorization headers to prevent API keys from being saved to cassettes.

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.

Templating

Parameterized cassettes for testing APIs with dynamic values. One cassette can handle multiple requests with different IDs, timestamps, or other varying data.

Quick Example

# 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

How It Works

  1. Extract dynamic values using regex patterns (1234-5678)
  2. Template request/response with markers ({{sku.0}})
  3. Match on structure, not values
  4. Substitute new values during replay

Perfect For

  • 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

Common Patterns

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]+/
  ]
]

LLM Example

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.

Custom Request Matching

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

With Helper Functions

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

Usage with ReqLLM

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

First run: Costs money (real API call) Subsequent runs: FREE (replays from cassette)

See docs/REQ_LLM_INTEGRATION.md for detailed ReqLLM integration guide.

Cassette Format

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

Body Types

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

Configuration Options

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

Why ReqCassette over ExVCR?

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 ⚠️ Manual
Recording modes βœ… 3 modes ⚠️ Limited
Maintenance Low High

Development

Quick Commands

# Development workflow
mix precommit  # Format, check, test (run before commit)
mix ci         # CI checks (read-only format check)

Testing

# 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

Documentation

Guides

Reference

Example Test

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
end

License

This project is licensed under the MIT License - see the LICENSE file for details.

Contributing

Contributions welcome! Please open an issue or PR.

See ROADMAP.md for planned features and development priorities.

About

VCR-style record-and-replay library for Req HTTP client

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages