An open API service indexing awesome lists of open source software.

https://github.com/bstien/httpmock

Lightweight HTTP mocking for Swift
https://github.com/bstien/httpmock

dsl integration-testing mock networking swift swift-testing unit-testing urlsession xctest

Last synced: 8 months ago
JSON representation

Lightweight HTTP mocking for Swift

Awesome Lists containing this project

README

          






Unit Tests



License


SwiftPM Compatible

Swift 6.0+

A tiny, test-first way to mock `URLSession` — **fast to set up, easy to read, zero test servers**. Queue responses for specific hosts/paths (and optional query params), then run your code against a regular `URLSession` that returns exactly what you told it to.

> **Design goals**: simple, explicit and ergonomic for everyday tests or prototyping. No fixtures or external servers. Just say what a request should get back.

## Highlights
- **Two ways to add mocks**: a **clean DSL** or **single registration methods** — use whichever reads best for your use case.
- **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.
- **Passthrough networking**: configure unmocked requests to either return a hardcoded 404 or be passed through to the network.
- **File-based responses**: serve response data directly from a file on disk.

## Installation (SPM)
Add this package to your test target:

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

## Quick start

### Option 1 — Imperative
```swift
import HTTPMock

// 1) Queue responses for a specific host + path
HTTPMock.shared.addResponses(
forPath: "/user",
host: "api.example.com",
responses: [
.encodable(User(id: 1, name: "Alice")), // defaults to .ok + application/json
.empty(status: .notFound) // the second call gets 404
]
)

// 2) Use the session in your code under test
let session = HTTPMock.shared.urlSession
let url = URL(string: "https://api.example.com/user")!
let (data, response) = try await session.data(from: url)
```

You can also use the imperative **builder** variant for readability:
```swift
HTTPMock.shared.addResponses(forPath: "/user", host: "api.example.com") {
MockResponse.encodable(User(id: 1, name: "Alice"))
MockResponse.empty(status: .notFound)
}
```

### Option 2 — Declarative DSL via result builder
```swift
HTTPMock.shared.registerResponses {
Host("api.example.com") {
Path("/user") {
MockResponse.encodable(User(id: 1, name: "Alice"))
MockResponse.empty(status: .notFound)
}
}
}
```

Both approaches are equivalent — pick what suits your use case. **Responses are consumed FIFO** for each queue matching host, path and query parameters.

## Headers (with optional cascade)
```swift
HTTPMock.shared.registerResponses {
Host("api.example.com") {
Headers(["X-Env": "Test"], cascade: true) // cascades to all child paths

Path("/profile") {
MockResponse.encodable(Profile(...)) // inherits X-Env
}

Path("/admin") {
Headers(["X-Env": "AdminOnly"], cascade: false)
MockResponse.empty(status: .unauthorized) // X-Env applies only here

Path("/audit") {
MockResponse.encodable(Audit(...)) // does NOT inherit AdminOnly
}
}
}
}
```
- Later headers in the **same scope** override earlier ones on the same key.
- **Response headers** override inherited headers on conflict.

## Query parameters
Queries are **path-local** (not inherited). You can require exact matches or require a set of params to exist on the request.

```swift
// Exact: only these params are accepted.
Path("/search", query: ["q": "swift", "page": "1"], matching: .exact) {
MockResponse.plaintext("ok-exact")
}

// Contains: these params must match; others are ignored
Path("/search", query: ["q": "swift"], matching: .contains) {
MockResponse.plaintext("ok-contains-1")
MockResponse.plaintext("ok-contains-2")
}
```

## File-based responses
Serve response data directly from a file on disk. Useful for pre-recorded and/or large responses. Either specify the `Content-Type` manually, or let it be inferred from the file.

```swift
HTTPMock.shared.registerResponses {
Host("api.example.com") {
Path("/data") {
// Point to a file in the specified `Bundle`.
MockResponse.file(named: "response", extension: "json", in: Bundle.main)

// Load the contents of a file from a `URL`.
MockResponse.file(url: urlToFile)
}
}
}
```

The file path is relative to the current working directory or absolute. This allows you to serve JSON, images, or any other file content as the response body.

## Response lifetime
Each response can be configured with a `lifetime` parameter to control how many times it is served before being removed from the queue. The default value of the parameter is `.single`.

- `.single`: The response is served once, then removed from the queue. This is the default.
- `.multiple(Int)`: The response is served the specified number of times, then removed from the queue.
- `.eternal`: The response is never removed and is served indefinitely.

Example:

```swift
MockResponse.plaintext("served once", lifetime: .single)
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.

```swift
// Default: return a hardcoded 404 response when no mock is registered for the incoming URL.
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
```

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
Use these in `tearDown()` or in individual tests:
```swift
// Remove all queued responses and registrations.
HTTPMock.shared.clearQueues()

// Remove all paths/responses for a host you've already registered.
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?**
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` 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.

**What happens if a request is not mocked?**
By default, unmocked requests return a hardcoded "404 Not Found" response. You can configure `HTTPMock`'s `UnmockedPolicy` to instead pass such requests through to the real network, allowing unmocked calls to succeed.

## Example response helpers
These are available as static factory methods on `MockResponse` and can be used directly inside a `Path` or `addResponses` builder:

```swift
MockResponse.encodable(T, status: .ok, headers: [:])
MockResponse.dictionary([String: Any], status: .ok, headers: [:])
MockResponse.plaintext(String, status: .ok, headers: [:])
MockResponse.file(named: String, extension: String, in: Bundle, status: .ok, headers: [:])
MockResponse.file(URL, status: .ok, headers: [:])
MockResponse.empty(status: .ok, headers: [:])
```

For example:

```swift
Path("/user") {
MockResponse.encodable(User(id: 1, name: "Alice"))
MockResponse.empty(status: .notFound)
}
```

## Notes
- Intended for **tests** (unit/integration/UI previews), not production networking.
- Internally uses a custom `URLProtocol` to intercept requests and match incoming requests to a specific mocked response.
- Thread-safe queueing and matching by host + path + optional query.
- Supports passthrough networking or 404 for unmocked requests, configurable via `HTTPMock.unmockedPolicy`.

## 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.
- [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.
- [ ] Does arrays in query parameters work? I think they're being overwritten with the current setup.