Native mock server inside XCTest
Introduction
An automated testing can be a tricky thing, especially when you’re dealing with unstable or constantly changing backend. What if we could get rid of the real backend and use a mock server in our tests instead? Perhaps that would solve a couple of our problems, eh?
Check out this repository to dive into the source code I’ll be playing with in this note.
Precondition
I’ll use Swifter just because I love it and it’s super easy to start hacking right away with it. But really there are several similar tools, so grab any other if Swifter does not suit you.
Create sample project
- Open Xcode
- Click on
Create a new Xcode project
- Choose
iOS App
as a template - Fill the product name and the team name
- Choose
SwiftUI
as an interface - Click on the
Next
button - Open the
ContentView.swift
file - Fill it in with this code
- Run an app on any iOS Simulator
-
Tap on the
Game controller 🎮
The app pulls a random user from the backend
Install Swifter
- Open Xcode
- Go to
File
=>Add Packages...
- Type
https://github.com/httpswift/swifter.git
in the search field - Click on the
Add Package
button - Open
UITests
target =>Build Phases
tab - Click on the plus button under the
Link Binary With Libraries
section - Find
Swifter
from the list under theSwift Package
section - Click on the
Add button
- Open a
<package name>UITests
file -
Import Swifter
import Swifter
-
Create this variable in the test class
private var server = HttpServer()
-
Create this method in the test class
private func startServer() { do { try server.start(4567) print("Server status: \(server.state)") } catch { print("Server start error: \(error)") } }
-
Run this test to make sure everything goes fine (hopefully, you see a green light ✅)
func testUsername() { startServer()() XCUIApplication().launch() XCUIApplication().images["gamecontroller"].coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)).tap() let username = XCUIApplication().staticTexts["username"] XCTAssertTrue(username.exists) }
Mock production backend
- Open the
ContentView.swift
file -
Replace an existed function
apiUrl()
with:func apiUrl() -> URL { var urlString = "https://random-data-api.com" #if DEBUG if ProcessInfo.processInfo.arguments.contains("MOCK_SERVER") { urlString = "http://localhost:4567" } #endif let url = URL(string: urlString)! return url }
-
Run an app on iOS Simulator and tap on the
Game controller 🎮
3.1 The result should remain the same, except for the username of course
- Open a
<package name>UITests
file -
Create new methods and variables to mock the required endpoint
private var username = "" private func configureServer() { server["/api/v2/users"] = { [self] _ in var response = readData(fromFile: "users") response["first_name"] = username print("\nGAME STARTED 🏈\n\(response.toString(prettyPrinted: true))") return .ok(.json(response)) } } private func mockUsername(_ username: String) { self.username = username } private func readData(fromFile name: String, ext: String = "json") -> [String: Any] { let bundle = Bundle(for: Self.self) let url = bundle.url(forResource: name, withExtension: ext)! let data = try! Data(contentsOf: url) return String(bytes: data, encoding: .utf8)!.json } extension String { var json: [String: Any] { try! JSONSerialization.jsonObject(with: Data(self.utf8), options: .mutableContainers) as! [String: Any] } }
- Create a json template users.json in your project to mock the response from the backend
-
Update the test and run it again
func testUsername() { startServer()() configureServer() XCUIApplication().launchArguments = ["MOCK_SERVER"] XCUIApplication().launch() let expectedUsername = "Frodo" mockUsername(expectedUsername) XCUIApplication().images["gamecontroller"].coordinate(withNormalizedOffset: CGVector(dx: 0.0, dy: 0.0)).tap() let actualUsername = XCUIApplication().staticTexts["username"].label XCTAssertEqual(expectedUsername, actualUsername) }
-
Look at the green light ✅
The app pulls the expected user from the mock server
Explore the mock server
Get request details
server["/api/v2/users"] = { request in
let headers = request.headers
let queryParams = request.queryParams
let address = request.address
let body = request.body
let params = request.params
let path = request.path
let method = request.method
return HttpResponse.ok(.text("""
{ "hello": "world" }
""")
)
}
Mock dynamic endpoint
server["api/:version/users"] = { request in
let apiVersion = request.params[":version"]
return HttpResponse.ok(.text("""
{ "hello": "world" }
""")
)
}
Return internal server error
server["api/:version/users"] = { _ in
.internalServerError
}
Redirect
server["api/:version/users"] = { _ in
.movedPermanently("http://www.google.com")
}
Customize response details
server["api/:version/users"] = { _ in
let body = """
{ "hello": "world" }
""".data(using: .utf8)
let statusCode = 33
let message = "Test message"
let headers = ["User-Agent": "fake", "Test-Header": "test"]
return HttpResponse.raw(statusCode, message, headers, { (writer) in
try writer.write(body!)
})
}
WebSockets
server["/websocket-echo"] = websocket(text: { session, text in
session.writeText(text)
}, connected: { [weak self] _ in
print("WS connected")
}, disconnected: { [weak self] _ in
print("WS disconnected")
})
Set up a random port for parallel tests
private func startServer(
port: in_port_t = in_port_t.random(in: 8081..<10000),
maximumOfAttempts: Int = 10
) {
guard maximumOfAttempts > 0 else { return }
do {
try server.start(port, forceIPv4: true)
} catch SocketError.bindFailed(let message) where message == "Address already in use" {
startServer(maximumOfAttempts: maximumOfAttempts - 1)
} catch {
print("Server start error: \(error)")
}
}