lostbean

lostbean

Hello Elixir community! :wave:

I’m excited to announce the first release of ReqCassette, a VCR-style record-and-replay library specifically designed for Req.

What is ReqCassette?

ReqCassette captures HTTP responses to JSON files (“cassettes”) and replays them in subsequent test runs, making your tests:

  • Faster - No network calls after initial recording
  • Deterministic - Same response every time
  • Offline-capable - Work without internet once cassettes are recorded
  • Cost-effective - Perfect for testing paid APIs (like LLM services!)

Why build this

ReqCassette leverages Req’s native testing infrastructure instead of global mocking, making it process-isolated and fully compatible with ExUnit’s async testing.

Quick Example

defmodule MyApp.APITest do
  use ExUnit.Case, async: true

  test "fetches user data" do
    # First run: records to cassette
    # Subsequent runs: instant replay!
    response = Req.get!(
      "https://api.example.com/users/1",
      plug: {ReqCassette.Plug, %{cassette_dir: "test/cassettes"}}
    )

    assert response.status == 200
    assert response.body["name"] == "Alice"
  end
end

Created with ReqLLM in mind

One of my favorite use cases is testing LLM applications with
ReqLLM:

{:ok, response} = ReqLLM.generate_text(
  "anthropic:claude-sonnet-4-20250514",
  "Explain recursion",
  max_tokens: 100,
  req_http_options: [
    plug: {ReqCassette.Plug, %{cassette_dir: "test/cassettes"}}
  ]
)

First call costs money and it’s slow, but every subsequent test is fast and
FREE - the response is replayed from the cassette! This makes testing with LLMs
much more practical.

How It Works

ReqCassette uses Req’s :plug option to intercept requests:

  1. First request → Forwards to real server → Saves response to JSON →
    Returns response
  2. Subsequent requests → Loads JSON cassette → Returns saved response

Cassettes are matched by HTTP method, path, query string, and request body,
creating a unique MD5 hash for the filename.

What’s Next?

Some ideas for future versions:

  • Additional recording modes (:once, :none, :all)
  • Custom cassette naming
  • Header-based matching
  • Cassette expiration/TTL
  • Request filtering/sanitization helpers

Feedback Welcome!

This is the first release, and I’d love to hear your thoughts:

  • Are there features you’d like to see?
  • Any issues or bugs to report?

Thanks for reading, and happy testing! :test_tube:

Resources

https://github.com/lostbean/req_cassette


Note: Special thanks to the Req maintainers for building such a fantastic HTTP
client with great testing support, and to the ReqLLM maintainers for creating a
promising and foundational library that makes Elixir shine in the new LLM
space!

Showing Posts 1 to 10

lostbean

lostbean OP

ReqCassette v0.2.0 Update :clapper_board:

Quick update on ReqCassette! v0.2.0 is now available with some nice
improvements:

What’s New:

  • :sparkles: with_cassette/3 function API for cleaner test code
  • :memo: Human-readable cassette filenames (github_user.json instead of
    a1b2c3d4.json)
  • :package: Multiple interactions per cassette file
  • :level_slider: Four recording modes: :replay, :record, :record_missing, :bypass
  • :locked: Sensitive data filtering (headers, regex-based redaction)
  • :bullseye: Configurable request matching
  • :artist_palette: Pretty-printed JSON with native JSON objects (40% smaller)

Example:

import ReqCassette

test "API integration" do
  with_cassette "github_user", [mode: :replay], fn plug ->
    response = Req.get!("https://api.github.com/users/wojtekmach", plug: plug)
    assert response.status == 200
  end
end

First run records, subsequent runs replay instantly (no network, async-safe).

Links:

:warning: Breaking changes from v0.1 - see migration guide.

mikehostetler

mikehostetler

Nice work! Thanks for putting this together!!!

lostbean

lostbean OP

ReqCassette v0.3.0 – Safer Recording & Better Filtering

What’s New

:shield: Safer :record mode (breaking change)

  • Fixed dangerous behavior where multi-request tests would lose interactions
  • Simplified API: 4 recording modes → 3
  • Migration: just replace :record_missing with :record

:locked: Better filtering

Quick Example

with_cassette "api_test",
  [filter_request_headers: ["authorization"]],
  fn plug ->
    Req.get!("https://api.example.com", plug: plug)
  end
tmbb

tmbb

I am using ReqCassette to test a wrapper I’ve written for the Google Gemini API.I am not very happy with the wrappers I’ve found, so I’ve written my own - actually most of the code and the module docs are automatically generated from the JSON API spec.

One thing I want to be able to test is polling the same URL with the same query params to see if the batch has completed. This is clearly a stateful interaction. Even though this is a bit of a dumb thing to test, it’s actually really useful to be able to replay this interaction so that I can test/prototype new ways of displaying progress (progress bar, spinner, whatever).

Apparently, ReqCassette will not store the sequence of requests, but instead will save the request/response pair once, and will continue to return that result forever (I got this idea from inspecting the cassettes, not from inspecting the code).

Is this interpretation right? Is there any way of using ReqCassette for propertly stateful interactions?

lostbean

lostbean OP

ReqCassette v0.4.0 - Templating & Better Debugging

Quick update on ReqCassette since 0.3.1

What’s New

Templating - One cassette can now handle multiple requests with different dynamic values. Great for APIs that return unique IDs, timestamps, or when testing with varying inputs.

with_cassette "product_lookup",
  [template: [patterns: [sku: ~r/\d{4}-\d{4}/]]],
  fn plug ->
    # First run records with SKU 1234-5678
    # Future runs work with ANY SKU - cassette substitutes automatically
    Req.get!("https://api.example.com/products/9999-0000", plug: plug)
  end

LLM Presets - Built-in patterns for Anthropic and OpenAI APIs. No more cassette mismatches from msg_* or chatcmpl-* IDs:

template: [preset: :anthropic]  # handles msg_*, toolu_*, req_*
template: [preset: :openai]     # handles chatcmpl-*, call_*
template: [preset: :llm]        # all providers

Mismatch Diagnostics - When replay fails, you now get detailed info showing exactly which fields didn’t match:

🔴 :uri NO match
🟢 :method match

🔬 :uri details
Record 1:
stored: "https://api.example.com/v1/users"
value:  "https://api.example.com/v2/users"

Debug Tools - New mix req_cassette.inspect task to inspect cassette contents, and debug: true option for template troubleshooting.

Links

lostbean

lostbean OP

Thanks for giving it a try @tmbb!

I have been able to test a multi-turn conversation with LLM APIs, where there is a chain of messages one after another that runs, and the cassette can store the sequence.

It’s not completely clear to me about what you mean by stateful interaction, but maybe the feature that I just released about templates could be useful here. Where we can parametrize the recorded cassettes, so we can pattern match against that and update the response.

Again, the use cases I have been using are related to LLMs. But in my case, I have some information on the input request that changes over time, but that’s predictable, so I can create a template and have it respond accordingly.

Let me know if that goes in the direction that helps you in your use case. I am definitely open to exploring ways to make the library more powerful.

tmbb

tmbb

In pseudocode, I am doing the following:

def wait(batch, poll_interval) do
  # Note the following URL, query params, etc are all deterministic;
  # The response may be equal the first couple times, but eventually
  # it will change when some responses in the batch are processed
  new_batch = update_state(batch, Req.get("google_api.com/#{batch.name}"))
  if new_batch.done do
    new_batch
  else
    # Wait a bit more
    :timer.sleep(poll_interval)
    wait(new_batch, poll_interval)
  end
end

When I call this with a cassette (I have my custom logic to get the plug into the request from the Process dictionary, assume that part works), it loops forever because apparently it always sees the same response in which the batch is not done.

lostbean

lostbean OP

ReqCassette v0.5.0 - Sequential Matching for Stateful API Testing

What’s New in v0.5.0

Sequential Matching

By default, ReqCassette uses first-match: the same request always returns the
same response. This works for most tests, but not for polling scenarios
where identical requests should return different responses over time.

Now you can enable sequential matching:

# Polling API that returns different states
with_cassette "job_polling", [sequential: true], fn plug ->
  Req.get!("/job/status", plug: plug)  # -> {"status": "pending"}
  Req.get!("/job/status", plug: plug)  # -> {"status": "running"}
  Req.get!("/job/status", plug: plug)  # -> {"status": "completed"}
end

Requests match interactions in order (request 1 → interaction 0, request 2 →
interaction 1, etc.).

Cross-Process Support

If you’re making HTTP requests from spawned processes (Task.async,
GenServer, etc.) with sequential matching, use a shared session:

session = ReqCassette.start_shared_session()
try do
  with_cassette "parallel_test", [session: session, sequential: true], fn plug ->
    tasks = for i <- 1..3 do
      Task.async(fn -> Req.get!("/item/#{i}", plug: plug) end)
    end
    Task.await_many(tasks)
  end
after
  ReqCassette.end_shared_session(session)
end

Templates Auto-Enable Sequential

If you’re using templating (template: [...]), sequential matching is now
automatically enabled - no need to add sequential: true.

Backward Compatibility

Default behavior is unchanged. Existing tests work without modification.

Links

Feedback welcome!

lostbean

lostbean OP

Ah! I got it and I just released 0.5.0 adds exactly what I think you need - sequential matching.

Enable it with sequential: true:

with_cassette "batch_polling", [sequential: true], fn plug ->
  # Each call to the same URL now returns the NEXT recorded response
  wait(batch, poll_interval, plug)
end

When recording, each request hits the real API and stores a new interaction.
When replaying, requests match interactions in order (request 1 →
interaction 0, request 2 → interaction 1, etc.) instead of first-match.

Your cassette will look something like:

{
  "interactions": [
    {"request": {...}, "response": {"status": "RUNNING", "progress": 0.2}},
    {"request": {...}, "response": {"status": "RUNNING", "progress": 0.5}},
    {"request": {...}, "response": {"status": "RUNNING", "progress": 0.8}},
    {"request": {...}, "response": {"status": "SUCCEEDED", "done": true}}
  ]
}

On replay, each poll gets the next response in sequence until it sees
done: true.

One caveat: If your polling code runs in spawned processes (Task.async,
GenServer), you’ll need a shared session:

session = ReqCassette.start_shared_session()
try do
  with_cassette "batch_polling", [session: session, sequential: true], fn plug ->
    wait(batch, poll_interval, plug)
  end
after
  ReqCassette.end_shared_session(session)
end

But if everything runs in a single process (which sounds like your case), just
sequential: true should work.

Let know how it goes!

sergio

sergio

Thank you this is exactly what I was looking for and it works really well! :heart:

Where Next? Top

Trending in Announcing Top

wojtekmach
Hey everyone! Req is an HTTP client for Elixir that I’ve been working on for quite some time. There is already a lot of HTTP clients out...
New
handnot2
Samly can be used to enable SAML 2.0 Single Sign On in a Plug/Phoenix application. This library uses Erlang esaml to provide plug enabl...
New
woylie
Flop is an Elixir library that applies filtering, ordering and pagination parameters to your Ecto queries. offset-based pagination with...
New
MRdotB
I needed to reuse React components from my Chrome extension in my Phoenix/LiveView backend. I noticed that for Svelte/Vue, there are live...
New
garrison
Hobbes is a low-level distributed database for the Elixir programming language. Hobbes provides a simple, safe, and scalable storage lay...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
fuelen
Hi all! I want to present a small library which provides a mix task for generating an Entity-Relationship Diagram for Ecto schemas. You...
New

Other Trending Topics Top

mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
mudasobwa
I am seeing a lot of aplications of Argumentum ad Vericundiam in software discussions. They do link some piece of writing and point us to...
New
AstonJ
This showed up on my feed.. anyone heard of it? Just hype? Ox Alpha is a reasoning model designed for coding, sustained ag...
New
sergio
It’s not that it’s vocabulary is too advanced. It’s something worse. I get lost trying to follow even a paragraph written by Claude. It’...
New
sorenone
Today we’re releasing Oban for Python. Not an Oban client in Python. Not a pythonx wrapper embedded in Elixir. Nope, it’s a fully operati...
New
akoutmos
@hugobarauna, Dr. Dimitrios Koutmos (my brother) and I (Alex Koutmos) have been hard at work on writing a book on how you can use Elixir ...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews