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 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/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. 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+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 304b8cf..dcc1b0c 100644 --- a/Sources/HTTPMock/HTTPMock.swift +++ b/Sources/HTTPMock/HTTPMock.swift @@ -6,14 +6,19 @@ 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) } } - private init() { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [HTTPMockURLProtocol.self] - urlSession = URLSession(configuration: configuration) + let mockIdentifier: UUID + + public convenience init() { + self.init(identifier: UUID()) + } + + required init(identifier mockIdentifier: 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. @@ -28,7 +33,8 @@ public final class HTTPMock { forHost: defaultDomain, path: normalized(path), queryItems: queryItems, - queryMatching: queryMatching + queryMatching: queryMatching, + forMockIdentifier: mockIdentifier ) } @@ -45,7 +51,8 @@ public final class HTTPMock { forHost: host, path: normalized(path), queryItems: queryItems, - queryMatching: queryMatching + queryMatching: queryMatching, + forMockIdentifier: mockIdentifier ) } @@ -56,12 +63,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. diff --git a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift index 4d3286e..cc80b7e 100644 --- a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift +++ b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift @@ -1,27 +1,53 @@ import Foundation final class HTTPMockURLProtocol: URLProtocol { - static var queues: [Key: [MockResponse]] = [:] - static var unmockedPolicy: UnmockedPolicy = .notFound + private static var queues: [UUID: [Key: [MockResponse]]] = [:] + 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]]) { + queueLock.sync { + queues[mockIdentifier] = queue + } + } + + static func getQueue(for mockIdentifier: UUID) -> [Key: [MockResponse]] { + queueLock.sync { + queues[mockIdentifier] ?? [:] + } + } + /// Clear all queues – basically a reset. - static func clearQueues() { - lock.sync { - queues.removeAll() + static func clearQueues(mockIdentifier: UUID) { + queueLock.sync { + queues[mockIdentifier]?.removeAll() } } /// Clear the response queue for a single host. - static func clearQueue(forHost host: String) { - lock.sync { - queues = queues.filter { $0.key.host != host } + static func clearQueue(forHost host: String, mockIdentifier: UUID) { + queueLock.sync { + guard let mockQueues = queues[mockIdentifier] else { return } + queues[mockIdentifier] = mockQueues.filter { $0.key.host != host } } } @@ -30,38 +56,43 @@ 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) { - lock.sync { - let responses = givenResponses.filter(\.hasValidLifetime) + static func add( + responses givenResponses: [MockResponse], + forKey key: Key, + forMockIdentifier mockIdentifier: UUID + ) { + 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 queue = queues[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) - queues[key] = queue + 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 @@ -82,6 +113,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 +136,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, @@ -129,7 +167,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( @@ -176,51 +214,56 @@ final class HTTPMockURLProtocol: URLProtocol { // MARK: - Private methods private static func pop( + mockIdentifier: UUID, host: String, path: String, query: [String: String] ) -> MockResponse? { - lock.sync { - // Find the first key matching host+path(+query). - let matchingKey = queues.keys.first { - matches($0, host: host, path: path, query: query) - } + var mockQueues = getQueue(for: mockIdentifier) - if let matchingKey { - guard var queue = queues[matchingKey], !queue.isEmpty else { - return nil - } + // Find the first key matching host+path(+query). + let matchingKey = mockQueues.keys.first { + matches($0, host: host, path: path, query: query) + } - let first = queue.removeFirst() - switch first.lifetime { - case .single: - queues[matchingKey] = queue - 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 - return nil - case 1: - queues[matchingKey] = queue - default: - let copy = first.copyWithNewLifetime(.multiple(count - 1)) - queues[matchingKey] = [copy] + queue - HTTPMockLog.info("Mock response will be used \(count) more time(s) for \(mockKeyDescription(matchingKey))") - return copy - } - case .eternal: - return first - } + if let matchingKey { + guard var queue = mockQueues[matchingKey], !queue.isEmpty else { + return nil + } - if queue.isEmpty { - HTTPMockLog.info("Queue now depleted for \(mockKeyDescription(matchingKey))") + let first = queue.removeFirst() + switch first.lifetime { + case .single: + mockQueues[matchingKey] = queue + setQueue(for: 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 + case 1: + mockQueues[matchingKey] = queue + 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( @@ -261,13 +304,16 @@ extension HTTPMockURLProtocol { "\(key.host)\(key.path) \(describeQuery(key.queryItems, key.queryMatching))" } - private static func queueSize(host: String, path: String, query: [String: String]) -> Int { - lock.sync { - queues - .filter { matches($0.key, host: host, path: path, query: query) } - .map(\.value.count) - .first ?? 0 - } + private static func queueSize( + mockIdentifier: UUID, + host: String, + path: String, + query: [String: String] + ) -> Int { + getQueue(for: mockIdentifier) + .filter { matches($0.key, host: host, path: path, query: query) } + .map(\.value.count) + .first ?? 0 } private static func describeQuery( diff --git a/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift b/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift index 9c25777..fd2a968 100644 --- a/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift +++ b/Tests/HTTPMockTests/HTTPMockResultBuilderTests.swift @@ -4,14 +4,16 @@ import Foundation struct HTTPMockResultBuilderTests { let httpMock: HTTPMock + let identifier: UUID + var mockQueues: [HTTPMockURLProtocol.Key: [MockResponse]] { - HTTPMockURLProtocol.queues + HTTPMockURLProtocol.getQueue(for: 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..3c24d4e 100644 --- a/Tests/HTTPMockTests/HTTPMockTests.swift +++ b/Tests/HTTPMockTests/HTTPMockTests.swift @@ -4,14 +4,16 @@ import Foundation struct HTTPMockTests { let httpMock: HTTPMock + let identifier: UUID + var mockQueues: [HTTPMockURLProtocol.Key: [MockResponse]] { - HTTPMockURLProtocol.queues + HTTPMockURLProtocol.getQueue(for: identifier) } init() { - httpMock = HTTPMock.shared + identifier = UUID() + httpMock = HTTPMock(identifier: identifier) httpMock.defaultDomain = "example.com" - HTTPMock.shared.clearQueues() HTTPMockLog.level = .trace } @@ -426,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")) @@ -451,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 {