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
2 changes: 1 addition & 1 deletion .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ jobs:
run: swift build

- name: Test
run: swift test --no-parallel
run: swift test --parallel
1 change: 0 additions & 1 deletion HTTPMock.xctestplan
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
},
"testTargets" : [
{
"parallelizable" : false,
"target" : {
"containerPath" : "container:",
"identifier" : "HTTPMockTests",
Expand Down
43 changes: 37 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ A tiny, test-first way to mock `URLSession` — **fast to set up, easy to read,

## Highlights
- **Two ways to add mocks**: a **clean DSL** or **single registration methods** — use whichever reads best for your use case.
- **Singleton API**: `HTTPMock.shared` is the only instance you need. No global state leaks between tests: clear with `clearQueues()`.
- **Works with real `URLSession`**: inject `HTTPMock.shared.urlSession` into the code under test.
- **Instance or singleton**: you can either use the singleton `HTTPMock.shared` or create separate instances with `HTTPMock()`. Different instances have separate response queues.
- **Provides a real `URLSession`**: inject `HTTPMock.shared.urlSession` or your own instance's `urlSession` into the code under test.
- **Precise matching**: host + path, plus optional **query matching** (`.exact` or `.contains`).
- **Headers support**: define headers at the host or path, with optional **cascade** to children when using the DSL.
- **FIFO responses**: queue multiple responses and they'll be served in order.
Expand All @@ -35,7 +35,7 @@ A tiny, test-first way to mock `URLSession` — **fast to set up, easy to read,
Add this package to your test target:

```swift
.package(url: "https://github.com/bstien/HTTPMock.git", from: "0.0.1")
.package(url: "https://github.com/bstien/HTTPMock.git", from: "0.0.3")
```

## Quick start
Expand Down Expand Up @@ -193,12 +193,43 @@ HTTPMock.shared.clearQueues()
HTTPMock.shared.clearQueue(forHost: "domain.com")
```

## Singleton vs. separate instances
You can use the global singleton `HTTPMock.shared` for simplicity in most cases. However, if you need isolated queues to, for example, run parallel tests or maintain different mock configurations you can create separate instances with `HTTPMock()`.

Each instance maintains their own queue and properties, and they have no connection to each other.

Example:

```swift
// Using the singleton
HTTPMock.shared.registerResponses {
Host("api.example.com") {
Path("/user") {
MockResponse.plaintext("Hello from singleton!")
}
}
}
let singletonSession = HTTPMock.shared.urlSession

// Using a separate instance.
let mockInstance = HTTPMock()
mockInstance.registerResponses {
Host("api.example.com") {
Path("/user") {
MockResponse.plaintext("Hello from instance!")
}
}
}
let instanceSession = mockInstance.urlSession
```


## FAQs
**Can I run tests that use `HTTPMock` in parallel?**
No, currently only a single instance of `HTTPMock` can exist, so tests must be run sequentially.
Previously, only a single instance of `HTTPMock` could exist, so tests had to be run sequentially. Now, you can create multiple independent `HTTPMock` instances using `HTTPMock()`, allowing parallel tests or separate mock configurations. The singleton `HTTPMock.shared` still exists for convenience.

**Can I use my own `URLSession`?**
Yes — most tests just use `HTTPMock.shared.urlSession`. If your code constructs its own session, inject `HTTPMock.shared.urlSession` into the component under test.
Yes — most tests just use `HTTPMock.shared.urlSession`. If your code constructs its own session, inject `HTTPMock.shared.urlSession` or your own instance's `urlSession` into the component under test.

**Is order guaranteed?**
Yes, per (host, path, [query]) responses are popped in **FIFO** order.
Expand Down Expand Up @@ -237,6 +268,6 @@ Path("/user") {
- [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.
- [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.
- [ ] 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.
22 changes: 22 additions & 0 deletions Sources/HTTPMock/Extensions/URLSession+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation
import ObjectiveC.runtime

private var MockIdentifierKey: UInt8 = 0

extension URLSession {
/// Attach/read a UUID on a URLSession instance.
var mockIdentifier: UUID? {
get { objc_getAssociatedObject(self, &MockIdentifierKey) as? UUID }
set { objc_setAssociatedObject(self, &MockIdentifierKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}

static func identifiedSession(with identifier: UUID) -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [HTTPMockURLProtocol.self]

let urlSession = URLSession(configuration: configuration)
urlSession.mockIdentifier = identifier

return urlSession
}
}
4 changes: 2 additions & 2 deletions Sources/HTTPMock/HTTPMock+ResultBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ extension HTTPMock {
queryItems: registration.queryItems,
queryMatching: registration.queryMatching ?? .exact
)
HTTPMockURLProtocol.add(responses: finalResponses, forKey: key)
HTTPMockURLProtocol.add(responses: finalResponses, forKey: key, forMockIdentifier: mockIdentifier)
}
}
}
Expand All @@ -38,7 +38,7 @@ extension HTTPMock {
queryItems: registration.queryItems,
queryMatching: registration.queryMatching ?? .exact
)
HTTPMockURLProtocol.add(responses: finalResponses, forKey: key)
HTTPMockURLProtocol.add(responses: finalResponses, forKey: key, forMockIdentifier: mockIdentifier)
}
}

Expand Down
27 changes: 17 additions & 10 deletions Sources/HTTPMock/HTTPMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@ public final class HTTPMock {
public var defaultDomain = "example.com"

public var unmockedPolicy: UnmockedPolicy {
get { HTTPMockURLProtocol.unmockedPolicy }
set { HTTPMockURLProtocol.unmockedPolicy = newValue }
get { HTTPMockURLProtocol.getUnmockedPolicy(for: mockIdentifier) }
set { HTTPMockURLProtocol.setUnmockedPolicy(for: mockIdentifier, newValue) }
}

private init() {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [HTTPMockURLProtocol.self]
urlSession = URLSession(configuration: configuration)
let mockIdentifier: UUID

public convenience init() {
self.init(identifier: UUID())
}

required init(identifier mockIdentifier: UUID) {
self.mockIdentifier = mockIdentifier
urlSession = URLSession.identifiedSession(with: mockIdentifier)
}

/// Queue responses for a given path (e.g. "/some-path") for host in `defaultDomain`. Each request will pop the next response.
Expand All @@ -28,7 +33,8 @@ public final class HTTPMock {
forHost: defaultDomain,
path: normalized(path),
queryItems: queryItems,
queryMatching: queryMatching
queryMatching: queryMatching,
forMockIdentifier: mockIdentifier
)
}

Expand All @@ -45,7 +51,8 @@ public final class HTTPMock {
forHost: host,
path: normalized(path),
queryItems: queryItems,
queryMatching: queryMatching
queryMatching: queryMatching,
forMockIdentifier: mockIdentifier
)
}

Expand All @@ -56,12 +63,12 @@ public final class HTTPMock {

/// Clear all queues – basically a reset.
public func clearQueues() {
HTTPMockURLProtocol.clearQueues()
HTTPMockURLProtocol.clearQueues(mockIdentifier: mockIdentifier)
}

/// Clear the response queue for a single host.
public func clearQueue(forHost host: String) {
HTTPMockURLProtocol.clearQueue(forHost: host)
HTTPMockURLProtocol.clearQueue(forHost: host, mockIdentifier: mockIdentifier)
}

/// Makes sure all paths are prefixed with `/`. We need this for consistency when looking up responses from the queue.
Expand Down
Loading