This guide helps you upgrade from ReqCassette v0.4 to v0.5.
v0.5.0 adds sequential matching and cross-process session support. Sequential matching is opt-in (or automatically enabled with templates).
First-Match (Default) - Requests match the first interaction that matches the request criteria. Same request always returns same response. This is the same behavior as v0.4.
with_cassette "api_test", fn plug ->
Req.get!("/users/1", plug: plug) # → Alice
Req.get!("/users/2", plug: plug) # → Bob
Req.get!("/users/1", plug: plug) # → Alice (same as first call)
endWhen you need identical requests to return different responses, enable
sequential matching with sequential: true:
# Polling API that returns different states over time
with_cassette "polling_test", [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"}
endSequential matching is essential for:
- Identical requests expecting different responses (polling, state changes)
- Templated cassettes where multiple requests have the same structure
- Nested
with_cassettecalls using the same cassette name
Templates automatically enable sequential matching - no need to add
sequential: true when using template: [...].
If your tests make HTTP requests from spawned processes and need sequential matching, use shared sessions.
# Spawned processes don't share process dictionary state
with_cassette "parallel_test", [sequential: true], fn plug ->
tasks = for i <- 1..3 do
Task.async(fn ->
Req.post!("https://api.example.com", plug: plug, json: %{id: i})
end)
end
Task.await_many(tasks)
# Each task incorrectly starts from interaction 0!
end# Use shared sessions for cross-process sequential matching
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.post!("https://api.example.com", plug: plug, json: %{id: i})
end)
end
Task.await_many(tasks)
# Tasks correctly get interactions 0, 1, 2
end
after
ReqCassette.end_shared_session(session)
endEnable sequential matching for a cassette:
with_cassette "test", [sequential: true], fn plug ->
# Requests match interactions in order
endCreates a shared session for cross-process cassette matching. Returns a session
reference to pass to with_cassette/3.
session = ReqCassette.start_shared_session()Ends a shared session and cleans up resources. Always call this in an after
block.
ReqCassette.end_shared_session(session)Pass a shared session to with_cassette/3:
with_cassette "test", [session: session, sequential: true], fn plug ->
# All requests share session state across processes
endShorthand for cross-process support that auto-manages the session lifecycle:
# Equivalent to manually managing start_shared_session/end_shared_session
with_cassette "test", [shared: true], fn plug ->
tasks = for i <- 1..3 do
Task.async(fn -> Req.get!("https://api.example.com/#{i}", plug: plug) end)
end
Task.await_many(tasks)
endConvenience wrapper that handles the try/after boilerplate for shared sessions:
# Instead of:
session = ReqCassette.start_shared_session()
try do
with_cassette "test", [session: session, template: [preset: :common]], fn plug ->
# ...
end
after
ReqCassette.end_shared_session(session)
end
# You can write:
with_shared_cassette "test", [template: [preset: :common]], fn plug ->
# ...
endIf your tests use first-match behavior (different requests, or same request expecting same response), no changes are needed.
If you have tests where the same request should return different responses:
# Before: might have worked by accident with recording order
with_cassette "polling_test", fn plug ->
Req.get!("/status", plug: plug) # interaction 0
Req.get!("/status", plug: plug) # interaction 1
Req.get!("/status", plug: plug) # interaction 2
end
# After: explicitly enable sequential matching
with_cassette "polling_test", [sequential: true], fn plug ->
Req.get!("/status", plug: plug) # interaction 0
Req.get!("/status", plug: plug) # interaction 1
Req.get!("/status", plug: plug) # interaction 2
endIf you have tests with spawned processes AND need sequential matching:
Before:
test "parallel API calls" do
with_cassette "parallel_test", fn plug ->
tasks = Enum.map(1..3, fn i ->
Task.async(fn -> make_request(plug, i) end)
end)
Task.await_many(tasks)
end
endAfter (if sequential matching needed):
test "parallel API calls" do
session = ReqCassette.start_shared_session()
try do
with_cassette "parallel_test", [session: session, sequential: true], fn plug ->
tasks = Enum.map(1..3, fn i ->
Task.async(fn -> make_request(plug, i) end)
end)
Task.await_many(tasks)
end
after
ReqCassette.end_shared_session(session)
end
endFor cleaner tests, use ExUnit's setup:
defmodule MyApp.ParallelAPITest do
use ExUnit.Case, async: true
import ReqCassette
setup do
session = ReqCassette.start_shared_session()
on_exit(fn -> ReqCassette.end_shared_session(session) end)
%{session: session}
end
test "parallel API calls", %{session: session} do
with_cassette "parallel_test", [session: session, sequential: true], fn plug ->
tasks = Enum.map(1..3, fn i ->
Task.async(fn -> make_request(plug, i) end)
end)
Task.await_many(tasks)
end
end
endIf your code uses GenServers that make HTTP calls, pass the plug through the GenServer's initialization or function arguments:
defmodule MyApp.APIWorker do
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
end
def fetch_data(pid) do
GenServer.call(pid, :fetch_data)
end
@impl true
def init(opts) do
{:ok, %{req_opts: opts[:req_opts] || []}}
end
@impl true
def handle_call(:fetch_data, _from, state) do
response = Req.get!("https://api.example.com/data", state.req_opts)
{:reply, response.body, state}
end
end
# Test with shared session
test "genserver makes API calls", %{session: session} do
with_cassette "genserver_test", [session: session], fn plug ->
# Start GenServer with the plug in req_opts
{:ok, pid} = MyApp.APIWorker.start_link(req_opts: [plug: plug])
# GenServer's HTTP calls will use the shared session
result = MyApp.APIWorker.fetch_data(pid)
assert result["status"] == "ok"
end
endKey insight: The GenServer runs in a separate process, so shared sessions are required for sequential matching to work correctly across processes.
| Scenario | Options Needed |
|---|---|
| Different requests, different responses | None (default) |
| Same request, same expected response | None (default) |
| Retry logic | None (default) |
| Same request, different responses | sequential: true |
| Templated requests | template: [...] (sequential auto-enabled) |
| Cross-process + sequential | session: session, sequential: true |
Fully backward compatible. Default behavior (first-match) is unchanged.
If you use ReqCassette.Plug directly (without with_cassette), behavior is
unchanged - it uses first-match scanning.
# Direct plug usage - unchanged behavior
Req.get!("https://api.example.com",
plug: {ReqCassette.Plug, %{cassette_name: "test", cassette_dir: "cassettes"}}
)If you see this error after upgrading:
-
Check if you need sequential matching: If you're replaying identical requests that should return different responses, add
sequential: true. -
Request order changed: With sequential matching, requests must be in the same order they were recorded. Re-record the cassette if order changed.
-
Missing shared session: If using
Task.asyncor similar with sequential matching, add a shared session.
This usually indicates cross-process race conditions. Add shared sessions to affected tests.
| Change | Action Required |
|---|---|
| Default (first-match) | None |
| Sequential matching | Add sequential: true where needed |
| Templates | None (sequential auto-enabled) |
| Cross-process + sequential | Add shared session |
| Direct Plug usage | None |