Skip to content

lostbean/req_cassette

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

23 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

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

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.

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

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