NetworkSpyKit is a lightweight, thread-safe HTTP spy and stub tool for testing code that performs network requests in Swift.
It allows you to:
- Record outgoing
URLRequests - Return predefined or dynamic stubbed responses
- Assert request behavior without hitting the real network
- Keep your tests fast, isolated, and deterministic
- ๐ซ Never touches the real network
- ๐งช Spy on requests (headers, body, URL, method)
- ๐ญ Stub custom responses on a per-request basis
- ๐งต Thread-safe and safe for parallel test execution
- โ Built-in teapot response for fun (and HTTP 418 awareness)
NetworkSpy works with any network clients which are URLSession-based.
URLSession
import Foundation
import NetworkSpyKit
let networkSpy = NetworkSpy(sessionConfiguration: .default)
let networkClient = URLSession(configuration: networkSpy.sessionConfiguration)import Alamofire
import NetworkSpyKit
let networkSpy = NetworkSpy(sessionConfiguration: .af.default)
let networkClient = Alamofire.Session(configuration: sessionConfiguration)import Foundation
import OpenAPIRuntime
import OpenAPIURLSession
import NetworkSpyKit
let networkSpy = NetworkSpy(sessionConfiguration: .default)
let session = URLSession(configuration: sessionConfiguration)
let configuration = URLSessionTransport.Configuration(session: session)
let networkClient = Client(serverURL: serverURL,
transport: URLSessionTransport(configuration: configuration))โน๏ธ
NetworkSpys default response is418 I'm a teapot
networkSpy.responseProvider = { request in
return StubbedResponse(statusCode: 200,
data: "A pot of coffee".data(using: .utf8))
}Stubbed responses never touch the real network. All requests are intercepted at the protocol layer using a
URLProtocolsubclass under the hood.
import NetworkSpyKit
struct MyNetworkingTest {
private let networkSpy = NetworkSpy(sessionConfiguration: .default)
}import NetworkSpyKit
struct MyNetworkingTest {
private let networkSpy = NetworkSpy(sessionConfiguration: .default)
func whenOrderingCoffee_itSendsACoffeeRequest() async throws {
networkSpy.responseProvider = { request in
return StubbedResponse(statusCode: 200,
data: "A pot of coffee".data(using: .utf8))
}
}
}import NetworkSpyKit
struct MyNetworkingTest {
private let networkSpy = NetworkSpy(sessionConfiguration: .default)
func whenOrderingCoffee_itSendsACoffeeRequest() async throws {
networkSpy.responseProvider = { request in
return StubbedResponse(statusCode: 200,
data: "A pot of coffee".data(using: .utf8))
}
let networkClient = makeNetworkClient(urlSessionConfiguration: networkSpy.sessionConfiguration)
}
}import NetworkSpyKit
struct MyNetworkingTest {
private let networkSpy = NetworkSpy(sessionConfiguration: .default)
func whenOrderingCoffee_itSendsACoffeeRequest() async throws {
networkSpy.responseProvider = { request in
return StubbedResponse(statusCode: 200,
data: "A pot of coffee".data(using: .utf8))
}
let networkClient = makeNetworkClient(urlSessionConfiguration: networkSpy.sessionConfiguration)
try await networkClient.orderCoffee()
}
}NetworkSpy.recordedRequests collects all send URLRequest, which we can inspect.
import NetworkSpyKit
struct MyNetworkingTest {
private let networkSpy = NetworkSpy(sessionConfiguration: .default)
func whenOrderingCoffee_itSendsACoffeeRequest() async throws {
networkSpy.responseProvider = { request in
return StubbedResponse(statusCode: 200,
data: "A pot of coffee".data(using: .utf8))
}
let networkClient = makeNetworkClient(urlSessionConfiguration: networkSpy.sessionConfiguration)
try await networkClient.orderCoffee()
#expect(networkSpy.recordedRequests.first?.url?.path == "/api/coffee/order")
}
}In this example we expect that orderCoffee() transforms the network response into a Beverage.aPotOfCoffee.
import NetworkSpyKit
struct MyNetworkingTest {
private let networkSpy = NetworkSpy(sessionConfiguration: .default)
func whenOrderingCoffee_itSendsACoffeeRequest() async throws {
networkSpy.responseProvider = { request in
return StubbedResponse(statusCode: 200,
data: "A pot of coffee".data(using: .utf8))
}
let networkClient = makeNetworkClient(urlSessionConfiguration: networkSpy.sessionConfiguration)
let beverage = try await networkClient.orderCoffee()
#expect(beverage == .aPotOfCoffee)
}
}NetworkSpyKit contains convenient StubbedResponses to reduce redundancy.
This includes standard HTTP responses like
| Response | StubbedResponse |
|---|---|
| 200 OK | .ok |
| 404 NOT FOUND | .notFound |
| 500 INTERNAL SERVER ERROR | .internalServerError |
| 501 NOT IMPLEMENTED | .notImplemented |
See CommonResponses for more convenient response implementations.
In addition it provides a convenient way to create json responses
-
Use
StubbedResponse.json(statusCode:_:jsonFormattingOptions:)for supplying anEncodabletype as JSON payload in the responses body.To ensure your Encodable types encode deterministically the default
jsonFormattingOptionscontains the.sortedKeysoption. -
Use
StubbedResponse.json(statusCode:jsonData:)for supplying raw JSON data in a response body. -
Use
StubbedResponse.json(statusCode:jsonString:)for supplying a JSON string in a response body.
Example Creating a 200 OK JSON response from an Encodable model:
let encodableModel = YourModelConformingToEncodable()
let response = NetworkSpy.StubbedResponse.json(200, encodableModel)Example Creating a 404 Not Found response with a JSON error body:
let response = NetworkSpy.StubbedResponse.json(statusCode: 404,
jsonString: "{\"error\":\"Not found\"}")NetworkSpys default response is 418 I'm a teapot
let networkSpy = NetworkSpy()
networkSpy.responseProvider = { _ in .teaPot() }Returns:
418 I'm a teapot
Content-Type": "application/json"
{"error": "I'm a teapot"}
Because Hyper Text Coffee Pot Control Protocol is real. Sort of.
NetworkSpyuses an internal serial queue to synchronize access.- You can safely use multiple spies in parallel or across test targets.
- Isolated by using unique headers to associate intercepted requests with the correct
NetworkSpyinstance.
Swift Package Manager
Add the following to your Package.swift:
.package(url: "https://github.com/yourusername/NetworkSpyKit.git", from: "1.0.0")Then import it where needed:
import NetworkSpyKitCocoaPods
Add the following line to your Podfile:
pod 'NetworkSpyKit'Then run:
pod installMIT License. See LICENSE for details.