Migration Guide: v0.4 → v0.5
View SourceThis guide helps you upgrade from ReqCassette v0.4 to v0.5.
Overview
v0.5.0 adds sequential matching and cross-process session support. Sequential matching is opt-in (or automatically enabled with templates).
What's New
Matching Behavior (Unchanged Default)
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)
endSequential Matching (New, Opt-in)
When 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: [...].
Cross-Process Sessions
If your tests make HTTP requests from spawned processes and need sequential matching, use shared sessions.
The Problem
# 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!
endThe Solution
# 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)
endNew API
New Option: :sequential
Enable sequential matching for a cassette:
with_cassette "test", [sequential: true], fn plug ->
# Requests match interactions in order
endReqCassette.start_shared_session/0
Creates a shared session for cross-process cassette matching. Returns a session
reference to pass to with_cassette/3.
session = ReqCassette.start_shared_session()ReqCassette.end_shared_session/1
Ends a shared session and cleans up resources. Always call this in an after
block.
ReqCassette.end_shared_session(session)New Option: :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
endNew Option: :shared
Shorthand 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)
endReqCassette.with_shared_cassette/2,3
Convenience 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 ->
# ...
endMigration Steps
Step 1: Most Tests Need No Changes
If your tests use first-match behavior (different requests, or same request expecting same response), no changes are needed.
Step 2: Add sequential: true Where 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
endStep 3: Add Shared Sessions for Cross-Process Sequential Tests
If 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
endStep 4: Use ExUnit Setup (Recommended for Multiple Tests)
For 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
endStep 5: GenServer Testing Pattern
If 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.
When to Use Each Feature
| 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 |
Backward Compatibility
with_cassette Usage
Fully backward compatible. Default behavior (first-match) is unchanged.
Direct Plug Usage
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"}}
)Troubleshooting
"No matching interaction found" errors
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.
Tests pass individually but fail together
This usually indicates cross-process race conditions. Add shared sessions to affected tests.
Summary
| 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 |