MutantInjector is a powerful, lightweight Swift library for network request interception and mocking in iOS and macOS applications. It provides an elegant solution for testing and development by allowing you to mock network responses without hitting real endpoints.
- Zero-configuration setup - One line to initialize the interceptor for all network requests
- Transparent swizzling - Intercepts all network requests without modifying application code
- Status code simulation - Return different responses based on HTTP status codes
- Bundle integration - Easily include mock responses in your project bundle
- Modular design - Use only what you need, minimal footprint
- Test-friendly - Designed to simplify and accelerate your unit and UI testing
- Development support - Mock API responses during development to work without real endpoints
- Debug support - Improved error reporting for fast debugging
Add the following dependency to your Package.swift file:
dependencies: [
.package(url: "https://github.com/samuelail/MutantInjector.git", from: "1.0.5")
]import MutantInjector
// In your test setup or development environment
func setUp() {
super.setUp()
MutantInjector.setupGlobalInterceptor()
// Register a mock response
MutantInjector.addMockResponse(
for: "https://api.example.com/users",
statusCode: 200,
method: .get, // optional, defaults to .all
jsonFilename: "users_success"
)
}
func tearDown() {
MutantInjector.tearDownGlobalInterceptor()
super.tearDown()
}// Register a mock response using a filename
MutantInjector.addMockResponse(
for: "https://api.example.com/users",
statusCode: 200,
method: .get, // optional, defaults to .all
jsonFilename: "users_success"
)
// The JSON file "users_success.json" should be in your test bundle
// { "users": [{"id": 1, "name": "John Doe"}] }// Mock a success response
MutantInjector.addMockResponse(
for: "https://api.example.com/users",
statusCode: 200,
method: .get, // optional, defaults to .all
jsonFilename: "users_success"
)
// Mock an error response
MutantInjector.addMockResponse(
for: "https://api.example.com/users",
statusCode: 404,
method: .get, // optional, defaults to .all
jsonFilename: "not_found_error"
)
// Both responses are registered for the same URL but different status codes// Create a URL to your mock JSON file
let fileURL = Bundle(for: type(of: self)).url(forResource: "users_success", withExtension: "json")!
// Register a mock response using a file URL
MutantInjector.addMockResponse(
for: "https://api.example.com/users",
statusCode: 200,
method: .get, // optional, defaults to .all
fileURL: fileURL
)You can simulate a longer response time for requests by delaying the time it takes MutantInjector to respond to a request.
MutantInjector.addMockResponse(
for: "https://api.example.com/users",
statusCode: 404,
method: .get, // optional, defaults to .all
jsonFilename: "response",
additionalParams: AdditionalRequestParameters(
responseDelay: 2.0 // Delay response for 2 seconds
)
)Although MutantInjector does not include any dedicated GraphQL methods, you can use the body-matching feature to intercept GraphQL requests or target a specific request when multiple requests are sent to the same URL endpoint.
MutantInjector.addMockResponse(
for: "https://api.example.com/users",
statusCode: 404,
method: .get, // optional, defaults to .all
jsonFilename: "response",
additionalParams: AdditionalRequestParameters(
bodyMatches: BodyMatchHelpers.jsonContainsObject { dict in
if let op = dict["operationName"] as? String, op == "GetUser" { //Matches a dictionary's key value
return true
}
return false
}
)
)In addition to intercepting requests and mocking responses, MutantInjection also allows you to log the API requests that your app is making.
/**
* The RequestLogMode options for logging are:
* - `.none`: No request logging will be performed (default mode).
* - `.compact`: Logs only the request method, URL, and body (if present).
* - `.verbose`: Logs full request details including headers and body.
*/
// Log all requests in verbose mode
MutantInjector.setRequestLogMode(.verbose)
// Log only specific URLs in compact mode
MutantInjector.setRequestLogMode(.compact, for: [
"https://api.example.com/users",
"https://api.example.com/posts"
])
// Log requests with a custom callback to handle the log data
MutantInjector.setRequestLogMode(.verbose) { logInfo in
print("🌐 \(logInfo.method) \(logInfo.url)")
if let headers = logInfo.headers {
print("📋 Headers: \(headers)")
}
if let body = logInfo.body {
print("📦 Body: \(body)")
}
}
// Log specific URLs with callback
MutantInjector.setRequestLogMode(.compact,
for: ["https://api.example.com/login"]) { logInfo in
print("Login request: \(logInfo.method) \(logInfo.url)")
}public struct RequestLogInfo {
public let method: String // HTTP method (GET, POST, etc.)
public let url: String // Full URL
public let headers: [String: String]? // Headers (verbose mode only)
public let body: Data? // Request body data (if present)
}import XCTest
@testable import YourAppModule
import MutantInjector
class NetworkTests: XCTestCase {
override func setUp() {
super.setUp()
MutantInjector.setupGlobalInterceptor()
}
override func tearDown() {
MutantInjector.tearDownGlobalInterceptor()
super.tearDown()
}
func testUserFetch() throws {
// Setup expectations
let expectation = XCTestExpectation(description: "Fetch users")
// Register mock response
MutantInjector.addMockResponse(
for: "https://api.example.com/users",
statusCode: 200,
method: .get, // optional, defaults to .all
jsonFilename: "users_success"
)
// Execute the code that makes the network request
let userService = UserService()
userService.fetchUsers { result in
switch result {
case .success(let users):
XCTAssertEqual(users.count, 1)
XCTAssertEqual(users.first?.name, "John Doe")
case .failure(let error):
XCTFail("Expected success but got error: \(error)")
}
expectation.fulfill()
}
// Wait for the expectation to be fulfilled
wait(for: [expectation], timeout: 1.0)
}
}MutantInjector isn't limited to testing scenarios - it can be invaluable during development as well:
When the backend is still under development or experiencing downtime, MutantInjector allows frontend developers to continue working:
// In your AppDelegate or application startup code
#if DEBUG
import MutantInjector
func setupMockResponses() {
MutantInjector.setupGlobalInterceptor()
// Register your development mocks
MutantInjector.addMockResponse(
for: "https://api.yourapp.com/v1/users",
statusCode: 200,
method: .get, // optional, defaults to .all
jsonFilename: "dev_users"
)
MutantInjector.addMockResponse(
for: "https://api.yourapp.com/v1/products",
statusCode: 200,
method: .get, // optional, defaults to .all
jsonFilename: "dev_products"
)
}
#endif
// Then call setupMockResponses() during app initializationWhen developing new features that depend on APIs not yet implemented:
// Feature flag system
if FeatureFlags.isNewFeatureEnabled {
// Setup mocks for new endpoints
MutantInjector.addMockResponse(
for: "https://api.yourapp.com/v2/new-feature",
statusCode: 200,
method: .get, // optional, defaults to .all
jsonFilename: "new_feature_response"
)
}Create a fully functional demo version of your app without requiring backend access:
// A helper class for demo mode
class DemoModeHelper {
static func enableDemoMode() {
MutantInjector.setupGlobalInterceptor()
registerAllMockResponses()
}
private static func registerAllMockResponses() {
// Register all the mock responses needed for demo mode
MutantInjector.addMockResponse(
for: "https://api.yourapp.com/v1/login",
statusCode: 200,
method: .get, // optional, defaults to .all
jsonFilename: "demo_login"
)
MutantInjector.addMockResponse(
for: "https://api.yourapp.com/v1/dashboard",
statusCode: 200,
method: .get, // optional, defaults to .all
jsonFilename: "demo_dashboard"
)
}
}
// Usage in app startup
if isDemoMode {
DemoModeHelper.enableDemoMode()
}MutantInjector can be used with SwiftUI previews to display UI components with realistic data:
#if DEBUG
import SwiftUI
import MutantInjector
struct UserProfileView_Previews: PreviewProvider {
static var previews: some View {
// Setup mock response for the preview
setupMockForPreview()
// Return the view that will use the mocked network response
return UserProfileView(userId: "preview-user-id")
}
static func setupMockForPreview() {
MutantInjector.setupGlobalInterceptor()
MutantInjector.addMockResponse(
for: "https://api.yourapp.com/v1/users/preview-user-id",
statusCode: 200,
method: .get, // optional, defaults to .all
jsonFilename: "preview_user_profile"
)
}
}
#endifCreate a centralized configuration for development environments:
// AppConfiguration.swift
#if DEBUG
import MutantInjector
struct AppConfiguration {
static func configureForDevelopment() {
// Setup API mocking only in development builds
MutantInjector.setupGlobalInterceptor()
// Register mock responses from a centralized catalog
MockResponseCatalog.registerAll()
}
}
// A catalog of all available mock responses
struct MockResponseCatalog {
static func registerAll() {
registerAuthResponses()
registerUserResponses()
registerContentResponses()
}
static func registerAuthResponses() {
MutantInjector.addMockResponse(
for: "https://api.yourapp.com/v1/login",
statusCode: 200,
method: .get, // optional, defaults to .all
jsonFilename: "dev_login_success"
)
MutantInjector.addMockResponse(
for: "https://api.yourapp.com/v1/login",
statusCode: 401,
method: .get, // optional, defaults to .all
jsonFilename: "dev_login_failed"
)
}
// Additional registration methods for other API categories
// ...
}
#endifMutantInjector uses method swizzling to inject a custom URLProtocol implementation into all URLSessionConfiguration instances. This allows it to intercept network requests and provide mock responses without requiring any changes to your application code.
- When
setupGlobalInterceptor()is called, MutantInjector:- Registers
MockURLProtocolwith the URL loading system - Uses the Objective-C runtime to track swizzling state without static variables
- Swizzles the
defaultandephemeralclass methods ofURLSessionConfiguration
- Registers
- When a network request is made:
MockURLProtocolchecks if there's a registered mock response for the URL using a thread-safe registry- If found, it returns the mock data from the specified JSON file
- If not, it passes the request through to the next protocol in the chain
- For thread safety and to avoid static variables:
- The implementation uses a dispatch queue-based concurrency model
- All shared state is managed via the Objective-C runtime's associated objects API
- The system is designed to be thread-safe without using static variables
// Set up the global interceptor
public static func setupGlobalInterceptor()
// Tear down the global interceptor
public static func tearDownGlobalInterceptor()
// Add a mock response using a JSON filename
public static func addMockResponse(
for url: String,
statusCode: Int,
method: RequestMethod,
jsonFilename: String,
additionalParams: AdditionalRequestParameters?,
identifier: String?
)
// Add a mock response using a direct URL to a JSON file
public static func addMockResponse(
for url: String,
statusCode: Int,
method: RequestMethod,
fileURL: URL,
additionalParams: AdditionalRequestParameters?,
identifier: String?
)
// Clear all registered mock responses
public static func clearAllMockResponses()- Call
setupGlobalInterceptor()at the start of your test andtearDownGlobalInterceptor()at the end to avoid affecting other tests - Place your mock JSON files in your test bundle for easier management
- Use descriptive names for your JSON files to make tests more readable
- For complex testing scenarios, create helper methods that register multiple mock responses at once
- Clear mock responses between tests using
clearAllMockResponses()to avoid cross-test contamination - When using in a Swift Package, take note that JSON resources need special handling - consider using an application target for tests that require resource files
- For high-concurrency environments, this implementation is designed to be thread-safe without static variables
- iOS 13.0+ / macOS 10.15+
- Swift 5.5+
- Xcode 13.0+
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the project
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
MutantInjector has been carefully designed to avoid using static variables, making it safer in concurrent environments:
- Thread-Safe Registry Pattern: Uses the Objective-C runtime's associated objects API to store state without static variables
- Dispatch Queue Concurrency: Ensures thread-safe access to shared resources through strategic use of concurrent queues with barriers
- Singleton Access Without Statics: Provides global access to singletons via class methods rather than static properties
- Modular Architecture: Separates concerns into distinct components (MockResponseManager, SwizzleRegistry, etc.)
Developers extending or modifying MutantInjector should maintain these design principles to ensure thread safety and avoid concurrency issues.
This project is licensed under the MIT License
- Inspired by the need for reliable network mocking in Swift applications
