Skip to content

Support request-header matching for stub-based playback#17

Open
grdsdev wants to merge 1 commit into
mattt:mainfrom
grdsdev:fix/stub-request-headers-matching
Open

Support request-header matching for stub-based playback#17
grdsdev wants to merge 1 commit into
mattt:mainfrom
grdsdev:fix/stub-request-headers-matching

Conversation

@grdsdev

@grdsdev grdsdev commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Matcher.headers([...]) compares an incoming request's header values against a candidate request. For HAR-based playback that candidate carries real recorded headers, so it works as documented. For stub-based playback (.replay(stubs:)), the candidate is synthesized in PlaybackStore.makeEntry(from:) from a Stub — and a Stub never has request headers set on it (Stub.headers is documented as, and only used as, the response headers returned to the caller).

The practical effect: .headers([...]) can never usefully match a stub. It only passes when the real request's named headers are nil, so trying to assert "this request must send Accept: text/csv" against a Stub-based test silently never matches — every request fails to find a stub, regardless of whether the code under test sent the right header.

We hit this migrating a test suite off Mocker: the old tests asserted request headers (Accept, Prefer, etc.) via Mocker's .snapshotRequest curl output, and we reached for .headers([...]) as the natural Replay equivalent — only to find it's a no-op for Stub-based tests as currently implemented.

Fix

  • Added Stub.requestHeaders: [String: String] = [:] — a new field, defaulted so it's source-compatible with every existing initializer and call site.
  • Added Stub.matchingRequestHeaders(_:), a fluent modifier for setting it inline in a stub list literal, e.g.:
    .get("https://api.example.com/reports", 200, [:]) { "..." }
        .matchingRequestHeaders(["Accept": "text/csv"])
  • PlaybackStore.makeEntry(from:) now carries stub.requestHeaders onto the synthesized candidate request, so Matcher.headers([...]) matches as expected.
  • Updated the .headers case doc comment to call out the stub-based caveat explicitly, so the next person doesn't have to rediscover this from source.

Test plan

  • Added two tests to PlaybackTests.swift:
    • one demonstrating the existing gap (a stub with no requestHeaders never matches .headers([...]), even though the real request sets the header)
    • one confirming the fix (matchingRequestHeaders makes the match succeed, and a mismatched header value still correctly fails)
  • Full suite: swift test — 345/345 passing.
  • swift-format lint clean on all changed files.

Matcher.headers([...]) compares the incoming request's headers against
a candidate request. For stub-based playback (.replay(stubs:)), that
candidate is synthesized from a Stub with no headers ever set on it —
Stub.headers only becomes the *response* headers. So .headers([...])
could never usefully match: it would only pass when the real request's
named headers were absent, making it unusable for asserting an expected
header value against an in-memory stub.

Add Stub.requestHeaders (defaulted, source-compatible with all existing
initializers) and a matchingRequestHeaders(_:) fluent modifier to set
it. Playback.makeEntry(from:) now carries those headers onto the
synthesized candidate request, so .headers([...]) matches as expected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant