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
60 changes: 58 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ MockResponse.plaintext("delayed response", delivery: .delayed(2.0)) // delivered
```

## Handling unmocked requests
By default, unmocked requests return a hardcoded 404 response with a small body. You can configure `HTTPMock.unmockedPolicy` to control this behavior, choosing between returning a 404 or allowing the request to pass through to the real network. The default is `notFound`, aka. the hardoced 404 response.
By default, unmocked requests return a hardcoded 404 response with a small body. You can configure `HTTPMock.unmockedPolicy` to control this behavior with four options:

```swift
// Default: return a hardcoded 404 response when no mock is registered for the incoming URL.
Expand All @@ -283,8 +283,61 @@ 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.
HTTPMock.shared.unmockedPolicy = .passthrough

// Custom: provide your own MockResponse for unmocked requests
HTTPMock.shared.unmockedPolicy = .mock(.plaintext("Service temporarily unavailable", status: .other(503)))

// Fatal: trigger a fatalError for unmocked requests (strict testing mode)
HTTPMock.shared.unmockedPolicy = .fatalError
```

### Custom unmocked responses
The `.mock(MockResponse)` option allows you to define exactly what unmocked requests should return. This is useful for simulating service outages, maintenance modes, or providing consistent fallback responses during testing.

```swift
// Simple custom response
HTTPMock.shared.unmockedPolicy = .mock(.plaintext("API is down for maintenance", status: .other(503)))

// JSON error response with headers
HTTPMock.shared.unmockedPolicy = .mock(
.dictionary(
["error": "endpoint_not_found", "code": 404],
status: .notFound,
headers: ["X-Error-Source": "HTTPMock"]
)
)

// File-based fallback response
HTTPMock.shared.unmockedPolicy = .mock(.file(named: "fallback", extension: "json", in: Bundle.main))

// Delayed response to simulate slow networks
HTTPMock.shared.unmockedPolicy = .mock(.plaintext("Slow response", delivery: .delayed(2.0)))
```

**Note:** Custom unmocked responses support all `MockResponse` features (status codes, headers, delivery timing, file serving), but they don't maintain queue state like regular mocked responses. The `MockResponse` provided here will be used for all subsequent incoming unmocked requests. This means the **`lifetime` property is NOT used/honored** for these responses. Consider the `lifetime` to be `MockResponse.Lifetime.eternal`.

### Strict testing with fatalError
The `.fatalError` option provides the strictest testing mode by triggering a `fatalError()` whenever an unmocked request is encountered. This is useful for:

- **Catching missing mocks** during development.
- **Ensuring complete test coverage** of all network interactions.

```swift
// Enable strict mode - any unmocked request will crash the app
HTTPMock.shared.unmockedPolicy = .fatalError

// Use during test development to find missing mocks:
func testMyFeature() {
HTTPMock.shared.unmockedPolicy = .fatalError

// If this test makes any unmocked network requests, it will crash
// and tell you exactly which endpoints need mocking.
myFeature.performNetworkOperations()
}
```

**Warning:** Only use `.fatalError` during development and testing. It will crash your app on unmocked requests.

Passthrough is useful for integration-style tests where only some endpoints need mocking, but it is not recommended for strict unit tests.

## Resetting between tests
Expand Down Expand Up @@ -349,6 +402,9 @@ Yes. You can register multiple patterns that could match the same request. HTTPM
**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.

**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.

## 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 Expand Up @@ -382,5 +438,5 @@ Path("/user") {
- [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.
- [X] 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.
128 changes: 90 additions & 38 deletions Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ final class HTTPMockURLProtocol: URLProtocol {
/// A plain session without `HTTPMockURLProtocol` to support passthrough of requests when policy requires it.
private lazy var passthroughSession = URLSession(configuration: .ephemeral)

/// **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.
static var fatalErrorClosure: (() -> Void)?

// MARK: - Internal methods

static func getUnmockedPolicy(for mockIdentifier: UUID) -> UnmockedPolicy {
Expand Down Expand Up @@ -152,51 +156,39 @@ final class HTTPMockURLProtocol: URLProtocol {
HTTPMockLog.trace("Found mock in queue for matching registration: '\(keyDescription)'")
HTTPMockLog.debug("Remaining queue count for '\(keyDescription)': \(Self.queueSize(for: mockIdentifier, key: key))")

let sendResponse = { [weak self] in
guard let self else { return }
do {
HTTPMockLog.info("Serving mock for incoming request \(host)\(path) (\(self.statusCode(of: mock)))")

let response = HTTPURLResponse(
url: url,
statusCode: mock.status.code,
httpVersion: "HTTP/1.1",
headerFields: mock.headers
)!

let payload = try mock.payloadData()
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
self.client?.urlProtocol(self, didLoad: payload)
self.client?.urlProtocolDidFinishLoading(self)
} catch {
HTTPMockLog.error("Failed to serve mock for \(host)\(path): \(error)")
self.client?.urlProtocol(self, didFailWithError: error)
}
}

switch mock.delivery {
case .instant:
sendResponse()
case .delayed(let delay):
HTTPMockLog.info("Delaying response for request '\(requestDescription)' for \(delay) seconds")
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay, execute: sendResponse)
}
sendResponse(
url: url,
statusCode: mock.status.code,
headers: mock.headers,
delivery: mock.delivery,
payload: try mock.payloadData()
)
} else {
switch Self.getUnmockedPolicy(for: mockIdentifier) {
case .notFound:
HTTPMockLog.error("No mock found for request '\(requestDescription)' — returning 404")
let response = HTTPURLResponse(
HTTPMockLog.error("No mock found for incoming request '\(requestDescription)' — returning hardcoded 404 response")

sendResponse(
url: url,
statusCode: 404,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "text/plain"]
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data("No mock for \(host)\(path)".utf8))
client?.urlProtocolDidFinishLoading(self)
headers: ["Content-Type": "text/plain"],
delivery: .instant,
payload: Data("No mock for \(host)\(path)".utf8)
)

case .mock(let mock):
HTTPMockLog.error("No mock found for incoming request '\(requestDescription)' — returning user-defined mock")

sendResponse(
url: url,
statusCode: mock.status.code,
headers: mock.headers,
delivery: mock.delivery,
payload: try mock.payloadData()
)

case .passthrough:
HTTPMockLog.info("No mock found for \(requestDescription) — passthrough to network")
HTTPMockLog.info("No mock found for incoming request '\(requestDescription)' — passthrough to network")
var request = request

// Set known value on request to prevent handling the same request multiple times.
Expand All @@ -221,6 +213,25 @@ final class HTTPMockURLProtocol: URLProtocol {
self.client?.urlProtocolDidFinishLoading(self)
}
task.resume()

case .fatalError:
HTTPMockLog.info("No mock found for incoming request '\(requestDescription)' — performing a fatalError")

if let fatalErrorClosure = Self.fatalErrorClosure {
// Call the closure and reset it to avoid interfering other tests.
fatalErrorClosure()
Self.fatalErrorClosure = nil

// Send a default blank response so the tests don't wait indefinitely.
sendResponse(
url: url,
statusCode: 200,
delivery: .instant,
payload: Data()
)
} else {
fatalError("No mock found for incoming request '\(requestDescription)'")
}
}
}
}
Expand All @@ -231,6 +242,47 @@ final class HTTPMockURLProtocol: URLProtocol {

// MARK: - Private methods

private func sendResponse(
url: URL,
statusCode: Int,
headers: [String: String] = [:],
delivery: MockResponse.Delivery,
payload: @escaping @autoclosure () throws -> Data
) {
let performDelivery: (Data) -> Void = { [weak self] payload in
guard let self else { return }
HTTPMockLog.info("Delivering response with status code \(statusCode) for '\(url.absoluteString)'")

let response = HTTPURLResponse(
url: url,
statusCode: statusCode,
httpVersion: "HTTP/1.1",
headerFields: headers
)!

self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
self.client?.urlProtocol(self, didLoad: payload)
self.client?.urlProtocolDidFinishLoading(self)
}

do {
let payloadData = try payload()

switch delivery {
case .instant:
performDelivery(payloadData)
case .delayed(let delay):
HTTPMockLog.info("Delaying response for request '\(url.absoluteString)` for \(delay) seconds")
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay) {
performDelivery(payloadData)
}
}
} catch {
HTTPMockLog.error("Failed to encode payload for '\(url.absoluteString)`: \(error)")
client?.urlProtocol(self, didFailWithError: error)
}
}

private static func findAndPopNextMock(
for mockIdentifier: UUID,
host: String,
Expand Down
7 changes: 7 additions & 0 deletions Sources/HTTPMock/Models/UnmockedPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ public enum UnmockedPolicy {
/// Return a hardcoded "Not found" message, along with HTTP status 404.
case notFound

/// Return a user-defined mocked response.
case mock(MockResponse)

/// Let the request pass through to the internet.
/// Useful for integration tests where you want to mock some responses, but let others hit actual network.
case passthrough

/// Perform a `fatalError()` call to abruptly end the running app/test.
/// Useful for strict testing of your networking.
case fatalError
}
84 changes: 84 additions & 0 deletions Tests/HTTPMockTests/HTTPMockFatalErrorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import Testing
import Foundation
@testable import HTTPMock

/// These tests are separated from the rest, so we can run them in sequence. We need to do this, because they all set the `unmockedPolicy` to `fatalError`.
/// Having them run in parallel will cause issues, since `HTTPMockURLProtocol.fatalErrorClosure` is shared and will be reset between each call to an unmocked URL
/// where the policy is `fatalError.`
@Suite(.serialized)
struct HTTPMockFatalErrorTests {
let httpMock: HTTPMock

init() {
httpMock = HTTPMock()
HTTPMockLog.level = .trace
}

@Test
func unmockedPolicy_fatalError_callsFatalErrorClosure() async throws {
var fatalErrorCalled = false

// Set up the test closure
HTTPMockURLProtocol.fatalErrorClosure = {
fatalErrorCalled = true
}

// Configure fatal error policy
httpMock.unmockedPolicy = .fatalError
let url = try #require(URL(string: "https://example.com/fatal-test"))

// The request should trigger our test closure instead of actually calling fatalError
_ = try? await httpMock.urlSession.data(from: url)
#expect(fatalErrorCalled == true)
#expect(HTTPMockURLProtocol.fatalErrorClosure == nil) // Should be reset after use
}

@Test
func unmockedPolicy_fatalError_resetsClosureAfterCall() async throws {
var firstCallMade = false
var secondCallMade = false

// Set up the test closure for first call
HTTPMockURLProtocol.fatalErrorClosure = {
firstCallMade = true
}
httpMock.unmockedPolicy = .fatalError
let url = try #require(URL(string: "https://example.com/fatal-reset-test"))

// First unmocked request should trigger closure
_ = try? await httpMock.urlSession.data(from: url)
#expect(firstCallMade == true)
#expect(HTTPMockURLProtocol.fatalErrorClosure == nil)

// Set up a different closure for second call
HTTPMockURLProtocol.fatalErrorClosure = {
secondCallMade = true
}

// Second unmocked request should trigger the new closure
_ = try? await httpMock.urlSession.data(from: url)
#expect(secondCallMade == true)
#expect(HTTPMockURLProtocol.fatalErrorClosure == nil)
}

@Test
func unmockedPolicy_fatalError_switchingFromOtherPolicies() async throws {
let url = try #require(URL(string: "https://example.com/policy-to-fatal"))

// Start with notFound policy
httpMock.unmockedPolicy = .notFound
let (_, response1) = try await httpMock.urlSession.data(from: url)
#expect(response1.httpStatusCode == 404)

// Switch to fatalError policy
var fatalErrorCalled = false
HTTPMockURLProtocol.fatalErrorClosure = {
fatalErrorCalled = true
}
httpMock.unmockedPolicy = .fatalError

// Should now trigger fatal error behavior
_ = try? await httpMock.urlSession.data(from: url)
#expect(fatalErrorCalled == true)
}
}
Loading