diff --git a/README.md b/README.md index 65cf81a..def4c3d 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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: @@ -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. diff --git a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift index d5d20e3..8505b46 100644 --- a/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift +++ b/Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift @@ -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 { @@ -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. @@ -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)'") + } } } } @@ -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, diff --git a/Sources/HTTPMock/Models/UnmockedPolicy.swift b/Sources/HTTPMock/Models/UnmockedPolicy.swift index 2feeb2a..a0a7c84 100644 --- a/Sources/HTTPMock/Models/UnmockedPolicy.swift +++ b/Sources/HTTPMock/Models/UnmockedPolicy.swift @@ -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 } diff --git a/Tests/HTTPMockTests/HTTPMockFatalErrorTests.swift b/Tests/HTTPMockTests/HTTPMockFatalErrorTests.swift new file mode 100644 index 0000000..ed0085a --- /dev/null +++ b/Tests/HTTPMockTests/HTTPMockFatalErrorTests.swift @@ -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) + } +} diff --git a/Tests/HTTPMockTests/HTTPMockTests.swift b/Tests/HTTPMockTests/HTTPMockTests.swift index 8fc9037..c237789 100644 --- a/Tests/HTTPMockTests/HTTPMockTests.swift +++ b/Tests/HTTPMockTests/HTTPMockTests.swift @@ -36,7 +36,7 @@ struct HTTPMockTests { } @Test - func itReturnsAHardcodedMessageOn404NotFound() async throws { + func unmockedPolicy_default_returnsHardcoded404Response() async throws { let url = try #require(URL(string: "https://example.com")) let (data, response) = try await httpMock.urlSession.data(from: url) @@ -44,6 +44,109 @@ struct HTTPMockTests { #expect(data.toString == "No mock for example.com/") } + @Test + func unmockedPolicy_userDefinedMock_returnsCustomResponse() async throws { + // Configure custom unmocked response + httpMock.unmockedPolicy = .mock(.plaintext("Custom unmocked response", status: .other(418), headers: ["X-Custom": "Header"])) + + let url = try #require(URL(string: "https://example.com/unmocked")) + let (data, response) = try await httpMock.urlSession.data(from: url) + + #expect(response.httpStatusCode == 418) + #expect(data.toString == "Custom unmocked response") + #expect(response.headerValue(for: "X-Custom") == "Header") + } + + @Test + func unmockedPolicy_userDefinedMock_withDelay() async throws { + // Configure delayed unmocked response + httpMock.unmockedPolicy = .mock(.plaintext("Delayed unmocked", delivery: .delayed(0.2))) + + let url = try #require(URL(string: "https://example.com/delayed-unmocked")) + let start = Date() + let (data, response) = try await httpMock.urlSession.data(from: url) + let elapsed = Date().timeIntervalSince(start) + + #expect(response.httpStatusCode == 200) + #expect(data.toString == "Delayed unmocked") + #expect(elapsed >= 0.18) // Account for scheduling jitter + } + + @Test + func unmockedPolicy_userDefinedMock_withFileResponse() async throws { + // Create a temporary file + let fileContent = "{\"error\": \"No mock configured\", \"code\": 999}" + let url = try writeTempFile(named: "unmocked", ext: "json", contents: Data(fileContent.utf8)) + + // Configure file-based unmocked response + httpMock.unmockedPolicy = .mock(.file(url: url, status: .other(503), headers: ["X-Source": "File"])) + + let requestURL = try #require(URL(string: "https://example.com/file-unmocked")) + let (data, response) = try await httpMock.urlSession.data(from: requestURL) + + #expect(response.httpStatusCode == 503) + #expect(data.toString == fileContent) + #expect(response.headerValue(for: "Content-Type") == "application/json") + #expect(response.headerValue(for: "X-Source") == "File") + } + + @Test + func unmockedPolicy_userDefinedMock_multipleLifetime() async throws { + // Configure unmocked response with multiple lifetime + // Lifetime *IS NOT* tracked across unmocked requests like it is for queued responses + httpMock.unmockedPolicy = .mock(.plaintext("Used multiple times", lifetime: .multiple(3))) + + let url = try #require(URL(string: "https://example.com/multi-unmocked")) + + // Test that the unmocked response is served consistently + for _ in 1...5 { + let (data, response) = try await httpMock.urlSession.data(from: url) + #expect(response.httpStatusCode == 200) + #expect(data.toString == "Used multiple times") + } + + // The response should continue to be available since unmocked policies + // don't maintain queue state like regular mocked responses + } + + @Test + func unmockedPolicy_userDefinedMock_eternalLifetime() async throws { + // Configure eternal unmocked response + httpMock.unmockedPolicy = .mock(.plaintext("Always available", lifetime: .eternal)) + + let url = try #require(URL(string: "https://example.com/eternal-unmocked")) + + // Should work indefinitely + for _ in 1...5 { + let (data, response) = try await httpMock.urlSession.data(from: url) + #expect(response.httpStatusCode == 200) + #expect(data.toString == "Always available") + } + } + + @Test + func unmockedPolicy_switchingBetweenPolicies() async throws { + let url = try #require(URL(string: "https://example.com/policy-switch")) + + // Start with custom mock + httpMock.unmockedPolicy = .mock(.plaintext("Custom mock")) + let (data1, response1) = try await httpMock.urlSession.data(from: url) + #expect(response1.httpStatusCode == 200) + #expect(data1.toString == "Custom mock") + + // Switch to notFound + httpMock.unmockedPolicy = .notFound + let (data2, response2) = try await httpMock.urlSession.data(from: url) + #expect(response2.httpStatusCode == 404) + #expect(data2.toString == "No mock for example.com/policy-switch") + + // Switch back to a different custom mock + httpMock.unmockedPolicy = .mock(.plaintext("Different mock", status: .other(400))) + let (data3, response3) = try await httpMock.urlSession.data(from: url) + #expect(response3.httpStatusCode == 400) + #expect(data3.toString == "Different mock") + } + @Test func itDoesNotRegisterTheKeyIfNoResponsesAreProvided() { let key = makeKey()