Contents

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.6.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.6.0",
        traits: ["AsyncHTTPClient"]
    )
]

Xcode

  1. Add the package: File → Add Packages…
  2. 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 as a folder reference so it's copied into the test bundle with its name intact.

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(replaysFrom: Bundle(for: TestBundleToken.self)))
struct YourSuite { /* ... */ }

The bundle is a fallback for playback. When the test's source file is present on the machine running the tests, Replay reads archives from the Replays/ directory next to it, and always records there, so recordings land in your project rather than in a build product.

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.fetchUser

This 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:

@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:

@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:

@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:

@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:

@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:

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:

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:

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:

// 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-directory to 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, .path instead of full .url)
  • Re-record the fixture intentionally
  • Inspect the archive to see what it contains:
swift package replay inspect path/to/archive.har

License

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