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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
27 changes: 24 additions & 3 deletions Sources/HTTPMock/HTTPMock.swift
Original file line number Diff line number Diff line change
@@ -1,24 +1,45 @@
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() {
self.init(identifier: UUID())
public convenience init(passthroughSession: URLSession? = nil) {
self.init(identifier: UUID(), passthroughSession: passthroughSession)
}

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)

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.
Expand Down
22 changes: 19 additions & 3 deletions Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
173 changes: 173 additions & 0 deletions Tests/HTTPMockTests/HTTPMockPassthroughTests.swift
Original file line number Diff line number Diff line change
@@ -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 }
}