From 05dec9767c35f57530e0f82188d0d48a2a51ce84 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sun, 31 Aug 2025 10:55:59 +0200 Subject: [PATCH 01/12] Associate a known identifier with our URLSession --- .../Extensions/URLSession+Extensions.swift | 22 +++++++++++++++++++ Sources/HTTPMock/HTTPMock.swift | 9 ++++---- 2 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 Sources/HTTPMock/Extensions/URLSession+Extensions.swift diff --git a/Sources/HTTPMock/Extensions/URLSession+Extensions.swift b/Sources/HTTPMock/Extensions/URLSession+Extensions.swift new file mode 100644 index 0000000..3bddadf --- /dev/null +++ b/Sources/HTTPMock/Extensions/URLSession+Extensions.swift @@ -0,0 +1,22 @@ +import Foundation +import ObjectiveC.runtime + +private var MockIdentifierKey: UInt8 = 0 + +extension URLSession { + /// Attach/read a UUID on a URLSession instance. + var mockIdentifier: UUID? { + get { objc_getAssociatedObject(self, &MockIdentifierKey) as? UUID } + set { objc_setAssociatedObject(self, &MockIdentifierKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + static func identifiedSession(with identifier: UUID) -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [HTTPMockURLProtocol.self] + + let urlSession = URLSession(configuration: configuration) + urlSession.mockIdentifier = identifier + + return urlSession + } +} diff --git a/Sources/HTTPMock/HTTPMock.swift b/Sources/HTTPMock/HTTPMock.swift index 304b8cf..a047def 100644 --- a/Sources/HTTPMock/HTTPMock.swift +++ b/Sources/HTTPMock/HTTPMock.swift @@ -10,10 +10,11 @@ public final class HTTPMock { set { HTTPMockURLProtocol.unmockedPolicy = newValue } } - private init() { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [HTTPMockURLProtocol.self] - urlSession = URLSession(configuration: configuration) + let mockIdentifier: UUID + + init(identifier mockIdentifier: UUID = UUID()) { + self.mockIdentifier = mockIdentifier + urlSession = URLSession.identifiedSession(with: mockIdentifier) } /// Queue responses for a given path (e.g. "/some-path") for host in `defaultDomain`. Each request will pop the next response. From 98afde52b77506ac7db429aa06a71bfd04178663 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sun, 31 Aug 2025 10:56:37 +0200 Subject: [PATCH 02/12] HTTPMockURLProtocol: Read and use mockIdentifier when handling requests --- .../Internal/HTTPMockURLProtocol.swift | 70 +++++++++++++------ 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift index 4d3286e..9f8ef9b 100644 --- a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift +++ b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift @@ -1,7 +1,7 @@ import Foundation final class HTTPMockURLProtocol: URLProtocol { - static var queues: [Key: [MockResponse]] = [:] + static var queues: [UUID: [Key: [MockResponse]]] = [:] static var unmockedPolicy: UnmockedPolicy = .notFound private static let handledKey = "HTTPMockHandled" private static let lock = DispatchQueue(label: "MockURLProtocol.lock") @@ -12,16 +12,17 @@ final class HTTPMockURLProtocol: URLProtocol { // MARK: - Internal methods /// Clear all queues – basically a reset. - static func clearQueues() { + static func clearQueues(mockIdentifier: UUID) { lock.sync { - queues.removeAll() + queues[mockIdentifier]?.removeAll() } } /// Clear the response queue for a single host. - static func clearQueue(forHost host: String) { + static func clearQueue(forHost host: String, mockIdentifier: UUID) { lock.sync { - queues = queues.filter { $0.key.host != host } + guard let mockQueues = queues[mockIdentifier] else { return } + queues[mockIdentifier] = mockQueues.filter { $0.key.host != host } } } @@ -30,13 +31,18 @@ final class HTTPMockURLProtocol: URLProtocol { forHost host: String, path: String, queryItems: [String: String]? = nil, - queryMatching: QueryMatching = .exact + queryMatching: QueryMatching = .exact, + forMockIdentifier mockIdentifier: UUID ) { let key = Key(host: host, path: path, queryItems: queryItems, queryMatching: queryMatching) - add(responses: responses, forKey: key) + add(responses: responses, forKey: key, forMockIdentifier: mockIdentifier) } - static func add(responses givenResponses: [MockResponse], forKey key: Key) { + static func add( + responses givenResponses: [MockResponse], + forKey key: Key, + forMockIdentifier mockIdentifier: UUID + ) { lock.sync { let responses = givenResponses.filter(\.hasValidLifetime) @@ -49,7 +55,8 @@ final class HTTPMockURLProtocol: URLProtocol { return } - var queue = queues[key] ?? [] + var mockQueue = queues[mockIdentifier] ?? [:] + var queue = mockQueue[key] ?? [] // Let user know if they're trying to insert responses after an eternal mock. if queue.contains(where: \.isEternal) { @@ -57,7 +64,8 @@ final class HTTPMockURLProtocol: URLProtocol { } queue.append(contentsOf: responses) - queues[key] = queue + mockQueue[key] = queue + queues[mockIdentifier] = mockQueue HTTPMockLog.info("Registered \(responses.count) response(s) for \(mockKeyDescription(key))") HTTPMockLog.debug("Current queue size for \(key.host)\(key.path): \(queue.count)") @@ -82,6 +90,13 @@ final class HTTPMockURLProtocol: URLProtocol { } override func startLoading() { + guard + let urlSession = task?.value(forKey: "session") as? URLSession, + let mockIdentifier = urlSession.mockIdentifier + else { + fatalError("Could not find mock identifier for URLSession") + } + guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false), @@ -98,12 +113,12 @@ final class HTTPMockURLProtocol: URLProtocol { HTTPMockLog.trace("Handling request → \(requestDescription)") // Look for, and pop, the next queued response mathing host, path and query params. - if let mock = Self.pop(host: host, path: path, query: queryDict) { + if let mock = Self.pop(mockIdentifier: mockIdentifier, host: host, path: path, query: queryDict) { 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(host: host, path: path, query: queryDict))") + HTTPMockLog.debug("Remaining queue for \(requestDescription): \(Self.queueSize(mockIdentifier: mockIdentifier, host: host, path: path, query: queryDict))") let response = HTTPURLResponse( url: url, @@ -176,36 +191,45 @@ final class HTTPMockURLProtocol: URLProtocol { // MARK: - Private methods private static func pop( + mockIdentifier: UUID, host: String, path: String, query: [String: String] ) -> MockResponse? { lock.sync { + guard var mockQueues = queues[mockIdentifier] else { + return nil + } + // Find the first key matching host+path(+query). - let matchingKey = queues.keys.first { + let matchingKey = mockQueues.keys.first { matches($0, host: host, path: path, query: query) } if let matchingKey { - guard var queue = queues[matchingKey], !queue.isEmpty else { + guard var queue = mockQueues[matchingKey], !queue.isEmpty else { return nil } let first = queue.removeFirst() switch first.lifetime { case .single: - queues[matchingKey] = queue + mockQueues[matchingKey] = queue + queues[mockIdentifier] = mockQueues case .multiple(let count): switch count { case _ where count < 0, 0: // Ignore this mock if lifetime count is at, or below, 0. - queues[matchingKey] = queue + mockQueues[matchingKey] = queue + queues[mockIdentifier] = mockQueues return nil case 1: - queues[matchingKey] = queue + mockQueues[matchingKey] = queue + queues[mockIdentifier] = mockQueues default: let copy = first.copyWithNewLifetime(.multiple(count - 1)) - queues[matchingKey] = [copy] + queue + mockQueues[matchingKey] = [copy] + queue + queues[mockIdentifier] = mockQueues HTTPMockLog.info("Mock response will be used \(count) more time(s) for \(mockKeyDescription(matchingKey))") return copy } @@ -261,9 +285,15 @@ extension HTTPMockURLProtocol { "\(key.host)\(key.path) \(describeQuery(key.queryItems, key.queryMatching))" } - private static func queueSize(host: String, path: String, query: [String: String]) -> Int { + private static func queueSize( + mockIdentifier: UUID, + host: String, + path: String, + query: [String: String] + ) -> Int { lock.sync { - queues + guard let mockQueues = queues[mockIdentifier] else { return 0 } + return mockQueues .filter { matches($0.key, host: host, path: path, query: query) } .map(\.value.count) .first ?? 0 From 8494562bf6561c364df740498f8043650c1e40eb Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sun, 31 Aug 2025 10:56:52 +0200 Subject: [PATCH 03/12] Pass mockIdentifier when registering mocks --- Sources/HTTPMock/HTTPMock+ResultBuilder.swift | 4 ++-- Sources/HTTPMock/HTTPMock.swift | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/HTTPMock/HTTPMock+ResultBuilder.swift b/Sources/HTTPMock/HTTPMock+ResultBuilder.swift index 2ca5577..59fbb5f 100644 --- a/Sources/HTTPMock/HTTPMock+ResultBuilder.swift +++ b/Sources/HTTPMock/HTTPMock+ResultBuilder.swift @@ -17,7 +17,7 @@ extension HTTPMock { queryItems: registration.queryItems, queryMatching: registration.queryMatching ?? .exact ) - HTTPMockURLProtocol.add(responses: finalResponses, forKey: key) + HTTPMockURLProtocol.add(responses: finalResponses, forKey: key, forMockIdentifier: mockIdentifier) } } } @@ -38,7 +38,7 @@ extension HTTPMock { queryItems: registration.queryItems, queryMatching: registration.queryMatching ?? .exact ) - HTTPMockURLProtocol.add(responses: finalResponses, forKey: key) + HTTPMockURLProtocol.add(responses: finalResponses, forKey: key, forMockIdentifier: mockIdentifier) } } diff --git a/Sources/HTTPMock/HTTPMock.swift b/Sources/HTTPMock/HTTPMock.swift index a047def..dd30d1b 100644 --- a/Sources/HTTPMock/HTTPMock.swift +++ b/Sources/HTTPMock/HTTPMock.swift @@ -29,7 +29,8 @@ public final class HTTPMock { forHost: defaultDomain, path: normalized(path), queryItems: queryItems, - queryMatching: queryMatching + queryMatching: queryMatching, + forMockIdentifier: mockIdentifier ) } @@ -46,7 +47,8 @@ public final class HTTPMock { forHost: host, path: normalized(path), queryItems: queryItems, - queryMatching: queryMatching + queryMatching: queryMatching, + forMockIdentifier: mockIdentifier ) } @@ -57,12 +59,12 @@ public final class HTTPMock { /// Clear all queues – basically a reset. public func clearQueues() { - HTTPMockURLProtocol.clearQueues() + HTTPMockURLProtocol.clearQueues(mockIdentifier: mockIdentifier) } /// Clear the response queue for a single host. public func clearQueue(forHost host: String) { - HTTPMockURLProtocol.clearQueue(forHost: host) + HTTPMockURLProtocol.clearQueue(forHost: host, mockIdentifier: mockIdentifier) } /// Makes sure all paths are prefixed with `/`. We need this for consistency when looking up responses from the queue. From 8c2da99b757f5a6c4f6f01d6864df20b72d0ccf6 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sun, 31 Aug 2025 10:58:30 +0200 Subject: [PATCH 04/12] Update tests to use separate instances and to run in parallel --- HTTPMock.xctestplan | 1 - Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift | 7 ++++--- Tests/HTTPMockTests/HTTPMockTests.swift | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/HTTPMock.xctestplan b/HTTPMock.xctestplan index 6cfa254..0d37b86 100644 --- a/HTTPMock.xctestplan +++ b/HTTPMock.xctestplan @@ -13,7 +13,6 @@ }, "testTargets" : [ { - "parallelizable" : false, "target" : { "containerPath" : "container:", "identifier" : "HTTPMockTests", diff --git a/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift b/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift index 9c25777..d71d68d 100644 --- a/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift +++ b/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift @@ -4,14 +4,15 @@ import Foundation struct HTTPMockResultBuilderTests { let httpMock: HTTPMock + let identifier: UUID var mockQueues: [HTTPMockURLProtocol.Key: [MockResponse]] { - HTTPMockURLProtocol.queues + HTTPMockURLProtocol.queues[identifier] ?? [:] } init() { - httpMock = HTTPMock.shared + identifier = UUID() + httpMock = HTTPMock(identifier: identifier) httpMock.defaultDomain = "example.com" - HTTPMock.shared.clearQueues() HTTPMockLog.level = .trace } diff --git a/Tests/HTTPMockTests/HTTPMockTests.swift b/Tests/HTTPMockTests/HTTPMockTests.swift index b9ad04b..2c27916 100644 --- a/Tests/HTTPMockTests/HTTPMockTests.swift +++ b/Tests/HTTPMockTests/HTTPMockTests.swift @@ -4,14 +4,15 @@ import Foundation struct HTTPMockTests { let httpMock: HTTPMock + let identifier: UUID var mockQueues: [HTTPMockURLProtocol.Key: [MockResponse]] { - HTTPMockURLProtocol.queues + HTTPMockURLProtocol.queues[identifier] ?? [:] } init() { - httpMock = HTTPMock.shared + identifier = UUID() + httpMock = HTTPMock(identifier: identifier) httpMock.defaultDomain = "example.com" - HTTPMock.shared.clearQueues() HTTPMockLog.level = .trace } From a8fc7f40f78af5a0ad2a53a8a202106be5400338 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sun, 31 Aug 2025 11:03:53 +0200 Subject: [PATCH 05/12] Cleanup lock behavior --- .../Internal/HTTPMockURLProtocol.swift | 99 ++++++++++--------- 1 file changed, 52 insertions(+), 47 deletions(-) diff --git a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift index 9f8ef9b..c51b54f 100644 --- a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift +++ b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift @@ -11,6 +11,18 @@ final class HTTPMockURLProtocol: URLProtocol { // MARK: - Internal methods + static func setQueue(for mockIdentifier: UUID, _ queue: [Key: [MockResponse]]) { + lock.sync { + queues[mockIdentifier] = queue + } + } + + static func getQueue(for mockIdentifier: UUID) -> [Key: [MockResponse]] { + lock.sync { + queues[mockIdentifier] ?? [:] + } + } + /// Clear all queues – basically a reset. static func clearQueues(mockIdentifier: UUID) { lock.sync { @@ -196,55 +208,51 @@ final class HTTPMockURLProtocol: URLProtocol { path: String, query: [String: String] ) -> MockResponse? { - lock.sync { - guard var mockQueues = queues[mockIdentifier] else { - return nil - } + var mockQueues = getQueue(for: mockIdentifier) + + // Find the first key matching host+path(+query). + let matchingKey = mockQueues.keys.first { + matches($0, host: host, path: path, query: query) + } - // Find the first key matching host+path(+query). - let matchingKey = mockQueues.keys.first { - matches($0, host: host, path: path, query: query) + if let matchingKey { + guard var queue = mockQueues[matchingKey], !queue.isEmpty else { + return nil } - if let matchingKey { - guard var queue = mockQueues[matchingKey], !queue.isEmpty else { + let first = queue.removeFirst() + switch first.lifetime { + case .single: + mockQueues[matchingKey] = queue + queues[mockIdentifier] = mockQueues + case .multiple(let count): + switch count { + case _ where count < 0, 0: + // Ignore this mock if lifetime count is at, or below, 0. + mockQueues[matchingKey] = queue + setQueue(for: mockIdentifier, mockQueues) return nil - } - - let first = queue.removeFirst() - switch first.lifetime { - case .single: + case 1: mockQueues[matchingKey] = queue - queues[mockIdentifier] = mockQueues - case .multiple(let count): - switch count { - case _ where count < 0, 0: - // Ignore this mock if lifetime count is at, or below, 0. - mockQueues[matchingKey] = queue - queues[mockIdentifier] = mockQueues - return nil - case 1: - mockQueues[matchingKey] = queue - queues[mockIdentifier] = mockQueues - default: - let copy = first.copyWithNewLifetime(.multiple(count - 1)) - mockQueues[matchingKey] = [copy] + queue - queues[mockIdentifier] = mockQueues - HTTPMockLog.info("Mock response will be used \(count) more time(s) for \(mockKeyDescription(matchingKey))") - return copy - } - case .eternal: - return first - } - - if queue.isEmpty { - HTTPMockLog.info("Queue now depleted for \(mockKeyDescription(matchingKey))") + setQueue(for: mockIdentifier, mockQueues) + default: + 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))") + return copy } - + case .eternal: return first } - return nil + + if queue.isEmpty { + HTTPMockLog.info("Queue now depleted for \(mockKeyDescription(matchingKey))") + } + + return first } + return nil } private static func matches( @@ -291,13 +299,10 @@ extension HTTPMockURLProtocol { path: String, query: [String: String] ) -> Int { - lock.sync { - guard let mockQueues = queues[mockIdentifier] else { return 0 } - return mockQueues - .filter { matches($0.key, host: host, path: path, query: query) } - .map(\.value.count) - .first ?? 0 - } + getQueue(for: mockIdentifier) + .filter { matches($0.key, host: host, path: path, query: query) } + .map(\.value.count) + .first ?? 0 } private static func describeQuery( From 1c399f1282e01f069576ff508a879451560b67b3 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sun, 31 Aug 2025 11:17:01 +0200 Subject: [PATCH 06/12] Update README.md --- README.md | 43 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1c740c8..c840c33 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,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. -- **Singleton API**: `HTTPMock.shared` is the only instance you need. No global state leaks between tests: clear with `clearQueues()`. -- **Works with real `URLSession`**: inject `HTTPMock.shared.urlSession` into the code under test. +- **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`). - **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. @@ -35,7 +35,7 @@ A tiny, test-first way to mock `URLSession` — **fast to set up, easy to read, Add this package to your test target: ```swift -.package(url: "https://github.com/bstien/HTTPMock.git", from: "0.0.1") +.package(url: "https://github.com/bstien/HTTPMock.git", from: "0.0.3") ``` ## Quick start @@ -193,12 +193,43 @@ HTTPMock.shared.clearQueues() HTTPMock.shared.clearQueue(forHost: "domain.com") ``` +## Singleton vs. separate instances +You can use the global singleton `HTTPMock.shared` for simplicity in most cases. However, if you need isolated queues to, for example, run parallel tests or maintain different mock configurations you can create separate instances with `HTTPMock()`. + +Each instance maintains their own queue and properties, and they have no connection to each other. + +Example: + +```swift +// Using the singleton +HTTPMock.shared.registerResponses { + Host("api.example.com") { + Path("/user") { + MockResponse.plaintext("Hello from singleton!") + } + } +} +let singletonSession = HTTPMock.shared.urlSession + +// Using a separate instance. +let mockInstance = HTTPMock() +mockInstance.registerResponses { + Host("api.example.com") { + Path("/user") { + MockResponse.plaintext("Hello from instance!") + } + } +} +let instanceSession = mockInstance.urlSession +``` + + ## FAQs **Can I run tests that use `HTTPMock` in parallel?** -No, currently only a single instance of `HTTPMock` can exist, so tests must be run sequentially. +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. **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` 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. @@ -237,6 +268,6 @@ Path("/user") { - [X] Allow for passthrough networking when mock hasn't been registered for the incoming URL. - [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. - [ ] Let user configure a default "not found" response. Will be used either when no matching mocks are found or if queue is empty. -- [ ] Create separate instances of `HTTPMock`. The current single instance requires tests to be run in sequence, instead of parallel. - [ ] Does arrays in query parameters work? I think they're being overwritten with the current setup. From 8125b45da7f145ae3a2bb96e6e200fc8cbb4b80e Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sun, 31 Aug 2025 11:17:08 +0200 Subject: [PATCH 07/12] HTTPMock: Add public init --- Sources/HTTPMock/HTTPMock.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/HTTPMock/HTTPMock.swift b/Sources/HTTPMock/HTTPMock.swift index dd30d1b..8f01be8 100644 --- a/Sources/HTTPMock/HTTPMock.swift +++ b/Sources/HTTPMock/HTTPMock.swift @@ -12,7 +12,11 @@ public final class HTTPMock { let mockIdentifier: UUID - init(identifier mockIdentifier: UUID = UUID()) { + public convenience init() { + self.init(identifier: UUID()) + } + + required init(identifier mockIdentifier: UUID) { self.mockIdentifier = mockIdentifier urlSession = URLSession.identifiedSession(with: mockIdentifier) } From 8c3987ffa6d9b5c24da88c700fef4739b3536fc4 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sun, 31 Aug 2025 11:22:15 +0200 Subject: [PATCH 08/12] Place unmockedPolicy settings in a dictionary --- Sources/HTTPMock/HTTPMock.swift | 4 +-- .../Internal/HTTPMockURLProtocol.swift | 30 ++++++++++++++----- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/Sources/HTTPMock/HTTPMock.swift b/Sources/HTTPMock/HTTPMock.swift index 8f01be8..dcc1b0c 100644 --- a/Sources/HTTPMock/HTTPMock.swift +++ b/Sources/HTTPMock/HTTPMock.swift @@ -6,8 +6,8 @@ public final class HTTPMock { public var defaultDomain = "example.com" public var unmockedPolicy: UnmockedPolicy { - get { HTTPMockURLProtocol.unmockedPolicy } - set { HTTPMockURLProtocol.unmockedPolicy = newValue } + get { HTTPMockURLProtocol.getUnmockedPolicy(for: mockIdentifier) } + set { HTTPMockURLProtocol.setUnmockedPolicy(for: mockIdentifier, newValue) } } let mockIdentifier: UUID diff --git a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift index c51b54f..4ac6140 100644 --- a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift +++ b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift @@ -2,37 +2,51 @@ import Foundation final class HTTPMockURLProtocol: URLProtocol { static var queues: [UUID: [Key: [MockResponse]]] = [:] - static var unmockedPolicy: UnmockedPolicy = .notFound + + private static var unmockedPolicyStorage: [UUID: UnmockedPolicy] = [:] private static let handledKey = "HTTPMockHandled" - private static let lock = DispatchQueue(label: "MockURLProtocol.lock") + private static let queueLock = DispatchQueue(label: "MockURLProtocol.queueLock") + private static let unmockedPolicyLock = DispatchQueue(label: "MockURLProtocol.unmockedPolicyLock") /// A plain session without `HTTPMockURLProtocol` to support passthrough of requests when policy requires it. private lazy var passthroughSession: URLSession = URLSession(configuration: .ephemeral) // MARK: - Internal methods + static func getUnmockedPolicy(for mockIdentifier: UUID) -> UnmockedPolicy { + unmockedPolicyLock.sync { + unmockedPolicyStorage[mockIdentifier] ?? .notFound + } + } + + static func setUnmockedPolicy(for mockIdentifier: UUID, _ unmockedPolicy: UnmockedPolicy) { + unmockedPolicyLock.sync { + unmockedPolicyStorage[mockIdentifier] = unmockedPolicy + } + } + static func setQueue(for mockIdentifier: UUID, _ queue: [Key: [MockResponse]]) { - lock.sync { + queueLock.sync { queues[mockIdentifier] = queue } } static func getQueue(for mockIdentifier: UUID) -> [Key: [MockResponse]] { - lock.sync { + queueLock.sync { queues[mockIdentifier] ?? [:] } } /// Clear all queues – basically a reset. static func clearQueues(mockIdentifier: UUID) { - lock.sync { + queueLock.sync { queues[mockIdentifier]?.removeAll() } } /// Clear the response queue for a single host. static func clearQueue(forHost host: String, mockIdentifier: UUID) { - lock.sync { + queueLock.sync { guard let mockQueues = queues[mockIdentifier] else { return } queues[mockIdentifier] = mockQueues.filter { $0.key.host != host } } @@ -55,7 +69,7 @@ final class HTTPMockURLProtocol: URLProtocol { forKey key: Key, forMockIdentifier mockIdentifier: UUID ) { - lock.sync { + queueLock.sync { let responses = givenResponses.filter(\.hasValidLifetime) guard !responses.isEmpty else { @@ -156,7 +170,7 @@ final class HTTPMockURLProtocol: URLProtocol { DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay, execute: sendResponse) } } else { - switch Self.unmockedPolicy { + switch Self.getUnmockedPolicy(for: mockIdentifier) { case .notFound: HTTPMockLog.error("No mock found for \(requestDescription) — returning 404") let resp = HTTPURLResponse( From c16f5742081fb8df480303465e38732f0e1af754 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sun, 31 Aug 2025 11:41:01 +0200 Subject: [PATCH 09/12] Add tests for multiple instances --- .../HTTPMockResultBuilderTests.swift | 3 +- Tests/HTTPMockTests/HTTPMockTests.swift | 87 ++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift b/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift index d71d68d..fd2a968 100644 --- a/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift +++ b/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift @@ -5,8 +5,9 @@ import Foundation struct HTTPMockResultBuilderTests { let httpMock: HTTPMock let identifier: UUID + var mockQueues: [HTTPMockURLProtocol.Key: [MockResponse]] { - HTTPMockURLProtocol.queues[identifier] ?? [:] + HTTPMockURLProtocol.getQueue(for: identifier) } init() { diff --git a/Tests/HTTPMockTests/HTTPMockTests.swift b/Tests/HTTPMockTests/HTTPMockTests.swift index 2c27916..201d582 100644 --- a/Tests/HTTPMockTests/HTTPMockTests.swift +++ b/Tests/HTTPMockTests/HTTPMockTests.swift @@ -5,8 +5,9 @@ import Foundation struct HTTPMockTests { let httpMock: HTTPMock let identifier: UUID + var mockQueues: [HTTPMockURLProtocol.Key: [MockResponse]] { - HTTPMockURLProtocol.queues[identifier] ?? [:] + HTTPMockURLProtocol.getQueue(for: identifier) } init() { @@ -452,6 +453,90 @@ struct HTTPMockTests { #expect(mockQueues[key]?.isEmpty == true) } + + // MARK: - Multiple instance isolation + + @Test + func instances_haveIsolatedQueues() { + // Current test instance has its own identifier/queues + #expect(mockQueues.isEmpty) + + // Create a separate instance with its own identifier + let otherIdentifier = UUID() + let other = HTTPMock(identifier: otherIdentifier) + + // Register responses in both + httpMock.addResponses(forPath: "/a", host: "one.example.com", responses: [.empty()]) + other.addResponses(forPath: "/b", host: "two.example.com", responses: [.empty()]) + + // Queues are stored per-identifier; they should not mix + let queues1 = HTTPMockURLProtocol.getQueue(for: identifier) + let queues2 = HTTPMockURLProtocol.getQueue(for: otherIdentifier) + + #expect(queues1.count == 1) + #expect(queues2.count == 1) + #expect(Set(queues1.keys.map { $0.host }) == ["one.example.com"]) + #expect(Set(queues2.keys.map { $0.host }) == ["two.example.com"]) + } + + @Test + func instances_doNotCrossServeResponses() async throws { + // Two isolated mocks + let idA = UUID() + let mockA = HTTPMock(identifier: idA) + mockA.defaultDomain = "api.a.com" + + let idB = UUID() + let mockB = HTTPMock(identifier: idB) + mockB.defaultDomain = "api.b.com" + + // Same path on different hosts, different payloads, and both responses eternal + mockA.addResponses(forPath: "/ping", responses: [.plaintext("A", lifetime: .eternal)]) + mockB.addResponses(forPath: "/ping", responses: [.plaintext("B", lifetime: .eternal)]) + + let urlA = try #require(URL(string: "https://api.a.com/ping")) + let urlB = try #require(URL(string: "https://api.b.com/ping")) + + let (dataA, responseA) = try await mockA.urlSession.data(from: urlA) + #expect(responseA.httpStatusCode == 200) + #expect(dataA.toString == "A") + + let (dataB, responseB) = try await mockB.urlSession.data(from: urlB) + #expect(responseB.httpStatusCode == 200) + #expect(dataB.toString == "B") + + // Cross-call: mockA hitting B's URL should be 404 (no queue in A for that host) + let (_, crossResponse1) = try await mockA.urlSession.data(from: urlB) + #expect(crossResponse1.httpStatusCode == 404) + + // And mockB hitting A's URL should be 404 + let (_, crossResponse2) = try await mockB.urlSession.data(from: urlA) + #expect(crossResponse2.httpStatusCode == 404) + } + + @Test + func clearingOneInstanceDoesNotAffectAnother() { + let otherId = UUID() + let other = HTTPMock(identifier: otherId) + + httpMock.addResponses(forPath: "/x", host: "x.com", responses: [.empty()]) + other.addResponses(forPath: "/y", host: "y.com", responses: [.empty()]) + + // Sanity + #expect(HTTPMockURLProtocol.getQueue(for: identifier).count == 1) + #expect(HTTPMockURLProtocol.getQueue(for: otherId).count == 1) + + // Clear only this test instance + httpMock.clearQueues() + + #expect(HTTPMockURLProtocol.getQueue(for: identifier).isEmpty) + #expect(HTTPMockURLProtocol.getQueue(for: otherId).count == 1) + + // Now clear the other; both should be empty + other.clearQueues() + #expect(HTTPMockURLProtocol.getQueue(for: otherId).isEmpty) + } + // MARK: - Helpers private func writeTempFile(named: String, ext: String, contents: Data) throws -> URL { From d254a4c80c6167402e2abb01f5aa104d6bbac415 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sun, 31 Aug 2025 11:47:13 +0200 Subject: [PATCH 10/12] Update lock handling --- .../Internal/HTTPMockURLProtocol.swift | 45 +++++++++---------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift index 4ac6140..cc80b7e 100644 --- a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift +++ b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift @@ -1,8 +1,7 @@ import Foundation final class HTTPMockURLProtocol: URLProtocol { - static var queues: [UUID: [Key: [MockResponse]]] = [:] - + private static var queues: [UUID: [Key: [MockResponse]]] = [:] private static var unmockedPolicyStorage: [UUID: UnmockedPolicy] = [:] private static let handledKey = "HTTPMockHandled" private static let queueLock = DispatchQueue(label: "MockURLProtocol.queueLock") @@ -69,33 +68,31 @@ final class HTTPMockURLProtocol: URLProtocol { forKey key: Key, forMockIdentifier mockIdentifier: UUID ) { - queueLock.sync { - let responses = givenResponses.filter(\.hasValidLifetime) + let responses = givenResponses.filter(\.hasValidLifetime) - guard !responses.isEmpty else { - if givenResponses.isEmpty { - HTTPMockLog.trace("No valid responses provided. Skipping registration.") - } else { - HTTPMockLog.trace("\(givenResponses.count) response(s) provided, but none were valid. Skipping registration.") - } - return + guard !responses.isEmpty else { + if givenResponses.isEmpty { + HTTPMockLog.trace("No valid responses provided. Skipping registration.") + } else { + HTTPMockLog.trace("\(givenResponses.count) response(s) provided, but none were valid. Skipping registration.") } + return + } - var mockQueue = queues[mockIdentifier] ?? [:] - var queue = mockQueue[key] ?? [] + var mockQueue = getQueue(for: mockIdentifier) + var queue = mockQueue[key] ?? [] - // 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.") - } + // 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.") + } - queue.append(contentsOf: responses) - mockQueue[key] = queue - queues[mockIdentifier] = mockQueue + queue.append(contentsOf: responses) + mockQueue[key] = queue + setQueue(for: mockIdentifier, mockQueue) - HTTPMockLog.info("Registered \(responses.count) response(s) for \(mockKeyDescription(key))") - HTTPMockLog.debug("Current queue size for \(key.host)\(key.path): \(queue.count)") - } + HTTPMockLog.info("Registered \(responses.count) response(s) for \(mockKeyDescription(key))") + HTTPMockLog.debug("Current queue size for \(key.host)\(key.path): \(queue.count)") } // MARK: - Overrides @@ -238,7 +235,7 @@ final class HTTPMockURLProtocol: URLProtocol { switch first.lifetime { case .single: mockQueues[matchingKey] = queue - queues[mockIdentifier] = mockQueues + setQueue(for: mockIdentifier, mockQueues) case .multiple(let count): switch count { case _ where count < 0, 0: From 46979fdb20d56b35f719548d6a95fb8d3d30a4fd Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sun, 31 Aug 2025 11:47:52 +0200 Subject: [PATCH 11/12] Run tests in parallel on GHA --- .github/workflows/unit-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 1eaef2d..7503f1a 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -21,4 +21,4 @@ jobs: run: swift build - name: Test - run: swift test --no-parallel + run: swift test --parallel From 98c6aa3e0d66a67da29007834bb01385d69ca2c9 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sun, 31 Aug 2025 12:01:44 +0200 Subject: [PATCH 12/12] Increase delay for test delivery_appliesPerResponse_inFifoOrder --- Tests/HTTPMockTests/HTTPMockTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/HTTPMockTests/HTTPMockTests.swift b/Tests/HTTPMockTests/HTTPMockTests.swift index 201d582..3c24d4e 100644 --- a/Tests/HTTPMockTests/HTTPMockTests.swift +++ b/Tests/HTTPMockTests/HTTPMockTests.swift @@ -428,8 +428,8 @@ struct HTTPMockTests { @Test func delivery_appliesPerResponse_inFifoOrder() async throws { let key = createMockKey(path: "/delay-sequence") - httpMock.addResponse(.plaintext("requested-first-but-delivered-second", delivery: .delayed(0.2)), for: key) - httpMock.addResponse(.plaintext("requested-second-but-delivered-first", delivery: .delayed(0.1)), for: key) + 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) let url = try #require(URL(string: "https://example.com/delay-sequence"))