mattt/replay
HTTP recording, playback, and stubbing for Swift,
Requirements
- Swift 6.1+
- macOS 10.15+ / iOS 13+ / tvOS 13+ / watchOS 6+ / visionOS 1+ / Linux
Installation
Swift Package Manager
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/mattt/Replay.git", from: "0.4.0")
]Then add Replay to your test target dependencies:
.testTarget(
name: "YourTests",
dependencies: [
.product(name: "Replay", package: "Replay")
]
)AsyncHTTPClient support
Replay can also intercept requests made with AsyncHTTPClient. Enable the AsyncHTTPClient package trait:
dependencies: [
.package(
url: "https://github.com/mattt/Replay.git",
from: "0.4.0",
traits: ["AsyncHTTPClient"]
)
]Xcode
- Add the package: File → Add Packages…
- Add Replay to your test target.
Getting Started
0. Design your HTTP client to accept a session (optional)
Replay can intercept URLSession.shared globally, but accepting a URLSession parameter enables parallel test execution and is generally good practice.
import Foundation
struct User: Identifiable, Codable {
let id: Int
let name: String
let email: String
}
actor ExampleAPIClient {
static let shared = ExampleAPIClient()
let baseURL: URL
let session: URLSession
init(
baseURL: URL = URL(string: "https://api.example.com")!,
session: URLSession = .shared
) {
self.baseURL = baseURL
self.session = session
}
func fetchUser(id: User.ID) async throws -> User {
let url = baseURL.appendingPathComponent("users/\(id)")
let (data, _) = try await session.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
}1. Add a Replays/ folder to your test target
Replay loads archives named Replays/<name>.har.
Create a Replays/ directory alongside your test files:
mkdir Tests/YourTests/Replays/Swift Package Manager: Copy fixtures into the test bundle
In Package.swift, add:
.testTarget(
name: "YourTests",
dependencies: [
.product(name: "Replay", package: "Replay")
],
resources: [
.copy("Replays")
]
)Use the .playbackIsolated test suite trait to point Replay at your package bundle:
import Foundation
import Testing
import Replay
@Suite(.playbackIsolated(replaysFrom: Bundle.module))Xcode: Include fixtures as test resources
Add your Replays/ folder to the test target and ensure it's included in the test bundle resources.
Use the .playbackIsolated test suite trait to point Replay at your test bundle's resources:
import Foundation
import Testing
import Replay
private final class TestBundleToken {}
@Suite(
.playbackIsolated(
replaysRootURL: Bundle(for: TestBundleToken.self)
.resourceURL?
.appendingPathComponent("Replays")
)
)
struct YourSuite { /* ... */ }2. Write a test using .replay("…")
import Foundation
import Testing
import Replay
@Suite(/* ... */)
struct YourSuite {
@Test(.replay("fetchUser"))
func fetchUser() async throws {
let client = ExampleAPIClient.shared
let user = try await client.fetchUser(id: 42)
#expect(user.id == 42)
}
}3. Run tests
The first run fails if the HAR file doesn't exist yet—this is intentional to prevent accidental recording.
Replay uses two environment variables to control behavior:
REPLAY_RECORD_MODE(default:none)
- none: never record - once: record only if the archive is missing - rewrite: rewrite the archive from scratch
REPLAY_PLAYBACK_MODE(default:strict)
- strict: require fixtures; fail if missing/unmatched - passthrough: use fixtures when available; otherwise hit the network - live: ignore fixtures and always hit the network
$ swift test
❌ Test fetchUser() recorded an issue at ExampleTests.swift
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ No Matching Entry in Archive
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Request: GET https://api.example.com/users/42
Archive: /path/to/.../Replays/fetchUser.har
This request was not found in the replay archive.
Options:
1. Run against the live network (ignore fixtures):
REPLAY_PLAYBACK_MODE=live swift test --filter <test-name>
2. Rewrite the archive from scratch:
REPLAY_RECORD_MODE=rewrite swift test --filter <test-name>
3. Check if request details changed (URL, method, headers)
and update test expectations
4. Inspect the archive:
swift package replay inspect /path/to/.../Replays/fetchUser.har
4. Record
REPLAY_RECORD_MODE=once swift test --filter YourSuite.fetchUserThis creates Replays/fetchUser.har.
[!TIP] To run tests against a live API (ignoring fixtures), use
REPLAY_PLAYBACK_MODE=live.
5. Re-run
$ swift test
✅ Test fetchUser() passed after 0.001 seconds.6. Commit fixtures
Replay can redact while recording using filters (recommended) or you can filter an existing HAR file using the plugin (see Tooling).
[!WARNING] HAR files may contain sensitive data (cookies, auth headers, tokens, PII). Always review/redact before committing to source control.
Usage
### Matching strategies
By default, Replay matches requests by HTTP method + full URL,
which requires scheme, host, port, path, query, and fragment to match exactly.
For APIs with volatile query parameters (pagination cursors, timestamps, cache-busters),
use a looser matching strategy:
```swift
@Test(.replay("fetchUser", matching: [.method, .path]))
func fetchUser() async throws { /* ... */ }
```
Matchers compose with `AND` semantics;
all must match for an entry to be selected.
| Matcher | Matches on |
| --------------- | ---------------------------------------------------- |
| `.method` | HTTP method (case-insensitive) |
| `.url` | Full URL string (strict) |
| `.host` | URL host |
| `.path` | URL path |
| `.query` | Query parameters (order-insensitive) |
| `.headers([…])` | Specified header values (names are case-insensitive) |
| `.body` | Request body bytes |
| `.custom(…)` | Custom `(URLRequest, URLRequest) -> Bool` |
> [!TIP]
> If built-in matchers don't cover your needs,
> use `.custom` to implement arbitrary matching logic.
### Filters
Filters strip sensitive data during recording:
```swift
@Test(
.replay(
"fetchUser",
matching: [.method, .path],
filters: [
.headers(removing: ["Authorization", "Cookie"]),
.queryParameters(removing: ["token", "api_key"])
]
)
)
func fetchUser() async throws { /* ... */ }
```
For request/response bodies, use `Filter.body(replacing:with:)` for string redaction
or `Filter.body(decoding:transform:)` to transform decoded JSON.
### Stubs
For simple cases, use inline stubs instead of HAR files:
```swift
@Test(
.replay(
stubs: [.get("https://example.com/greeting", 200, ["Content-Type": "text/plain"], { "Hello, world!" })]
)
)
func fetchGreeting() async throws {
let (data, _) = try await URLSession.shared.data(from: URL(string: "https://example.com/greeting")!)
#expect(String(data: data, encoding: .utf8) == "Hello, world!")
}
```
### Parallel test execution
By default, Replay uses global `URLProtocol` registration with serialized access
to prevent cross-test interference.
This means tests using `.replay()` run one at a time,
even when Swift Testing would otherwise run them in parallel.
For true parallel execution, use `scope: .test` to isolate each test's playback state:
```swift
@Suite(.playbackIsolated(replaysFrom: Bundle.module))
struct ParallelizableAPITests {
@Test(.replay("fetchUser", matching: [.method, .path], scope: .test))
func fetchUser() async throws {
// Use Replay.session instead of URLSession.shared
let client = ExampleAPIClient(session: Replay.session)
_ = try await client.fetchUser(id: 42)
}
@Test(.replay("fetchPosts", matching: [.method, .path], scope: .test))
func fetchPosts() async throws {
// Each test gets its own isolated playback store
let client = ExampleAPIClient(session: Replay.session)
_ = try await client.fetchPosts()
}
}
```
**Key differences with `scope: .test`:**
| Aspect | `scope: .global` (default) | `scope: .test` |
| --------------- | ------------------------------- | ------------------------- |
| Execution | Serialized (one test at a time) | Parallel |
| URLSession | Works with `URLSession.shared` | Requires `Replay.session` |
| State isolation | Shared global state | Per-test isolated state |
> [!IMPORTANT]
> When using `scope: .test`, you must use `Replay.session` (or `Replay.makeSession()`)
> instead of `URLSession.shared`. The test-scoped playback store is routed via a custom
> HTTP header that only `Replay.session` includes.
### Multiple requests per test
Each HAR file can contain multiple request/response entries.
Use one archive per test—don't stack `.replay(...)` traits:
```swift
@Test(.replay("fetchUser"), .replay("fetchPosts")) // ❌ Don't do this
func myTest() async throws { /* ... */ }
```
If a test makes multiple requests,
record them all into a single HAR file.
### Creating HAR files from browser sessions
You can also capture traffic using browser developer tools.
Open the Network tab, trigger the requests, then export as HAR:
- **Safari**: Right-click → Export HAR
- **Chrome**: Click ↓ → Save all as HAR with content
- **Firefox**: Right-click → Save All As HAR
> [!WARNING]
> Browser-exported HAR files often contain sensitive data (cookies, tokens, PII).
> Always review and redact before committing.
### AsyncHTTPClient
When the `AsyncHTTPClient` trait is enabled,
Replay provides `HTTPClientProtocol` — a protocol that both
`HTTPClient` and `ReplayHTTPClient` conform to.
Design your code against `some HTTPClientProtocol`
and swap in `ReplayHTTPClient` during tests:
```swift
import AsyncHTTPClient
import NIOCore
actor ExampleAPIClient {
let httpClient: any HTTPClientProtocol
init(httpClient: any HTTPClientProtocol) {
self.httpClient = httpClient
}
func fetchUser(id: Int) async throws -> User {
let request = HTTPClientRequest(url: "https://api.example.com/users/\(id)")
let response = try await httpClient.execute(request, timeout: .seconds(30))
let body = try await response.body.collect(upTo: 1024 * 1024)
return try JSONDecoder().decode(User.self, from: body)
}
}
```
In tests, use `ReplayHTTPClient` with HAR files or inline stubs:
```swift
import Testing
import Replay
@Test("fetch user from stub")
func fetchUser() async throws {
let client = try await ReplayHTTPClient(
stubs: [
Stub(
.get,
"https://api.example.com/users/42",
status: 200,
headers: ["Content-Type": "application/json"],
body: #"{"id":42,"name":"Alice"}"#
)
]
)
let api = ExampleAPIClient(httpClient: client)
let user = try await api.fetchUser(id: 42)
#expect(user.name == "Alice")
}
```
`ReplayHTTPClient` also accepts a `PlaybackConfiguration`
for HAR-file-based playback:
```swift
let client = try await ReplayHTTPClient(
configuration: PlaybackConfiguration(
source: .file(archiveURL),
playbackMode: .strict,
matchers: [.method, .path]
)
)
```
> [!NOTE]
> `AsyncHTTPClient` uses SwiftNIO for networking rather than Foundation's URL Loading System,
> so `URLProtocol`-based interception (used by `@Test(.replay(…))`) cannot intercept its traffic.
> The `HTTPClientProtocol` abstraction provides an equivalent mechanism through dependency injection.
### Using Replay without Swift Testing
For XCTest or manual control, use the lower-level APIs directly:
```swift
// Playback from a HAR file
let config = PlaybackConfiguration(
source: .file(archiveURL),
playbackMode: .strict, // or .passthrough, .live
recordMode: .none, // or .once, .rewrite
matchers: [.method, .path]
)
let session = try await Playback.session(configuration: config)
// Record traffic
let captureConfig = CaptureConfiguration(destination: .file(archiveURL))
let recordingSession = try await Capture.session(configuration: captureConfig)
// Read/write HAR files directly
let archive = try HAR.load(from: archiveURL)
try HAR.save(archive, to: outputURL)
```Tooling
Replay includes a Swift Package Manager command plugin to help manage HAR archives.
# Check status of archives (age, orphans, etc.)
swift package replay status
# Record specific tests (runs `swift test --filter …` with `REPLAY_RECORD_MODE=once` or `rewrite`)
swift package replay record ExampleAPITests.fetchUser
# Note: The archive name and location come from your `@Test(.replay("…"))`
# configuration (or the auto-generated name),
# not from the `--filter` string passed to the `swift test` command.
# Inspect a HAR file
swift package replay inspect Tests/YourTests/Replays/fetchUser.har
# Validate a HAR file
swift package replay validate Tests/YourTests/Replays/fetchUser.har
# Filter sensitive data from an existing HAR
swift package replay filter input.har output.har --headers Authorization --query-params token[!NOTE] Add
--allow-writing-to-package-directoryto commands to skip confirmation step.
Troubleshooting
“Replay Archive Missing”
This is expected on first run (unless you've already created Replays/<name>.har). Record intentionally for the failing test:
REPLAY_RECORD_MODE=rewrite swift test --filter <your-test-name>“No Matching Entry in Archive”
This means the test made a request that didn't match any entry in the HAR. Common fixes:
- Use a more stable matcher set (often
.method, .pathinstead of full.url) - Re-record the fixture intentionally
- Inspect the archive to see what it contains:
swift package replay inspect path/to/archive.harLicense
This project is available under the MIT license. See the LICENSE file for more info.
Package Metadata
Repository: mattt/replay
Default branch: main
README: README.md