Shock
A HTTP mocking framework written in Swift.
Summary
-
๐ Painless API mocking: Shock lets you quickly and painlessly provide mock responses for web requests made by your apps. -
๐งช Isolated mocking: When used with UI tests, Shock runs its server within the UI test process and stores all its responses within the UI tests target - so there is no need to pollute your app target with lots of test data and logic. -
โญ๏ธ Shock now supports parallel UI testing!: Shock can run isolated servers in parallel test processes. See below for more details! -
๐ Shock can now host a basic socket: In addition to an HTTP server, Shock can also host a socket server for a variety of testing tasks. See below for more details!
Installation
CocoaPods
Add the following to your podfile:
pod 'Shock', '~> x.y.z'
You can find the latest version on cocoapods.org
SPM
Copy the URL for this repo, and add the package in your project settings.
Mocking HTTP Requests
Shock aims to provide a simple interface for setting up your mocks.
Take the example below:
class HappyPathTests: XCTestCase {
var mockServer: MockServer!
override func setUp() {
super.setUp()
mockServer = MockServer(port: 6789, bundle: Bundle.module)
mockServer.start()
}
override func tearDown() {
mockServer.stop()
super.tearDown()
}
func testExample() {
let route: MockHTTPRoute = .simple(
method: .get,
urlPath: "/my/api/endpoint",
code: 200,
filename: "my-test-data.json"
)
mockServer.setup(route: route)
/* ... Your test code ... */
}
}
Bear in mind that you will need to replace your API endpoint hostname with 'localhost' and the port you specify in the setup method during test runs.
e.g. https://localhost:{PORT}/my/api/endpoint
In the case or UI tests, this is most quickly accomplished by passing a launch argument to your app that indicates which endpoint to use. For example:
let args = ProcessInfo.processInfo.arguments
let isRunningUITests = args.contains("UITests")
let port = args["MockServerPort"]
if isRunningUITests {
apiConfiguration.setHostname("http://localhost:\(port)/")
}
Note:
Route types
Shock provides different types of mock routes for different circumstances. All routes are conforming to the Codable protocol and can be decoded from a JSON file.
Simple Route
A simple mock is the preferred way of defining a mock route. It responds with the contents of a JSON file in the test bundle, provided as a filename to the mock declaration like so:
let route: MockHTTPRoute = .simple(
method: .get,
urlPath: "/my/api/endpoint",
code: 200,
filename: "my-test-data.json"
)
JSON
{
"type": "simple",
"method": "GET",
"urlPath": "/my/api/endpoint",
"code": 200,
"filename" : "my-test-data.json"
}
Custom Route
A custom mock allows further customisation of your route definition including the addition of query string parameters and HTTP headers.
This gives you more control over the finer details of the requests you want your mock to handle.
Custom routes will try to strictly match your query and header definitions so ensure that you add custom routes for all variations of these values.
let route = MockHTTPRoute = .custom(
method: .get,
urlPath: "/my/api/endpoint",
query: ["queryKey": "queryValue"],
requestHeaders: ["X-Custom-Header": "custom-header-value"],
responseHeaders: ["Content-Type": "application/json"],
code: 200,
filename: "my-test-data.json"
)
JSON
{
"type": "custom",
"method": "GET",
"urlPath": "/my/api/endpoint",
"query": {
"queryKey": "queryValue"
},
"requestHeaders": {
"X-Custom-Header": "custom-header-value"
},
"responseHeaders": {
"Content-Type": "application/json"
},
"code": 200,
"filename": "my-test-data.json"
}
Redirect Route
Sometimes we simply want our mock to redirect to another URL. The redirect mock allows you to return a 301 redirect to another URL or endpoint.
let route: MockHTTPRoute = .redirect(urlPath: "/source", destination: "/destination")
JSON
{
"type": "redirect",
"urlPath": "/source",
"destination": "/destination"
}
Templated Route
A templated mock allows you to build a mock response for a request at runtime. It uses Mustache to allow values to be built in to your responses when you setup your mocks.
For example, you might want a response to contain an array of items that is variable size based on the requirements of the test.
Check out the /template
route in the Shock Route Tester example app for a
more comprehensive example.
let route = MockHTTPRoute = .template(
method: .get,
urlPath: "/template",
code: 200,
filename: "my-templated-data.json",
templateInfo: [
"list": ["Item #1", "Item #2"],
"text": "text"
])
)
JSON
{
"type": "template",
"method": "GET",
"urlPath": "/template",
"code": 200,
"filename": "my-templated-data.json",
"templateInfo": {
"list": ["Item #1", "Item #2"],
"text": "text"
}
}
Collection
A collection route contains an array of other mock routes. It is simply a container for storing and organising routes for different tests. In general, if your test uses more than one route
Collection routes are added recursively, so a given collection route can be included in another collection route safely.
let firstRoute: MockHTTPRoute = .simple(method: .get, urlPath: "/route1", code: 200, filename: "data1.json")
let secondRoute: MockHTTPRoute = .simple(method: .get, urlPath: "/route2", code: 200, filename: "data2.json")
let collectionRoute: MockHTTPRoute = .collection(routes: [ firstRoute, secondRoute ])
JSON
{
"type": "collection",
"routes": [
{
"type": "simple",
"method": "GET",
"urlPath": "/my/api/endpoint",
"code": 200,
"filename" : "my-test-data.json"
},
{
"type": "simple",
"method": "GET",
"urlPath": "/my/api/endpoint2",
"code": 200,
"filename" : "my-test-data2.json"
}
]
}
Timeout Route
A timeout route is useful for testing client timeout code paths. It simply waits a configurable amount of seconds (defaulting to 120 seconds). Note if you do specify your own timeout, please make sure it exceeds your client's timeout.
let route: MockHTTPRoute = .timeout(method: .get, urlPath: "/timeouttest")
let route: MockHTTPRoute = .timeout(method: .get, urlPath: "/timeouttest", timeoutInSeconds: 5)
JSON
{
"type": "timeout",
"method": "GET",
"urlPath": "/timeouttest",
"timeoutInSeconds": 5
}
Force all calls to be mocked
In some case you might prefer to have all the calls to be mocked so that the tests can reliably run without internet connection. You can force this behaviour like so:
server.shouldSendNotFoundForMissingRoutes = true
This will send a 404 status code with an empty response body for any unrecognised paths.
Middleware
Shock now support middleware! Middleware lets you use custom logic to handle a given request.
๐ค Middleware can be used with or without mock routes.โ Middleware is chainable with the first middleware added receiving the context first, passing it to the next, and so on
ClosureMiddleware
The simplest way to use middleware is to add an instance of ClosureMiddleware to the server. For example:
let myMiddleware = ClosureMiddleware { request, response, next in
if request.headers["X-Question"] == "Can I have a cup of tea?" {
response.headers["X-Answer"] = "Yes, you can!"
}
next()
}
mockServer.add(middleware: myMiddleware)
The above will look for a request header named X-Question
and, if it is present with the
expected value, it will send back an answer in the 'X-Answer' response header.
Using Mock Routes and Middleware Together
Mock routes and middleware work fine together but there are a few things worth bearing in mind:
- Mock routes is managed by are managed by a single middleware
- This middleware will be added to the existing stack of middlewares when the first mock route is added to the server.
For middleware such as the example above, the order of middleware won't matter. However, if you are making changes to a part of the response that was already set by the mock routes middleware, you may get unexpected results!
Socket Server
Shock can now host a socket server in addition to the HTTP server. This is useful for cases where you need to mock
HTTP requests and a socket server. The Socket server uses familiar terminology to the HTTP server, so it has inherited
the term "route" to refer to a type of socket data handler. The API is similar to the HTTP API in that you need to create a
MockServerRoute
, call setupSocket
with the route and when server start
is called a socket will be setup with
your route (assuming at least one route is registered).
If no MockServerRoute
s are setup, the socket server is not started.
Prerequisites
The socket server can only be hosted in addition to the HTTP server, as such Shock will need a port range of
at least two ports, using the init
method that takes a range.
let range: ClosedRange<Int> = 10000...10010
let server = MockServer(portRange: range, bundle: ...)
Available routes
There is only one route currently available for the socket server and that is logStashEcho
. This route will setup a socket
that accepts messages being logged to Logstash and echo them back as strings.
Here is an example of using logStashEcho
with our JustLog framework.
import JustLog
import Shock
let server = MockServer(portRange: 9090...9099, bundle: ...)
let route = MockSocketRoute.logStashEcho { (log) in
print("Received \(log)"
}
server.setupSocket(route: route)
server.start()
let logger = Logger.shared
logger.logstashHost = "localhost"
logger.logstashPort = UInt16(server.selectedSocketPort)
logger.enableLogstashLogging = true
logger.allowUntrustedServer = true
logger.setup()
logger.info("Hello world!")
It's worth noting that Shock is an untrusted server, so the logger.allowUntrustedServer = true
is necessary.
Shock Route Tester
The Shock Route Tester example app lets you try out the different route types.
Edit the MyRoutes.swift
file to add your own and test them in the app.
License
Shock is available under Apache License 2.0. See the LICENSE file for more info.