diff --git a/README.md b/README.md index c840c33..65cf81a 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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) } @@ -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 ``` @@ -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: @@ -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. diff --git a/Sources/HTTPMock/Internal/HTTPMockMatcher.swift b/Sources/HTTPMock/Internal/HTTPMockMatcher.swift index eaf0e7e..d84f5c7 100644 --- a/Sources/HTTPMock/Internal/HTTPMockMatcher.swift +++ b/Sources/HTTPMock/Internal/HTTPMockMatcher.swift @@ -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.. 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 { diff --git a/Tests/HTTPMockTests/HTTPMockMatcherTests.swift b/Tests/HTTPMockTests/HTTPMockMatcherTests.swift index 2694abb..17dd438 100644 --- a/Tests/HTTPMockTests/HTTPMockMatcherTests.swift +++ b/Tests/HTTPMockTests/HTTPMockMatcherTests.swift @@ -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, @@ -383,7 +383,7 @@ struct HTTPMockMatcherTests { ) } - func checkMatch( + private func checkMatch( host: String = "api.example.com", path: String = "/search", queryItems: [String: String] = [:], diff --git a/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift b/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift index fd2a968..cdfc55a 100644 --- a/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift +++ b/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift @@ -34,8 +34,8 @@ struct HTTPMockResultBuilderTests { } let expectedQueues = [ - createMockKey(host: "domain.com", path: "/"), - createMockKey(host: "other-domain.com", path: "/") + makeKey(host: "domain.com", path: "/"), + makeKey(host: "other-domain.com", path: "/") ] #expect(Set(mockQueues.keys) == Set(expectedQueues)) } @@ -55,9 +55,9 @@ struct HTTPMockResultBuilderTests { } let expectedQueues: Set = [ - createMockKey(path: "/root"), - createMockKey(path: "/root/child"), - createMockKey(path: "/root/child/grand-child"), + makeKey(path: "/root"), + makeKey(path: "/root/child"), + makeKey(path: "/root/child/grand-child"), ] #expect(mockQueues.count == 3) #expect(Set(mockQueues.keys) == expectedQueues) @@ -98,8 +98,8 @@ struct HTTPMockResultBuilderTests { } let expectedQueues: [HTTPMockURLProtocol.Key] = [ - createMockKey(host: "example.com", path: "/"), - createMockKey(host: "example.com", path: "/some-other-path") + makeKey(host: "example.com", path: "/"), + makeKey(host: "example.com", path: "/some-other-path") ] #expect(Set(mockQueues.keys) == Set(expectedQueues)) } @@ -113,7 +113,7 @@ struct HTTPMockResultBuilderTests { } let expectedQueues: [HTTPMockURLProtocol.Key] = [ - createMockKey(path: "/root") + makeKey(path: "/root") ] #expect(Set(mockQueues.keys) == Set(expectedQueues)) } @@ -166,11 +166,11 @@ struct HTTPMockResultBuilderTests { } let expectedQueues: [HTTPMockURLProtocol.Key] = [ - createMockKey(host: "a", path: "/"), - createMockKey(host: "b", path: "/"), - createMockKey(host: "c", path: "/"), - createMockKey(host: "d", path: "/root"), - createMockKey(host: "e", path: "/root//////roooooot"), + makeKey(host: "a", path: "/"), + makeKey(host: "b", path: "/"), + makeKey(host: "c", path: "/"), + makeKey(host: "d", path: "/root"), + makeKey(host: "e", path: "/root//////roooooot"), ] #expect(Set(mockQueues.keys) == Set(expectedQueues)) } @@ -190,7 +190,7 @@ struct HTTPMockResultBuilderTests { } let expectedQueues: [HTTPMockURLProtocol.Key] = [ - createMockKey(path: "/") + makeKey(path: "/") ] #expect(Set(mockQueues.keys) == Set(expectedQueues)) } @@ -269,7 +269,7 @@ struct HTTPMockResultBuilderTests { } } - let resp = try #require(mockQueues[createMockKey(host: "example.com", path: "/conflict")]?.first) + let resp = try #require(mockQueues[makeKey(host: "example.com", path: "/conflict")]?.first) #expect(resp.headers["X-Shared"] == "response") } @@ -296,7 +296,7 @@ struct HTTPMockResultBuilderTests { } } - let resp = try #require(mockQueues[createMockKey(path: "/pos")]?.first) + let resp = try #require(mockQueues[makeKey(path: "/pos")]?.first) #expect(resp.headers == ["K": "V"]) // Still applied } @@ -361,4 +361,20 @@ struct HTTPMockResultBuilderTests { #expect(response2.httpStatusCode == 200) #expect(data2.toString == "ok-contains-2") } + + // MARK: - Helpers + + private func makeKey( + host: String = "example.com", + path: String = "/", + queryItems: [String : String]? = nil, + queryMatching: QueryMatching = .exact + ) -> HTTPMockURLProtocol.Key { + HTTPMockURLProtocol.Key( + host: host, + path: path, + queryItems: queryItems, + queryMatching: queryMatching + ) + } } diff --git a/Tests/HTTPMockTests/HTTPMockTests.swift b/Tests/HTTPMockTests/HTTPMockTests.swift index a69f3a8..8fc9037 100644 --- a/Tests/HTTPMockTests/HTTPMockTests.swift +++ b/Tests/HTTPMockTests/HTTPMockTests.swift @@ -22,7 +22,7 @@ struct HTTPMockTests { // Add response using default domain. httpMock.addResponses(forPath: "/root", responses: [.empty()]) - let mockKey1 = createMockKey(path: "/root") + let mockKey1 = makeKey(path: "/root") #expect(mockQueues.count == 1) #expect(Set(mockQueues.keys) == Set([mockKey1])) @@ -30,7 +30,7 @@ struct HTTPMockTests { httpMock.defaultDomain = "other.example.com" httpMock.addResponses(forPath: "/root", responses: [.empty()]) - let mockKey2 = createMockKey(host: "other.example.com", path: "/root") + let mockKey2 = makeKey(host: "other.example.com", path: "/root") #expect(mockQueues.count == 2) #expect(Set(mockQueues.keys) == Set([mockKey1, mockKey2])) } @@ -46,7 +46,7 @@ struct HTTPMockTests { @Test func itDoesNotRegisterTheKeyIfNoResponsesAreProvided() { - let key = createMockKey() + let key = makeKey() httpMock.addResponses([], for: key) #expect(mockQueues.isEmpty) @@ -54,7 +54,7 @@ struct HTTPMockTests { @Test func itDoesNotRegisterResponsesWithInvalidLifetimes() { - let key = createMockKey() + let key = makeKey() httpMock.addResponses([ .empty(lifetime: .multiple(0)), .empty(lifetime: .multiple(-1)), @@ -65,7 +65,7 @@ struct HTTPMockTests { @Test func itAllowsRegisteringResponsesAfterAnEternalResponse() { - let key = createMockKey() + let key = makeKey() httpMock.addResponses([ .empty(lifetime: .eternal), .empty(lifetime: .single), @@ -93,7 +93,7 @@ struct HTTPMockTests { httpMock.clearQueue(forHost: "domain.com") #expect(mockQueues.count == 1) - #expect(mockQueues.first?.key == createMockKey(host: "example.com", path: "/root")) + #expect(mockQueues.first?.key == makeKey(host: "example.com", path: "/root")) } @Test @@ -109,8 +109,8 @@ struct HTTPMockTests { @Test func itStoresAddedResponsesInQueue() { - let mockKey1 = createMockKey(path: "/root") - let mockKey2 = createMockKey(path: "/root/leaf") + let mockKey1 = makeKey(path: "/root") + let mockKey2 = makeKey(path: "/root/leaf") httpMock.addResponse(.empty(), for: mockKey1) httpMock.addResponse(.plaintext("Hey!"), for: mockKey2) @@ -123,7 +123,7 @@ struct HTTPMockTests { @Test func itPopsFromQueueOnRequest() async throws { - let mockKey = createMockKey() + let mockKey = makeKey() httpMock.addResponse(.empty(), for: mockKey) #expect(mockQueues.count == 1) @@ -137,7 +137,7 @@ struct HTTPMockTests { @Test func itPopsInFifoOrderForSamePath() async throws { - let key = createMockKey(path: "/fifo") + let key = makeKey(path: "/fifo") httpMock.addResponse(.plaintext("one"), for: key) httpMock.addResponse(.plaintext("two"), for: key) httpMock.addResponse(.plaintext("three"), for: key) @@ -188,7 +188,7 @@ struct HTTPMockTests { let path = "/search" // Register a response that matches only exactly these params (no extras) - let exactKey = createMockKey( + let exactKey = makeKey( host: host, path: path, queryItems: ["q": "swift", "page": "1"], @@ -213,7 +213,7 @@ struct HTTPMockTests { let host = "example.com" let path = "/search" - let containsKey = createMockKey( + let containsKey = makeKey( host: host, path: path, queryItems: ["q": "swift"], @@ -239,7 +239,7 @@ struct HTTPMockTests { let host = "example.com" let path = "/search" - let containsKey = createMockKey( + let containsKey = makeKey( host: host, path: path, queryItems: ["q": "swift"], @@ -258,7 +258,7 @@ struct HTTPMockTests { let host = "example.com" let path = "/search" - let containsKey = createMockKey( + let containsKey = makeKey( host: host, path: path, queryItems: ["q": "swift", "page": "1"], @@ -292,7 +292,7 @@ struct HTTPMockTests { let contents = "{\"hello\":\"world\"}" let url = try writeTempFile(named: "fixture", ext: "json", contents: Data(contents.utf8)) - let key = createMockKey(host: host, path: path) + let key = makeKey(host: host, path: path) httpMock.addResponse(.file(url: url), for: key) let requestURL = try #require(URL(string: "https://\(host)\(path)")) @@ -311,7 +311,7 @@ struct HTTPMockTests { let contents = "BINARYDATA" let url = try writeTempFile(named: "blob", ext: "bin", contents: Data(contents.utf8)) - let key = createMockKey(host: host, path: path) + let key = makeKey(host: host, path: path) httpMock.addResponse( .file(url: url, status: .ok, headers: ["Cache-Control": "no-store"], contentType: "application/custom"), for: key @@ -331,7 +331,7 @@ struct HTTPMockTests { @Test func lifetime_single_isConsumedOnce() async throws { - let key = createMockKey(path: "/lifetime-single") + let key = makeKey(path: "/lifetime-single") httpMock.addResponse(.plaintext("once", lifetime: .single), for: key) #expect(mockQueues[key]?.count == 1) @@ -350,7 +350,7 @@ struct HTTPMockTests { @Test func lifetime_multiple_isConsumedNTimes_thenRemoved() async throws { - let key = createMockKey(path: "/lifetime-multi") + let key = makeKey(path: "/lifetime-multi") httpMock.addResponse(.plaintext("multi", lifetime: .multiple(3)), for: key) #expect(mockQueues[key]?.count == 1) @@ -374,7 +374,7 @@ struct HTTPMockTests { @Test func lifetime_eternal_isNeverRemoved() async throws { - let key = createMockKey(path: "/lifetime-eternal") + let key = makeKey(path: "/lifetime-eternal") httpMock.addResponse(.plaintext("eternal", lifetime: .eternal), for: key) #expect(mockQueues[key]?.count == 1) @@ -393,7 +393,7 @@ struct HTTPMockTests { @Test func delivery_immediate_returnsQuickly() async throws { - let key = createMockKey(path: "/delay-immediate") + let key = makeKey(path: "/delay-immediate") httpMock.addResponse(.plaintext("ok", delivery: .instant), for: key) let url = try #require(URL(string: "https://example.com/delay-immediate")) @@ -410,7 +410,7 @@ struct HTTPMockTests { @Test func delivery_delayed_respectsInterval() async throws { - let key = createMockKey(path: "/delay-300ms") + let key = makeKey(path: "/delay-300ms") httpMock.addResponse(.plaintext("slow", delivery: .delayed(0.3)), for: key) let url = try #require(URL(string: "https://example.com/delay-300ms")) @@ -427,7 +427,7 @@ struct HTTPMockTests { @Test func delivery_appliesPerResponse_inFifoOrder() async throws { - let key = createMockKey(path: "/delay-sequence") + let key = makeKey(path: "/delay-sequence") httpMock.addResponse(.plaintext("requested-first-but-delivered-second", delivery: .delayed(0.5)), for: key) httpMock.addResponse(.plaintext("requested-second-but-delivered-first", delivery: .instant), for: key) @@ -546,4 +546,18 @@ struct HTTPMockTests { try contents.write(to: url, options: .atomic) return url } + + private func makeKey( + host: String = "example.com", + path: String = "/", + queryItems: [String : String]? = nil, + queryMatching: QueryMatching = .exact + ) -> HTTPMockURLProtocol.Key { + HTTPMockURLProtocol.Key( + host: host, + path: path, + queryItems: queryItems, + queryMatching: queryMatching + ) + } } diff --git a/Tests/HTTPMockTests/Helpers/UtilMethods.swift b/Tests/HTTPMockTests/Helpers/UtilMethods.swift deleted file mode 100644 index 9518212..0000000 --- a/Tests/HTTPMockTests/Helpers/UtilMethods.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation -@testable import HTTPMock - -func createMockKey( - host: String = "example.com", - path: String = "/", - queryItems: [String : String]? = nil, - queryMatching: QueryMatching = .exact -) -> HTTPMockURLProtocol.Key { - .init(host: host, path: path, queryItems: queryItems, queryMatching: queryMatching) -}