SBTUITunneledApplication
's headers are well commented making the library's functionality self explanatory. You can also checkout the UI test target in the example project which show basic usage of the library.
Instead of calling the launch()
method on XCUIApplication
as you're used to use launchTunnel()
or launchTunnel(options:startupBlock:)
. These methods will launch the test and establish the tunnel connection.
import SBTUITestTunnelClient
class MyTestClass: XCTestCase {
override func setUp() {
super.setUp()
app.launchTunnel()
}
func testStuff() {
// ...
}
}
Note how we don't need to instantiate the app
property
app.launchTunnel(withOptions: [SBTUITunneledApplicationLaunchOptionResetFilesystem]) {
// do additional setup before the app launches
// i.e. prepare stub request, start monitoring requests
}
SBTUITunneledApplicationLaunchOptionResetFilesystem
will delete the entire app's sandbox filesystemSBTUITunneledApplicationLaunchOptionDisableUITextFieldAutocomplete
disables UITextField's autocomplete functionality which can lead to unexpected results when typing text.
The startup block contains code that will be executed before the app enters the applicationDidFinishLaunching(_:)
. This is the right place to setup the application before it gets launched
The stubbing/monitoring/throttling/cookie blocking and rewrite functionalities of the library require a SBTRequestMatch
object in order to determine whether they should respond to a certain network request.
You can specify a regex on the URL, multiple regex on the query, body and HTTP method using one of the several available class methods.
The query
parameter in SBTRequestMatch
's initializer is an array of regex strings that are checked with the request query. If all regex in array match the request is handled by the library.
In a kind of unconventional syntax you can prefix the regex with an exclamation mark !
to specify that the request must not match that specific regex, see the following examples.
The body
parameter allows to match the request against its HTTP Body. As for the query
parameter, the passed value is used as a regex which is evaluated on the request HTTP Body and the exlamation mark !
can be used to specify an "inverted match" (i.e. that the HTTP Body should NOT match the provided body
pattern).
The requestHeader
and responseHeader
parameters allows to match the request against the HTTP request and response headers respectively. There parameters are [String: String] dictionaries with the key and value being regexes witch are evaluated on the key/value of the request/response headers. The regex follows the same rules that apply to the query
parameter.
The regex in GET
and DELETE
requests will match the entire URL including query parameters.
Below some matches for a sample request like http://wwww.myhost.com/v1/user/281218/info?param1=val1¶m2=val2 :
// this will match the request independently of the query parameters
let sr = SBTRequestMatch.url("myhost.com/v1/user/281218/info")
// this will match the request independently of the query parameters for any user id
let sr = SBTRequestMatch.url("myhost.com/v1/user/.*/info")
// this will match the request containing query parameters
let sr = SBTRequestMatch.url("myhost.com/v1/user/.*/info\?param1=val1¶m2=val2")
// this will match the request containing only param1 = val1 query
let sr = SBTRequestMatch.url("myhost.com/v1/user/.*/info\?.*param1=val1")
Given that parameter order isn't guaranteed it is recommended to specify the query
parameter in the SBTRequestMatch
's initializer. This is an array of regex that need to fulfill all for the request to match.
Considering the previous example the following SBTRequestMatch
will match if the request contains param1=val1
AND param2=val2
.
let sr = SBTRequestMatch.url("myhost.com/v1/user/.*/info", query: ["¶m1=val1", "¶m2=val2"])
let sr = SBTRequestMatch.url("myhost.com/v1/user/.*/info", query: ["¶m2=val2", "¶m1=val1"])
You can additionally specify that the query should not contain something by prefixing the regex with an exclamantion mark !
:
let sr = SBTRequestMatch.url("myhost.com/v1/user/.*/info", query: ["¶m1=val1", "¶m2=val2", "!param3=val3"])
This will match if the query contains param1=val1
AND param2=val2
AND NOT param3=val3
The body
parameter can be used to match HTTP Body and also supports !
to specify "inverted matches":
let sr = SBTRequestMatch(url: "myhost.com", query: [], method: "POST", body: "SomeBodyContent")
let sr = SBTRequestMatch(url: "myhost.com", query: [], method: "POST", body: "!UnwantedBodyContent")
This will match if the request headers contain both a header with key Accept-Enc*=gzip*
and another header Accept-Language=en
. The request MUST contain at least both these headers for the SBTRequestMatch to match.
let sr = SBTRequestMatch(url: "myhost.com", requestHeaders: ["Accept-Enc.*": "gzip.*", "Accept-Language": "en"])
Finally you can limit a specific HTTP method by specifying it in the method
parameter.
// will match GET request only
let sr = SBTRequestMatch.url("myhost.com/v1/user/.*/info", query: ["¶m1=val1", "¶m2=val2"], method: "GET")
let sr = SBTRequestMatch.url("myhost.com/v1/user/.*/info", method: "GET")
To stub a network request you pass the appropriate SBTRequestMatch
and SBTStubResponse
objects
let match = SBTRequestMatch.url("google.com")
let stubId = app.stubRequests(matching: match, response: SBTStubResponse(response: ["key": "value"])
// from here on network requests containing 'google.com' will return a JSON {"key" : "value" }
...
app.stubRequestsRemove(id: stubId) // To remove the stub either use the identifier
app.stubRequestsRemove(requestMatch: match) // or the SBTRequestMetch
app.stubRequestsRemoveAll() // You can also choose to remove all active stubs
You can get a list of the current active stubs
let stubs: [SBTActiveStub] = app.stubRequestsAll()
The stubbing methods of the library require a SBTStubResponse
object in order to determine what to return for network requests that have to be stubbed.
The class has a wide set of initializers that allow to specify data, HTTP headers, contentType, HTTP return code and response time of the stubbed request. For convenience some initializers omit some parameters which default to predefined values.
You can also specify how many times the stubbed response should be used by specifying the activeIterations
parameter in the initializer. The default value is 0 which means stubbing will be performed indefinitely.
let stubId = app.stubRequests(matching: SBTRequestMatch.url("google.com"), response: SBTStubResponse(response: ["key": "value"], activeIterations: 2))
// from here on the first two network requests containing 'google.com' will return a JSON {"key" : "value" }
// subsequent network requests won't be stubbed
...
To stub specific url errors, like for example not connected to internet you can use an instance of SBTStubFailureResponse
let response = SBTStubFailureResponse(errorCode: URLError.notConnectedToInternet.rawValue)
app.stubRequests(matching: SBTRequestMatch.url("google.com"), response: response)
The URLSession will fail with an Error
according to the specified errorCode
.
It's possible to specify multiple stubs for the same request match: stubs are evaluated in LIFO order (the latest added stub will be used). Adding multiple stubs for the same request match can be very powerful when combined with activeIterations
; we can specify in advance a "series" of stubs we want to use which will be applied in order and removed when their activeIterations
value reaches zero. This allows to test some cases where multple network calls are peformed subsequently and is not possibile to "manually" substitute a stub for the same network call.
For example we can test that, when receiving an authentication error, a client performs a credentials refresh and retries the original network request.
app.stubRequests(matching: requestMatchForAuthenticatedCall,
response: successfulResponse)
// Note that we remove authentication error response after one iteration
app.stubRequests(matching: requestMatchForAuthenticatedCall,
response: authenticationErrorResponse(activeIterations: 1))
app.monitorRequests(matching: credentialsRefreshCall, during: {
// action to perform authenticated call
})
verifyAuthenticatedCallSuccess()
let pathToFile = ... // path to file
app.uploadItem(atPath: pathToFile, toPath: "test_file.txt", relativeTo: .documentDirectory)
let uploadData = app.downloadItems(fromPath: "test_file.txt", relativeTo: .documentDirectory)
This may come in handy when you need to check that specific network requests are made. You first specify one or more network requests you want to monitor by passing SBTRequestMatch
like for stubbing methods. Once you performed actions in the app that triggered those request you can retrieve them by using monitoredRequestsPeekAll
or monitoredRequestsFlushAll
. The former will return all the collected network requests, the latter will additionally clear the list of requests.
app.monitorRequests(matching: SBTRequestMatch.url("apple.com"))
// Interact with UI. Once ready flush calls and get the list of requests
let requests: [SBTMonitoredNetworkRequest] = app.monitoredRequestsFlushAll()
for request in requests {
let requestBody = request.request!.HTTPBody // HTTP Body in POST request?
let responseJSON = request.responseJSON
let requestTime = request.requestTime // How long did the request take?
}
app.monitorRequestRemoveAll()
The library allows to throttle network calls by specifying a response time, which can be a positive number of seconds or one of the predefined SBTUITunnelStubsDownloadSpeed*
constants. You pass an SBTRequestMatch
like for stubbing methods.
let throttleId = app.throttleRequests(matching: SBTRequestMatch.url("apple.com"), responseTime:SBTUITunnelStubsDownloadSpeed3G) ?? ""
app.throttleRequestRemove(withId: throttleId)
The library allows to block outgoing cookies in network calls. You pass an SBTRequestMatch
like for stubbing methods.
let cookieBlockId = app.blockCookiesInRequests(matching: SBTRequestMatch.url("apple.com")) ?? ""
app.blockCookiesRequestsRemove(withId: cookieBlockId)
The library allows to rewrite the following elements of a network call:
- url
- request body
- request headers
- response body
- response headers
- response status code
To rewrite a network request you pass the appropriate SBTRequestMatch
and SBTRewrite
objects.
Note: rewrite does not work on stubbed requests.
The rewrite methods of the library require a SBTRewrite
object in order to determine what to rewrite in the network requests that have to be rewritten.
The class has a wide set of initializers that allow to specify request and response body/headers, return status codes and URL.
The header rewrite is specified by passing a dictionary where the key will replace (if key already exist) or add new values to the headers. To remove a specific key just pass an empty value.
The other rewrite are specified by an array of SBTRewriteReplacement
.
As for SBTStubResponse
you can instantiate passing the activeIterations
parameter to rewrite a specific number of network requests
The SBTRewriteReplacement
has a simple initializer where you pass 2 parameters: a regular expression that will be matched and a replacement string.
You can interact with the app's NSUserDefaults as follows
app.userDefaultsSetObject("test_value" as NSCoding, forKey: "test_key")
let obj = app.userDefaultsObject(forKey: "test_key")
app.userDefaultsRemoveObject(forKey: "test_key")
You can easily add a custom block of code in the application target that can be conveniently invoked from the test target. An NSString identifies the block of code when registering and invoking it.
You register a block of code that will be invoked from the test target as follows:
SBTUITestTunnelServer.registerCustomCommandNamed("myCustomCommandKey") {
injectedObject in
// this block will be invoked from app.performCustomCommandNamed()
return "Any object conforming to NSCoding that you want to pass back to test target"
}
Note It is your responsibility to unregister the custom command when it is no longer needed. Failing to do so may end up with unexpected behaviours.
You invoke the custom command by using the same identifier used on registration, optionally passing an NSObject:
let objReturnedByBlock = app.performCustomCommandNamed("myCustomCommand", object: someObjectToInject)
The tunnel adds methods to perform fine grain scrolling for table/collection/scroll views.
IMPORTANT: when using SwiftUI it is crucial to ensure that you interact with the XCUIElement before invoking the scrolling APIs.
For example:
app.button["Go to details"].tap()
// Perform a query on the XCUIElement you will be calling the scroll methods, for
// example checking for its existence
XCTAssert(app.collectionViews["details"].exists) // <--
app.scrollCollectionView(withIdentifier: "details", toElementIndex: 5, animated: true)
Pass the accessibility label/identifier of the table along with the row you want to scroll to. The row is an int which is section-wise flattened, if you want to scroll to last cell you can pass Int.max or any integer greater than the number of items in datasource
app.scrollTableView(withIdentifier: "identifier", toRow: .max, animated: false)
Pass the accessibility label/identifier of the table along with the accessibility label/identifier of the element you want to scroll to. Currently only vertical scrolling is supported.
app.scrollTableView(withIdentifier: "identifier", toElementWithIdentifier: "elementIdentifier", animated: false)
Pass the accessibility label/identifier of the collection along with the row you want to scroll to. The row is an int which is section-wise flattened, if you want to scroll to last cell you can pass Int.max or any integer greater than the number of items in datasource
app.scrollCollectionView(withIdentifier: "identifier", toRow: .max, animated: false)
Pass the accessibility label/identifier of the collection along with the accessibility label/identifier of the element you to scroll to. Currently only vertical scrolling is supported.
app.scrollCollectionView(withIdentifier: "identifier", toElementWithIdentifier: "elementIdentifier", animated: false)
Pass the accessibility label/identifier of the scrollView along with the accesibility lable/identifier of an element you want to scroll to.
app.scrollScrollViewWithIdentifier(withIdentifier: "identifier", toElementWitIdentifier: .max, animated: false)
Pass the normalized offset (0.0 - 1.0) of the scrollView you want to scroll to.
app.scrollScrollViewWithIdentifier(withIdentifier: "identifier", toOffset: 0.15, animated: true)
It is possible to enable CLLocationManager stubbing in order to test that your app properly responds to the various authorization statuses and location change event.
Disable the system location engine and enable stubbing (startMonitoring, stopMonitoring and other similar methods will become nops)
app.coreLocationStubEnabled(true)
Once enabled the default value for +[CLLocationManager authorizationStatus]
will be kCLAuthorizationStatusAuthorizedAlways
and +[CLLocationManager locationServicesEnabled]
will be YES
.
When stubbing is enable you can change the value that will be returned by +[CLLocationManager authorizationStatus]
using
app.coreLocationStubAuthorizationStatus(.denied)
When stubbing is enable you can change the value that will be returned by CLLocationManager.accuracyAuthorization
using
app.coreLocationStubAuthorizationStatus(.denied)
When stubbing is enable you can change the value that will be returned by +[CLLocationManager locationServicesEnabled]
using
app.coreLocationStubLocationServicesEnabled(false)
When stubbing is enabled you can change the value that will be returned by -[CLLocationManager location]
using
let location: CLLocation = ...
app.coreLocationStubManagerLocation(location)
When stubbing is enable (see) you can trigger a location change by using
app.coreLocationStubEnabled(true)
...
let locations: [CLLocation] = ...
app.coreLocationNotifyLocationUpdate(locations)
When stubbing is enable (see) you can trigger a location change by using
app.coreLocationStubEnabled(true)
...
let errror: Error = ...
app.coreLocationNotifyLocationError(errror)
It is possible to enable UNUserNotificationCenter stubbing in order to test that your app properly responds to the various authorization statuses.
Disable the system notification engine and enable stubbing
app.notificationCenterStubEnabled(true)
Once enabled the default value for -[UNNotificationSettings authorizationStatus]
will be UNAuthorizationStatusAuthorized
.
When stubbing is enable you can change the value that will be returned by -[UNNotificationSettings authorizationStatus]
using
app.notificationCenterStubAuthorizationStatus(.denied)
WKWebViews do not natively support NSURLProtocol which is used by SBTUITestTunnel to intercepect network requests allowing for stubbing/throttling/monitoring. To interact with network requests generated inside WKWebViews you'll neet to explicitly opt-in by invoking:
app.wkWebViewStubEnabled(true)
IMPORTANT NOTICE
Beware that to support NSURLProtocol inside WKWebViews we're calling internal APIs that have a side effect that body of POST requests generated inside the WKWebView are stripped away.