XCUITest Stubbing Network Calls
When it comes to stubbing data for testing your iOS application, there are a lot of options. One of the most popular being WireMock. This is what we started with for testing of network calls.
WireMock Implementation
The Good
Within minutes, you can have your WireMock implementation ready to go. We used the standalone server implementation which we could then run the instance locally or on a shared resource. Its structure is pretty simple — it has two main folders, __files and mappings. The mappings folder contains the files which specifies the URL Requests to match on and the Response to send back. The __files folder contains the response you want to send back. A simple example:
{
"request": {
"method": "GET",
"url": "/people"
},
"response": {
"status": 200,
"headers":
{
"Content-Type" : "application/xml"
},
"bodyFileName": "/people.xml"
}
}
By default when you run the WireMock server, it will run on port 8080 so the above mapping file would intercept the network call that matches “http://localhost:8080/people” and it would return the response with a status code of 200 and the response body with the contents of the people.xml file located in the __files directory.
WireMock has a lot of options for request matching which is also convenient but i’m not going to dive into those in this article.
The Problem
Going to use a simple example to illustrate (your application workflow will end up being a lot more complex i’m sure):
Your “Shopping List” application launches and the user logs in. It makes a network call to “/list” and displays a list of items. You want to test the following scenarios: No Items, 1 Item, 15 items, etc. Using WireMock, we can set it to match the url of “/list” then it can return the data given in the response section (or file specified). Let’s say we set this up to return no items:
{
"request": {
"method": "GET",
"url": "/list"
},
"response": {
"status": 200,
"headers":
{
"Content-Type" : "application/xml"
},
"jsonBody": []
}
}
So your test case runs successfully and shows an empty list! But wait… how do we now test 1 item?! 🤔.
Can you use the logged in user information? If the request header contains some type of information that would make each call unique to that particular user you might be able to do something like this for the user tom:
{
"request": {
"method": "GET",
"url": "/list",
"bodyPatterns": [
{
"contains": "tom"
}
]
},
"response": {
"status": 200,
"headers":
{
"Content-Type" : "application/xml"
},
"jsonBody": []
}
}
So now it will only return an empty list for the user Tom, and you could set up another user to return the scenario of 1 item, and another user for the scenario of 15 items. Oh boy, this sounds like it could be a lot to end up managing! 😖 Yes, this was the downfall. We ended up keeping a list of users that we should use for particular scenarios and as our application grew and became more complex, this list grew and made our heads hurt 😃.
WireMock does support Stateful Behavior which would allow you to track a state of a call and return a response based on the last known state. This still can become difficult to manage and if failures occur causing the states not to get reset properly, it can spiral the failures down.
Our Solution
Integrating SBTUITestTunnel allowed us to stub network calls with much more control.
Below, app is an instance of SBTUITunneledApplication. To launch the application, you use app.launchTunnel() instead of app.launch(). More information regarding setup can be found on the Usage page.
As with WireMock, SBTUITestTunnel supports different request and response options. Detailed information on that can be found within the Stubbing section.
Here is an example of how we could now stub the “/list” call. This will return the contents of the specified file when the given URL is matched.
import SBTUITestTunnelServer
import SBTUITestTunnelClientstruct StubHelper {
static func stubFile(fileName: String, url: String) {
let responseFile = SBTStubResponse(fileNamed: fileName) app.stubRequests(matching: SBTRequestMatch(url: url),
response: responseFile)
}
}
This will launch the application, set up the stubbing match request, login and perform the actions in the application to cause the network call to happen, return the stubbed response, and finally remove any stubbed request matching so that the next test can be set up using it’s desired stubbing.
import XCTest
import SBTUITestTunnelServer
import SBTUITestTunnelClientclass MyShoppingListTest: { var app: SBTUITunneledApplication override func setUp() {
app.launchTunnel()
} override func tearDown() {
app.stubRequestsRemoveAll()
} func testNoItems() {
StubHelper.stubFile(fileName: "listNone.json", url: ".*/list")
... login and perform any verification ...
} func testOneItem() {
StubHelper.stubFile(fileName: "listOne.json", url: ".*/list")
... login and perform any verification ...
} func testTwentyItems() {
StubHelper.stubFile(fileName: "listTwenty.json", url:
".*/list")
... login and perform any verification ...
}
}
If your application resends the call when refreshing the list or by navigating away then coming back, you could now support mocking different scenarios in these cases as well. Example:
import XCTest
import SBTUITestTunnelServer
import SBTUITestTunnelClientclass MyShoppingListTest: { private static var hasRunAtLeastOnce = false
var app: SBTUITunneledApplication override func setUp() {
if hasRunAtLeastOnce == false {
hasRunAtLeastOnce = true
app.launchTunnel()
StubHelper.stubFile(fileName: "listNone.json (see note
below)", url: ".*/list")
... login ...
}
} override func tearDown() {
app.stubRequestsRemoveAll()
} func testNoItems() {
StubHelper.stubFile(fileName: "listNone.json", url: ".*/list")
... refresh the list (i.e. swipe down to refresh) ...
...verify list...
} func testOneItem() {
StubHelper.stubFile(fileName: "listOne.json", url: ".*/list")
... refresh the list (i.e. swipe down to refresh) ...
...verify list...
} func testTwentyItems() {
StubHelper.stubFile(fileName: "listTwenty.json", url:
".*/list")
... refresh the list (i.e. swipe down to refresh) ...
...verify list...
}
}
Note: the initial stubbing of listNone.json will only be used for the initial login and won’t affect the results of the test scenarios here.
hasRunAtLeastOnce is a way to work around the issue of Xcode not supporting application launch in the class setUp() function. See more information on that issue here.
Furthermore, you can make this even better for REST API’s which return a JSON response! A basic example is displayed below to demonstrate this functionality:
People.swift
struct People: Codable {
var name: String
var age: Int?
}
OnePerson.json:
{
"name": "Tom",
"age": 10
}
PeopleStub.swift:
class PeopleStub: Codable {
var data: People! init(fileName: String = "OnePerson.json") {
let json = readFile(name: fileName)
self.data = try! JSONDecoder().decode(T.self, from: json)
} func readFile(name: String) -> Data {
let nameAndExtension = name.components(separatedBy: ".")
guard let path = Bundle(for: type(of:
self)).url(forResource: nameAndExtension.first ?? "",
withExtension: nameAndExtension.last ?? "")
else {
fatalError("File: \(name) doesn't exists")
} return try! Data(contentsOf: path)
} func stub() {
let url = ".*/people"
let data = try! JSONEncoder().encode(self.data)
app.stubRequests(matching: SBTRequestMatch(url: requestURL,
query: queryParams), response:
SBTStubResponse(response: strJson))
} func stubReallyLongName() {
self.data.name = "Thomas John Black Somereallylonglastname"
self.stub()
} func stubNoAge() {
self.data.age = null
self.stub()
}
}
PeopleTest.swift:
import XCTest
import SBTUITestTunnelServer
import SBTUITestTunnelClientclass PeopleTest: { var app: SBTUITunneledApplication
let peopleStub = PeopleStub() override func setUp() {
app.launchTunnel()
} override func tearDown() {
app.stubRequestsRemoveAll()
} func testLongName() {
peopleStub.stubReallyLongName()
... perform steps that would trigger the people API call...
} func testNoAge() {
peopleStub.stubNoAge()
... perform steps that would trigger the people API call...
}
}
This approach allows you to dynamically change the stub data as needed thru the Swift code instead of needing a different stub file for every scenario. 🙌.
You are able to support a similar mocking approach to SBTUITestTunnel with Android Espresso tests using WireMock as described in this article. I have not found a solution yet using WireMock for iOS testing. WireMockTest is a fairly newer option that looks like it has potential but only appears to support request matching on relative path at this time and only has one contributor whereas SBTUITestTunnel has several.
Know of other methods that work well? Let me know in the comments! Thanks and happy testing 😃