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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,19 @@ MockResponse.plaintext("served three times", lifetime: .multiple(3))
MockResponse.plaintext("served forever", lifetime: .eternal)
```

## Response delivery
Each response can optionally be given a `delivery` parameter that controls when the response is delivered to the client. The default value of the parameter is `.instant`.

- `.instant`: The response is delivered immediately (default behavior).
- `.delayed(TimeInterval)`: The response is delayed and delivered after the specified number of seconds.

Example:

```swift
MockResponse.plaintext("immediate response", delivery: .instant)
MockResponse.plaintext("delayed response", delivery: .delayed(2.0)) // delivered after 2 seconds
```

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

Expand Down Expand Up @@ -223,7 +236,7 @@ Path("/user") {
## Goals
- [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.
- [ ] Set delay on requests.
- [X] Set delay on requests.
- [ ] 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.
43 changes: 27 additions & 16 deletions Sources/HTTPMock/Internal/HTTPMockURLProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,23 +99,34 @@ final class HTTPMockURLProtocol: URLProtocol {

// 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) {
do {
HTTPMockLog.info("Serving mock for \(host)\(path) (\(statusCode(of: mock)))")
HTTPMockLog.debug("Remaining queue for \(requestDescription): \(Self.queueSize(host: host, path: path, query: queryDict))")

let response = HTTPURLResponse(
url: url,
statusCode: mock.status.code,
httpVersion: "HTTP/1.1",
headerFields: mock.headers
)!
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))")

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 {
self.client?.urlProtocol(self, didFailWithError: error)
}
}

let payload = try mock.payloadData()
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: payload)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
switch mock.delivery {
case .instant:
sendResponse()
case .delayed(let delay):
HTTPMockLog.info("Delaying response for \(requestDescription) for \(delay) seconds")
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay, execute: sendResponse)
}
} else {
switch Self.unmockedPolicy {
Expand Down
8 changes: 8 additions & 0 deletions Sources/HTTPMock/Models/MockResponse+Delivery.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Foundation

extension MockResponse {
public enum Delivery: Hashable {
case instant
case delayed(TimeInterval)
}
}
19 changes: 18 additions & 1 deletion Sources/HTTPMock/Models/MockResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,23 @@ public struct MockResponse: Hashable {

public let payload: Payload
public let status: Status
public let headers: [String: String]
public let lifetime: Lifetime
public let delivery: Delivery
public let headers: [String: String]

// MARK: - Init

public init(
payload: Payload,
status: Status = .ok,
lifetime: Lifetime = .single,
delivery: Delivery = .instant,
headers: [String: String] = [:]
) {
self.payload = payload
self.status = status
self.lifetime = lifetime
self.delivery = delivery

if let contentType = payload.contentType {
// Merge the headers, allowing the user to overwrite any we set.
Expand All @@ -40,6 +43,7 @@ extension MockResponse {
_ payload: T,
status: Status = .ok,
lifetime: Lifetime = .single,
delivery: Delivery = .instant,
headers: [String: String] = [:],
jsonEncoder: JSONEncoder = .mockDefault
) throws -> MockResponse {
Expand All @@ -48,6 +52,7 @@ extension MockResponse {
payload: .data(data, contentType: "application/json"),
status: status,
lifetime: lifetime,
delivery: delivery,
headers: headers
)
}
Expand All @@ -56,13 +61,15 @@ extension MockResponse {
_ payload: [String: Any],
status: Status = .ok,
lifetime: Lifetime = .single,
delivery: Delivery = .instant,
headers: [String: String] = [:]
) throws -> MockResponse {
let data = try JSONSerialization.data(withJSONObject: payload)
return Self.init(
payload: .data(data, contentType: "application/json"),
status: status,
lifetime: lifetime,
delivery: delivery,
headers: headers
)
}
Expand All @@ -71,26 +78,30 @@ extension MockResponse {
_ payload: String,
status: Status = .ok,
lifetime: Lifetime = .single,
delivery: Delivery = .instant,
headers: [String: String] = [:]
) -> MockResponse {
let data = Data(payload.utf8)
return Self.init(
payload: .data(data, contentType: "text/plain"),
status: status,
lifetime: lifetime,
delivery: delivery,
headers: headers
)
}

public static func empty(
status: Status = .ok,
lifetime: Lifetime = .single,
delivery: Delivery = .instant,
headers: [String: String] = [:]
) -> MockResponse {
Self.init(
payload: .empty,
status: status,
lifetime: lifetime,
delivery: delivery,
headers: headers
)
}
Expand All @@ -111,6 +122,7 @@ extension MockResponse {
in bundle: Bundle = .main,
status: Status = .ok,
lifetime: Lifetime = .single,
delivery: Delivery = .instant,
headers: [String: String] = [:],
contentType: String? = nil
) -> MockResponse {
Expand All @@ -121,6 +133,7 @@ extension MockResponse {
url: url,
status: status,
lifetime: lifetime,
delivery: delivery,
headers: headers,
contentType: contentType
)
Expand All @@ -138,6 +151,7 @@ extension MockResponse {
url: URL,
status: Status = .ok,
lifetime: Lifetime = .single,
delivery: Delivery = .instant,
headers: [String: String] = [:],
contentType: String? = nil
) -> MockResponse {
Expand All @@ -154,6 +168,7 @@ extension MockResponse {
payload: .data(data, contentType: contentType),
status: status,
lifetime: lifetime,
delivery: delivery,
headers: headers
)
}
Expand Down Expand Up @@ -195,6 +210,7 @@ extension MockResponse {
payload: self.payload,
status: self.status,
lifetime: lifetime,
delivery: delivery,
headers: extra.mergedInOther(self.headers)
)
}
Expand All @@ -204,6 +220,7 @@ extension MockResponse {
payload: payload,
status: status,
lifetime: newLifetime,
delivery: delivery,
headers: headers
)
}
Expand Down
64 changes: 64 additions & 0 deletions Tests/HTTPMockTests/HTTPMockTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,70 @@ struct HTTPMockTests {
}
}

// MARK: - Delivery (delay) tests

@Test
func delivery_immediate_returnsQuickly() async throws {
let key = createMockKey(path: "/delay-immediate")
httpMock.addResponse(.plaintext("ok", delivery: .instant), for: key)

let url = try #require(URL(string: "https://example.com/delay-immediate"))
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 == "ok")

// Should complete fast. Allow some time, "just in case"™.
#expect(elapsed < 0.1)
}

@Test
func delivery_delayed_respectsInterval() async throws {
let key = createMockKey(path: "/delay-300ms")
httpMock.addResponse(.plaintext("slow", delivery: .delayed(0.3)), for: key)

let url = try #require(URL(string: "https://example.com/delay-300ms"))
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 == "slow")

// Subtract some time, just in case of scheduling jitter.
#expect(elapsed >= 0.28)
}

@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)


let url = try #require(URL(string: "https://example.com/delay-sequence"))

try await withThrowingTaskGroup(of: (Data, URLResponse).self) { group in
group.addTask { try await httpMock.urlSession.data(from: url) }
group.addTask { try await httpMock.urlSession.data(from: url) }

var resultStrings = [String]()
for try await tuple in group {
resultStrings.append(tuple.0.toString)
}

let expectedOrder = [
"requested-second-but-delivered-first",
"requested-first-but-delivered-second"
]
#expect(resultStrings == expectedOrder)
}

#expect(mockQueues[key]?.isEmpty == true)
}

// MARK: - Helpers

private func writeTempFile(named: String, ext: String, contents: Data) throws -> URL {
Expand Down