From 35ab4153c59159d81861f4bf8510090d1c64c3c3 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sat, 13 Sep 2025 18:24:09 +0200 Subject: [PATCH 1/6] HTTPMock: Add property passthroughSession --- Sources/HTTPMock/HTTPMock.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/HTTPMock/HTTPMock.swift b/Sources/HTTPMock/HTTPMock.swift index dcc1b0c..93140e1 100644 --- a/Sources/HTTPMock/HTTPMock.swift +++ b/Sources/HTTPMock/HTTPMock.swift @@ -3,6 +3,7 @@ import Foundation public final class HTTPMock { public static let shared = HTTPMock() public let urlSession: URLSession + public let passthroughSession: URLSession public var defaultDomain = "example.com" public var unmockedPolicy: UnmockedPolicy { @@ -16,9 +17,13 @@ public final class HTTPMock { self.init(identifier: UUID()) } - required init(identifier mockIdentifier: UUID) { + required init( + identifier mockIdentifier: UUID, + passthroughSession: URLSession? = nil + ) { self.mockIdentifier = mockIdentifier urlSession = URLSession.identifiedSession(with: mockIdentifier) + self.passthroughSession = passthroughSession ?? URLSession(configuration: .ephemeral) } /// Queue responses for a given path (e.g. "/some-path") for host in `defaultDomain`. Each request will pop the next response. From 13fa3c1742ecd505b2e7cd66f01dcf9bff4c73f7 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sat, 13 Sep 2025 18:25:16 +0200 Subject: [PATCH 2/6] HTTPMockURLProtocol: Keep track of each HTTPMock's passthrough session --- .../Internal/HTTPMockURLProtocol.swift | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift index 8505b46..f807ce7 100644 --- a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift +++ b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift @@ -3,13 +3,12 @@ import Foundation final class HTTPMockURLProtocol: URLProtocol { private static var queues: [UUID: [Key: [MockResponse]]] = [:] private static var unmockedPolicyStorage: [UUID: UnmockedPolicy] = [:] + private static var passthroughSessions: [UUID: URLSession] = [:] private static let matcher = HTTPMockMatcher() private static let handledKey = "HTTPMockHandled" 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(configuration: .ephemeral) + private static let passthroughSessionsLock = DispatchQueue(label: "MockURLProtocol.passthroughSessionsLock") /// **Do not use**. This property is shared between all `HTTPMock` instances and will be set to `nil` after each unmocked call that uses /// the `UnmockedPolicy.fatalError`. This property only exists to be able to test the functionality. @@ -41,6 +40,18 @@ final class HTTPMockURLProtocol: URLProtocol { } } + static func setPassthroughSession(for mockIdentifier: UUID, _ session: URLSession) { + passthroughSessionsLock.sync { + passthroughSessions[mockIdentifier] = session + } + } + + static func getPassthroughSession(for mockIdentifier: UUID) -> URLSession? { + passthroughSessionsLock.sync { + passthroughSessions[mockIdentifier] + } + } + /// Clear all queues – basically a reset. static func clearQueues(mockIdentifier: UUID) { queueLock.sync { @@ -189,6 +200,11 @@ final class HTTPMockURLProtocol: URLProtocol { case .passthrough: HTTPMockLog.info("No mock found for incoming request '\(requestDescription)' — passthrough to network") + + guard let passthroughSession = Self.getPassthroughSession(for: mockIdentifier) else { + fatalError("Could not get passthrough session for mock identifier '\(mockIdentifier)'") + } + var request = request // Set known value on request to prevent handling the same request multiple times. From dab269722abb47483271cb5816a81873afb1fa24 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sat, 13 Sep 2025 18:26:22 +0200 Subject: [PATCH 3/6] Pass session for HTTPMock on instantiation --- Sources/HTTPMock/HTTPMock.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/HTTPMock/HTTPMock.swift b/Sources/HTTPMock/HTTPMock.swift index 93140e1..13a1d8d 100644 --- a/Sources/HTTPMock/HTTPMock.swift +++ b/Sources/HTTPMock/HTTPMock.swift @@ -24,6 +24,8 @@ public final class HTTPMock { self.mockIdentifier = mockIdentifier urlSession = URLSession.identifiedSession(with: mockIdentifier) self.passthroughSession = passthroughSession ?? URLSession(configuration: .ephemeral) + + HTTPMockURLProtocol.setPassthroughSession(for: mockIdentifier, self.passthroughSession) } /// Queue responses for a given path (e.g. "/some-path") for host in `defaultDomain`. Each request will pop the next response. From 24fdd32ba9ac24020eab9801e95e34da503c0709 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sat, 13 Sep 2025 18:26:54 +0200 Subject: [PATCH 4/6] HTTPMock: Add comments to properties --- Sources/HTTPMock/HTTPMock.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/HTTPMock/HTTPMock.swift b/Sources/HTTPMock/HTTPMock.swift index 13a1d8d..36f196b 100644 --- a/Sources/HTTPMock/HTTPMock.swift +++ b/Sources/HTTPMock/HTTPMock.swift @@ -1,16 +1,30 @@ import Foundation public final class HTTPMock { + /// A shared singleton instance for convenience. Use this for simple testing scenarios. + /// For parallel testing or isolated mock configurations, create separate instances with `HTTPMock()`. public static let shared = HTTPMock() + + /// The URLSession that intercepts and mocks HTTP requests. + /// Inject this session into your code under test to enable mocking. public let urlSession: URLSession + + /// A plain `URLSession` used for passthrough requests when `unmockedPolicy` is set to `.passthrough`. + /// This session bypasses the mocking layer and makes real network requests. + /// You can provide a custom passthrough session during initialization or configure this as needed. public let passthroughSession: URLSession + + /// The default domain used when registering responses without specifying a host. + /// Defaults to "example.com" and should be changed to match your API domain. public var defaultDomain = "example.com" + /// Controls how unmocked requests (requests with no registered response) are handled. public var unmockedPolicy: UnmockedPolicy { get { HTTPMockURLProtocol.getUnmockedPolicy(for: mockIdentifier) } set { HTTPMockURLProtocol.setUnmockedPolicy(for: mockIdentifier, newValue) } } + /// Unique identifier for this HTTPMock instance, used to isolate mock queues between different instances. let mockIdentifier: UUID public convenience init() { From a6ccada39d03381a020ce7cb7fc01ec1bc5cc227 Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sat, 13 Sep 2025 20:38:56 +0200 Subject: [PATCH 5/6] Add tests --- Sources/HTTPMock/HTTPMock.swift | 4 +- .../HTTPMockPassthroughTests.swift | 173 ++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 Tests/HTTPMockTests/HTTPMockPassthroughTests.swift diff --git a/Sources/HTTPMock/HTTPMock.swift b/Sources/HTTPMock/HTTPMock.swift index 36f196b..dbcf9f8 100644 --- a/Sources/HTTPMock/HTTPMock.swift +++ b/Sources/HTTPMock/HTTPMock.swift @@ -27,8 +27,8 @@ public final class HTTPMock { /// Unique identifier for this HTTPMock instance, used to isolate mock queues between different instances. let mockIdentifier: UUID - public convenience init() { - self.init(identifier: UUID()) + public convenience init(passthroughSession: URLSession? = nil) { + self.init(identifier: UUID(), passthroughSession: passthroughSession) } required init( diff --git a/Tests/HTTPMockTests/HTTPMockPassthroughTests.swift b/Tests/HTTPMockTests/HTTPMockPassthroughTests.swift new file mode 100644 index 0000000..e157a53 --- /dev/null +++ b/Tests/HTTPMockTests/HTTPMockPassthroughTests.swift @@ -0,0 +1,173 @@ +import Testing +import Foundation +@testable import HTTPMock + +struct HTTPMockPassthroughTests { + @Test + func passthroughSessionSetDuringInit() { + let httpMock = HTTPMock() + + // Verify that the passthrough session was set + let passthroughSession = HTTPMockURLProtocol.getPassthroughSession(for: httpMock.mockIdentifier) + #expect(passthroughSession != nil) + #expect(passthroughSession === httpMock.passthroughSession) + } + + @Test + func customPassthroughSessionCanBeProvided() { + let customSession = URLSession(configuration: .default) + + let httpMock = HTTPMock(passthroughSession: customSession) + #expect(httpMock.passthroughSession === customSession) + + let storedSession = HTTPMockURLProtocol.getPassthroughSession(for: httpMock.mockIdentifier) + #expect(storedSession === customSession) + } + + @Test + func defaultPassthroughSessionUsesEphemeralConfig() { + let httpMock = HTTPMock() + + // Default passthrough session should use ephemeral configuration + // We check that it's not the shared cache which indicates ephemeral usage + let isEphemeralStyle = httpMock.passthroughSession.configuration.urlCache !== URLCache.shared + #expect(isEphemeralStyle) + } + + @Test + func passthroughSessionsAreIsolatedBetweenInstances() { + let mock1 = HTTPMock() + let mock2 = HTTPMock() + + let session1 = HTTPMockURLProtocol.getPassthroughSession(for: mock1.mockIdentifier) + let session2 = HTTPMockURLProtocol.getPassthroughSession(for: mock2.mockIdentifier) + + #expect(session1 !== session2) + #expect(mock1.passthroughSession !== mock2.passthroughSession) + } + + @Test + func sharedInstanceHasOwnPassthroughSession() { + let sharedSession = HTTPMockURLProtocol.getPassthroughSession(for: HTTPMock.shared.mockIdentifier) + let newInstanceSession = HTTPMockURLProtocol.getPassthroughSession(for: HTTPMock().mockIdentifier) + + #expect(sharedSession != nil) + #expect(newInstanceSession != nil) + #expect(sharedSession !== newInstanceSession) + } + + @Test + func passthroughSessionStorageAndRetrievalWorks() { + let mockIdentifier = UUID() + let urlSession = URLSession(configuration: .ephemeral) + + // Initially no session stored + let initialSession = HTTPMockURLProtocol.getPassthroughSession(for: mockIdentifier) + #expect(initialSession == nil) + + // Store session + HTTPMockURLProtocol.setPassthroughSession(for: mockIdentifier, urlSession) + + // Retrieve stored session + let retrievedSession = HTTPMockURLProtocol.getPassthroughSession(for: mockIdentifier) + #expect(retrievedSession === urlSession) + } + + @Test + func httpMockUsesCorrectPassthroughSessionForIdentifier() { + let customConfig = URLSessionConfiguration.background(withIdentifier: "test-background") + let customSession = URLSession(configuration: customConfig) + + let httpMock = HTTPMock(passthroughSession: customSession) + + // Verify the custom session is stored correctly + let storedSession = HTTPMockURLProtocol.getPassthroughSession(for: httpMock.mockIdentifier) + #expect(storedSession === customSession) + #expect(storedSession === httpMock.passthroughSession) + } + + @Test + func multipleCustomPassthroughSessionsWorkIndependently() { + let config1 = URLSessionConfiguration.ephemeral + config1.timeoutIntervalForRequest = 5.0 + let session1 = URLSession(configuration: config1) + + let config2 = URLSessionConfiguration.ephemeral + config2.timeoutIntervalForRequest = 10.0 + let session2 = URLSession(configuration: config2) + + let mock1 = HTTPMock(passthroughSession: session1) + let mock2 = HTTPMock(passthroughSession: session2) + + #expect(mock1.passthroughSession === session1) + #expect(mock2.passthroughSession === session2) + #expect(mock1.passthroughSession !== mock2.passthroughSession) + + let stored1 = HTTPMockURLProtocol.getPassthroughSession(for: mock1.mockIdentifier) + let stored2 = HTTPMockURLProtocol.getPassthroughSession(for: mock2.mockIdentifier) + + #expect(stored1 === session1) + #expect(stored2 === session2) + } + + @Test + func passthroughSessionIsUsed() async throws { + // Create the passthrough session to use + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [InterceptingURLProtocol.self] + let urlSession = URLSession(configuration: configuration) + + // Setup HTTPMock and pass on the session + let httpMock = HTTPMock(passthroughSession: urlSession) + httpMock.unmockedPolicy = .passthrough + + let url = try #require(URL(string: "https://example.com")) + + do { + _ = try await httpMock.urlSession.data(from: url) + #expect(Bool(false), "The request above should throw an error, but it didn't") + } catch { + let nsError = error as NSError + #expect(nsError.domain == PassthroughSessionError.errorDomain) + #expect(nsError.userInfo[NSUnderlyingErrorKey] as? PassthroughSessionError == .wasCalled) + } + } +} + +// MARK: - URLProtocol for tests + +/// `URLProtocol` implementation that fails immediately upon request, using `PassthroughSessionError.wasCalled`. +/// Used to test that the `passthroughSession` is actually being used. +private class InterceptingURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + // Fail immediately. + // `URLSession` seems to wrap any errors thrown here in `URLError`, so we throw an `NSError` + // here along with some info that can we can extract and identify from the tests. + let underlyingError = PassthroughSessionError.wasCalled + let nsError = NSError( + domain: PassthroughSessionError.errorDomain, + code: underlyingError.errorCode, + userInfo: [NSUnderlyingErrorKey: underlyingError] + ) + client?.urlProtocol(self, didFailWithError: nsError) + } + + override func stopLoading() { + // NOOP + } +} + +private enum PassthroughSessionError: Int, Error, CustomNSError { + case wasCalled = 666 + + static var errorDomain: String { "HTTPMock.PassthroughSessionError" } + var errorCode: Int { rawValue } +} From de8c129203450deec7f1da84f6b968b868ecfb9e Mon Sep 17 00:00:00 2001 From: Bastian Stien Date: Sat, 13 Sep 2025 20:39:18 +0200 Subject: [PATCH 6/6] Update README --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index def4c3d..a0584f5 100644 --- a/README.md +++ b/README.md @@ -340,6 +340,27 @@ func testMyFeature() { Passthrough is useful for integration-style tests where only some endpoints need mocking, but it is not recommended for strict unit tests. +### Custom passthrough sessions +When using `.passthrough` policy you can provide a custom `URLSession` for handling unmocked requests. This allows you to configure timeouts, caching policies, and other networking behavior for passthrough requests. + +**Note:** By using `.passthrough` any unmocked requests will be sent to actual network. If this isn't your intention: either set `unmockedPolicy` to another value or configure and pass your own `URLSession` when instantiating HTTPMock. + +```swift +// Create a custom configuration for passthrough requests +let config = URLSessionConfiguration.default +config.timeoutIntervalForRequest = 10.0 +config.timeoutIntervalForResource = 30.0 +let passthroughSession = URLSession(configuration: config) + +// Use it when creating HTTPMock instance +let httpMock = HTTPMock(passthroughSession: passthroughSession) +httpMock.unmockedPolicy = .passthrough + +// Unmocked requests will now use your custom session configuration +``` + +If you don't provide a custom passthrough session, HTTPMock uses a default ephemeral session. Each HTTPMock instance maintains its own isolated passthrough session, so multiple instances can have different passthrough configurations. + ## Resetting between tests Use these in `tearDown()` or in individual tests: ```swift @@ -405,6 +426,9 @@ Use `*` for single-segment wildcards and `**` for multi-segment wildcards. All o **Can I customize what happens when no mock is found?** Yes. Use `HTTPMock.unmockedPolicy` to choose between `.notFound` (hardcoded 404), `.passthrough` (real network), `.mock(MockResponse)` (your custom response), or `.fatalError` (crash on unmocked requests). The custom option supports all `MockResponse` features, while `.fatalError` is useful for strict testing to catch missing mocks. +**Can I customize the URLSession used for passthrough requests?** +Yes. When creating an `HTTPMock` instance, you can provide a custom `passthroughSession` parameter with your own `URLSession` configuration. This allows you to control timeouts, caching policies, and other networking behavior for unmocked requests when using `.passthrough` policy. Each HTTPMock instance maintains its own isolated passthrough session. + ## Example response helpers These are available as static factory methods on `MockResponse` and can be used directly inside a `Path` or `addResponses` builder: