Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 120 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ A tiny, test-first way to mock `URLSession` — **fast to set up, easy to read,
## Highlights
- **Two ways to add mocks**: a **clean DSL** or **single registration methods** — use whichever reads best for your use case.
- **Instance or singleton**: you can either use the singleton `HTTPMock.shared` or create separate instances with `HTTPMock()`. Different instances have separate response queues.
- **Provides a real `URLSession`**: inject `HTTPMock.shared.urlSession` or your own instance's `urlSession` into the code under test.
- **Precise matching**: host + path, plus optional **query matching** (`.exact` or `.contains`).
- **Provides a real `URLSession`**: inject `HTTPMock.shared.urlSession` or your own instance's `urlSession` into the code under test.
- **Flexible matching**: exact strings, **wildcard patterns** (`*` and `**`), plus optional **query matching** (`.exact` or `.contains`).
- **Headers support**: define headers at the host or path, with optional **cascade** to children when using the DSL.
- **FIFO responses**: queue multiple responses and they'll be served in order.
- **Passthrough networking**: configure unmocked requests to either return a hardcoded 404 or be passed through to the network.
Expand Down Expand Up @@ -122,6 +122,110 @@ Path("/search", query: ["q": "swift"], matching: .contains) {
}
```

## Wildcard patterns
Both hosts and paths support wildcard matching using glob-style patterns. This is useful for mocking multiple similar endpoints without registering each variation individually.

### Single segment wildcards (`*`)
Match within a single segment only. Segments are separated by `.` for hosts and `/` for paths.

#### Host wildcards
```swift
HTTPMock.shared.registerResponses {
Host("*.example.com") { // Single host pattern wildcard.
Path("/users") {
MockResponse.plaintext("wildcard host")
}
}
}
```

The host pattern (`*.example.com`) matches i.e.:
- `api.example.com`
- `staging.example.com`.

Since `*` only matches on a single segment this means the pattern will **NOT** match i.e. `api.staging.example.com`.

#### Path wildcards
```swift
HTTPMock.shared.addResponses(
forPath: "/api/*/users", // Single path pattern wildcard.
host: "api.example.com",
responses: [.encodable(users)]
)
```

The path pattern (`/api/*/users`) matches i.e.:
- `/api/v1/users`
- `/api/v2/users`.

Since `*` only matches on a single segment this means the pattern will **NOT** match i.e. `/api/v1/beta/users`

### Multi-segment wildcards (`**`)
Match across multiple segments (zero or more). Useful for flexible host/path matching.

#### Multi-segment host wildcards
```swift
HTTPMock.shared.registerResponses {
Host("**.example.com") {
Path("/api/**/data") {
MockResponse.plaintext("flexible matching")
}
}
}
```

The host pattern (`**.example.com`) matches i.e.:
- `api.example.com`
- `api.staging.example.com`.

The path pattern (`/api/**/data`) matches i.e.:
- `/api/data`
- `/api/v1/data`
- `/api/v1/beta/data`.

#### Complex patterns
```swift
HTTPMock.shared.addResponses(
forPath: "/api/**/users/*",
host: "api-*.example.com",
responses: [.encodable(users)]
)
```

The combination of host and path pattern will match i.e. `api-staging.example.com/api/v1/beta/users/123`.

### Pattern specificity
When multiple patterns could match the same request, HTTPMock automatically chooses the most specific:

1. **Exact matches** always win over wildcards.
2. **Fewer wildcards** beat more wildcards.
3. **Longer literal content** wins ties.

Given the registered patterns in the code block below, the table explains which pattern(s) would match, and win, on an incoming request.

```swift
Host("api.example.com") // exact - highest priority
Host("*.example.com") // single wildcard
Host("**.example.com") // multi wildcard - lowest priority
```

| Incoming request | Pattern matches | Winning pattern | Why? |
| :- | - | - | - |
| `api.example.com` | Matches on all three registered patterns | The exact pattern | It has no wildcards (lowest score). |
| `api-test.example.com` | Matches on both the single- and multi wildcard patterns | The single wildcard pattern | It has the fewest wildcards (lowest score). |
| `api.staging.example.com` | Matches only on the multi wildcard pattern | The multi wildcard pattern | The only pattern that matches. |

#### Specificity tie-breaker

Here's an example to provide more context to the specificity score when a tie between two patterns occurs. Given the registered patterns:

```swift
Host("*.example.*") // two single wildcard
Host("**.example.com") // multi wildcard
```

An incoming request to **`api.example.com`** would match on both of the patterns above, but the winning pattern will be the **multi wildcard pattern**. Both patterns have exactly two wildcards, but the multi wildcard pattern has a **longer matching literal** which gives it a higher score.

## File-based responses
Serve response data directly from a file on disk. Useful for pre-recorded and/or large responses. Either specify the `Content-Type` manually, or let it be inferred from the file.

Expand All @@ -131,7 +235,7 @@ HTTPMock.shared.registerResponses {
Path("/data") {
// Point to a file in the specified `Bundle`.
MockResponse.file(named: "response", extension: "json", in: Bundle.main)

// Load the contents of a file from a `URL`.
MockResponse.file(url: urlToFile)
}
Expand Down Expand Up @@ -177,7 +281,7 @@ By default, unmocked requests return a hardcoded 404 response with a small body.
HTTPMock.shared.unmockedPolicy = .notFound

// Alternative: let unmocked requests hit the real network.
// This can be useful if you're doing integration testing and only want to mock certain endpoints.
// This can be useful if you're doing integration testing and only want to mock certain endpoints.
HTTPMock.shared.unmockedPolicy = .passthrough
```

Expand Down Expand Up @@ -226,17 +330,25 @@ let instanceSession = mockInstance.urlSession

## FAQs
**Can I run tests that use `HTTPMock` in parallel?**
Previously, only a single instance of `HTTPMock` could exist, so tests had to be run sequentially. Now, you can create multiple independent `HTTPMock` instances using `HTTPMock()`, allowing parallel tests or separate mock configurations. The singleton `HTTPMock.shared` still exists for convenience.
Yes. You can create multiple independent `HTTPMock` instances, which allows for parallel tests or separate mock configurations. If you don't need separate instances you can use the singleton `HTTPMock.shared`.

Be aware that the singleton will exist for the whole duration of the app or tests, so call `HTTPMock.shared.clearQueues()` if you need to reset it.

**Can I use my own `URLSession`?**
Yes — most tests just use `HTTPMock.shared.urlSession`. If your code constructs its own session, inject `HTTPMock.shared.urlSession` or your own instance's `urlSession` into the component under test.
Yes. Most tests just use `HTTPMock.shared.urlSession`. If your code constructs its own session, inject `HTTPMock.shared.urlSession` or your own instance's `urlSession` into the component under test.

**Is order guaranteed?**
Yes, per (host, path, [query]) responses are popped in **FIFO** order.
Yes. Responses per (host, path, [query]) are queued and popped in **FIFO** order.

**What happens if a request is not mocked?**
By default, unmocked requests return a hardcoded "404 Not Found" response. You can configure `HTTPMock`'s `UnmockedPolicy` to instead pass such requests through to the real network, allowing unmocked calls to succeed.

**Can I mix exact and wildcard patterns for the same endpoint?**
Yes. You can register multiple patterns that could match the same request. HTTPMock will automatically choose the most specific pattern using a score ranking (exact beats wildcards, fewer wildcards beat more wildcards).

**What characters are supported in wildcard patterns?**
Use `*` for single-segment wildcards and `**` for multi-segment wildcards. All other characters are treated as literals. Special regex characters are automatically escaped, so patterns like `api-*.example.com` work as expected.

## Example response helpers
These are available as static factory methods on `MockResponse` and can be used directly inside a `Path` or `addResponses` builder:

Expand Down Expand Up @@ -269,5 +381,6 @@ Path("/user") {
- [X] Let user point to a file that should be served.
- [X] Set delay on requests.
- [X] Create separate instances of `HTTPMock`. The current single instance requires tests to be run in sequence, instead of parallel.
- [X] Support wildcard patterns in host and path matching (`*` and `**` glob-style patterns).
- [ ] Let user configure a default "not found" response. Will be used either when no matching mocks are found or if queue is empty.
- [ ] Does arrays in query parameters work? I think they're being overwritten with the current setup.
15 changes: 9 additions & 6 deletions Sources/HTTPMock/Internal/HTTPMockMatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,17 @@ struct HTTPMockMatcher {
return pattern == value
}

// Check if we have a cached pattern already, or create one if not.
// If regex compilation fails: fail silently and return `false`.
guard let regularExpression = try? expressionStorage.regex(for: pattern, kind: kind) else {
do {
// Check if we have a cached pattern already, or create one if not.
let regularExpression = try expressionStorage.regex(for: pattern, kind: kind)

let searchRange = NSRange(value.startIndex..<value.endIndex, in: value)
return regularExpression.firstMatch(in: value, range: searchRange) != nil
} catch {
// If regex compilation fails: warn the user and return `false`.
HTTPMockLog.warning("Failed to compile regex for pattern '\(pattern)'. This pattern will be ignored and may cause unexpected behavior.\nError: \(error)")
return false
}

let searchRange = NSRange(value.startIndex..<value.endIndex, in: value)
return regularExpression.firstMatch(in: value, range: searchRange) != nil
}

/// Computes a specificity score for a key where the return is a tuple of `(wildcardCount, -literalCount)`.
Expand Down
47 changes: 27 additions & 20 deletions Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,14 @@ final class HTTPMockURLProtocol: URLProtocol {

// Let user know if they're trying to insert responses after an eternal mock.
if queue.contains(where: \.isEternal) {
HTTPMockLog.warning("Registering response(s) after an eternal mock for \(mockKeyDescription(key)). These responses will never be served.")
HTTPMockLog.warning("Registered response(s) after an eternal mock for \(keyDescription(key)). These responses will never be served.")
}

queue.append(contentsOf: responses)
mockQueue[key] = queue
setQueue(for: mockIdentifier, mockQueue)

HTTPMockLog.info("Registered \(responses.count) response(s) for \(mockKeyDescription(key))")
HTTPMockLog.info("Registered \(responses.count) response(s) for \(keyDescription(key))")
HTTPMockLog.debug("Current queue size for \(key.host)\(key.path): \(queue.count)")
}

Expand Down Expand Up @@ -134,7 +134,7 @@ final class HTTPMockURLProtocol: URLProtocol {
let queryDict = components.queryItems.toDictionary
let requestDescription = Self.requestDescription(host: host, path: path, query: queryDict)

HTTPMockLog.trace("Handling request → \(requestDescription)")
HTTPMockLog.trace("Handling incoming request → '\(requestDescription)'")

// Look for, and pop, the next queued response mathing host, path and query params.
let match = Self.findAndPopNextMock(
Expand All @@ -147,12 +147,15 @@ final class HTTPMockURLProtocol: URLProtocol {
if let match {
let key = match.key
let mock = match.response
let keyDescription = Self.keyDescription(key)

HTTPMockLog.trace("Found mock in queue for matching registration: '\(keyDescription)'")
HTTPMockLog.debug("Remaining queue count for '\(keyDescription)': \(Self.queueSize(for: mockIdentifier, key: key))")

let sendResponse = { [weak self] in
guard let self else { return }
do {
HTTPMockLog.info("Serving mock for \(host)\(path) (\(self.statusCode(of: mock)))")
HTTPMockLog.debug("Remaining queue for \(requestDescription): \(Self.queueSize(for: mockIdentifier, key: key))")
HTTPMockLog.info("Serving mock for incoming request \(host)\(path) (\(self.statusCode(of: mock)))")

let response = HTTPURLResponse(
url: url,
Expand All @@ -166,6 +169,7 @@ final class HTTPMockURLProtocol: URLProtocol {
self.client?.urlProtocol(self, didLoad: payload)
self.client?.urlProtocolDidFinishLoading(self)
} catch {
HTTPMockLog.error("Failed to serve mock for \(host)\(path): \(error)")
self.client?.urlProtocol(self, didFailWithError: error)
}
}
Expand All @@ -174,30 +178,33 @@ final class HTTPMockURLProtocol: URLProtocol {
case .instant:
sendResponse()
case .delayed(let delay):
HTTPMockLog.info("Delaying response for \(requestDescription) for \(delay) seconds")
HTTPMockLog.info("Delaying response for request '\(requestDescription)' for \(delay) seconds")
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay, execute: sendResponse)
}
} else {
switch Self.getUnmockedPolicy(for: mockIdentifier) {
case .notFound:
HTTPMockLog.error("No mock found for \(requestDescription) — returning 404")
let resp = HTTPURLResponse(
HTTPMockLog.error("No mock found for request '\(requestDescription)' — returning 404")
let response = HTTPURLResponse(
url: url,
statusCode: 404,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "text/plain"]
)!
client?.urlProtocol(self, didReceive: resp, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data("No mock for \(host)\(path)".utf8))
client?.urlProtocolDidFinishLoading(self)

case .passthrough:
HTTPMockLog.info("No mock found for \(requestDescription) — passthrough to network")
var req = request
let mutableReq = (req as NSURLRequest).mutableCopy() as! NSMutableURLRequest
URLProtocol.setProperty(true, forKey: Self.handledKey, in: mutableReq) // prevent loop
req = mutableReq as URLRequest
let task = passthroughSession.dataTask(with: req) { data, response, error in
var request = request

// Set known value on request to prevent handling the same request multiple times.
let mutableRequest = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest
URLProtocol.setProperty(true, forKey: Self.handledKey, in: mutableRequest)
request = mutableRequest as URLRequest

let task = passthroughSession.dataTask(with: request) { data, response, error in
if let error {
self.client?.urlProtocol(self, didFailWithError: error)
return
Expand Down Expand Up @@ -259,15 +266,15 @@ final class HTTPMockURLProtocol: URLProtocol {
let copy = first.copyWithNewLifetime(.multiple(count - 1))
mockQueues[matchingKey] = [copy] + queue
setQueue(for: mockIdentifier, mockQueues)
HTTPMockLog.info("Mock response will be used \(count) more time(s) for \(mockKeyDescription(matchingKey))")
HTTPMockLog.info("Mock response will be used \(count) more time(s) for \(keyDescription(matchingKey))")
return MockMatch(key: matchingKey, response: copy)
}
case .eternal:
return MockMatch(key: matchingKey, response: first)
}

if queue.isEmpty {
HTTPMockLog.info("Queue now depleted for \(mockKeyDescription(matchingKey))")
HTTPMockLog.info("Queue now depleted for \(keyDescription(matchingKey))")
}

return MockMatch(key: matchingKey, response: first)
Expand All @@ -283,12 +290,12 @@ extension HTTPMockURLProtocol {
mock.status.code
}

private static func requestDescription(host: String, path: String, query: [String: String]) -> String {
"\(host)\(path) \(describeQuery(query, nil, dropQueryMatching: true))"
private static func keyDescription(_ key: Key) -> String {
"\(key.host)\(key.path) \(describeQuery(key.queryItems, key.queryMatching))"
}

private static func mockKeyDescription(_ key: Key) -> String {
"\(key.host)\(key.path) \(describeQuery(key.queryItems, key.queryMatching))"
private static func requestDescription(host: String, path: String, query: [String: String]) -> String {
"\(host)\(path) \(describeQuery(query, nil, dropQueryMatching: true))"
}

private static func queueSize(for mockIdentifier: UUID, key: Key) -> Int {
Expand Down
4 changes: 2 additions & 2 deletions Tests/HTTPMockTests/HTTPMockMatcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ struct HTTPMockMatcherTests {

// MARK: - Helpers

func makeKey(
private func makeKey(
host: String = "api.example.com",
path: String = "/search",
queryItems: [String: String]? = nil,
Expand All @@ -383,7 +383,7 @@ struct HTTPMockMatcherTests {
)
}

func checkMatch(
private func checkMatch(
host: String = "api.example.com",
path: String = "/search",
queryItems: [String: String] = [:],
Expand Down
Loading