I know this should be much easier, but I could not find a solution that is less complicated. However it is flexible and works.
The basic idea is to use a helper app that sends a test location to the app under test.
Here are the required steps:
1) Set up a helper app:
This is a single view app that displays test locations in a tableView:
import UIKit
import CoreLocation
class ViewController: UIViewController {
struct FakeLocation {
let name: String
let latitude: Double
let longitude: Double
}
@IBOutlet weak var tableView: UITableView!
var fakeLocations: [FakeLocation] = [
FakeLocation.init(name: "Location 1", latitude: 8.0, longitude: 49.0),
FakeLocation.init(name: "Location 2", latitude: 8.1, longitude: 49.1)
]
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = self
tableView.delegate = self
}
} // class ViewController
// MARK: - Table view data source
extension ViewController: UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return fakeLocations.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell")!
let row = indexPath.row
let fakeLocation = fakeLocations[row]
cell.textLabel?.text = fakeLocation.name
return cell
}
} // extension ViewController: UITableViewDataSource
extension ViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let cell = tableView.cellForRow(at: indexPath)
cell?.isSelected = false
let row = indexPath.row
let fakeLocation = fakeLocations[row]
let fakeLocationString = String.init(format: "%f, %f", fakeLocation.latitude, fakeLocation.longitude)
let urlEncodedFakeLocationString = fakeLocationString.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed)
let url = URL.init(string: "setFakeLocation://" + urlEncodedFakeLocationString!)
UIApplication.shared.open(url!) { (urlOpened) in
guard urlOpened else {
print("could not send fake location")
return
}
}
}
} // extension ViewController: UITableViewDelegate
If one taps a row in the tableView, the helper app tries to open a custom URL that contains the coordinates of the corresponding test location.
2) Prepare the app under test to process this custom URL:
Inset in the info.plist the following rows:
This registers the custom URL "setFakeLocation://
". In order that the app under test can process this URL, the following two functions in the AppDelegate
have to return true
:
func application(_ application: UIApplication,
willFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey : Any]? = nil) -> Bool {
return true
}
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
return true
}
Additionally, one has to implement the function that actually opens the URL:
func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool {
let urlString = url.absoluteString
let splittedString = urlString.components(separatedBy: "://")
let coordinatesURLEncodedString = splittedString[1]
let coordinateString = coordinatesURLEncodedString.removingPercentEncoding!
let coordinateStrings = coordinateString.components(separatedBy: ", ")
let latitude = Double(coordinateStrings[0])!
let longitude = Double(coordinateStrings[1])!
let coordinate = CLLocationCoordinate2D.init(latitude: latitude, longitude: longitude)
let location = CLLocation.init(coordinate: coordinate,
altitude: 0,
horizontalAccuracy: 0,
verticalAccuracy: 0,
timestamp: Date())
let locationManager = LocationManager.shared
locationManager.location = location
locationManagerDelegate?.locationManager(locationManager, didUpdateLocations: [location])
return true
}
Essentially, it extracts the coordinates from the URL and calls in the delegate of the location manager the function didUpdateLocations
as if the real location manager had updated the location.
3) Setup a subclass of CLLocationManager:
However, the app under test will most likely access the location
property of the location manager, which had to be set also. In a CLLocationManager
, this property is read only and cannot be set. Thus, one has to use a custom subclass of CLLocationManager
, and override this property:
final class LocationManager: CLLocationManager {
…
// Allow location to be set for testing. If it is set, the set value will be returned, else the current location.
private var _location: CLLocation?
@objc dynamic override var location: CLLocation? {
get {
let usedLocation = _location ?? super.location
return usedLocation
}
set {
self._location = newValue
}
}
…
} // class LocationManager
In normal operation, the property location
of the subclass is not set, i.e. nil
. So when it is read, the property location
of its superclass, the real CLLocationManager
is read. If however during a test this property is set to a test location, this test location will be returned.
4) Select a test location in a UI test of the app under test:
This is possible with multi-app UI testing. It requires access to the helper app:
In the UITests, in class ShopEasyUITests: XCTestCase
, define a property
var helperApp: XCUIApplication!
and assign to it the application that is defined via its bundleIdentifier
helperApp = XCUIApplication(bundleIdentifier: "com.yourCompany.Helperapp“)
Further, define a function that sets the required test location by activating the helper app and tapping the requested row in the tableView, and switches back to the app under test:
private func setFakeLocation(name: String) -> Bool {
helperApp.activate() // Activate helper app
let cell = helperApp.tables.cells.staticTexts[name]
let cellExists = cell.waitForExistence(timeout: 10.0)
if cellExists {
cell.tap() // Tap the tableViewCell that displays the selected item
}
appUnderTest.activate() // Activate the app under test again
return cellExists
}
Eventually, call this function:
func test_setFakeLocation() {
// Test that the helper app can send a fake location, and this location is handled correctly.
// when
let fakeLocationWasTappedInHelperApp = setFakeLocation(name: "Location 1")
// then
XCTAssert(fakeLocationWasTappedInHelperApp)
…
}
5) Further remarks:
Of course, the app under test and the helper app must be installed on the same simulator. Otherwise both apps could not communicate.
Probably the same approach can be used to UI test push notifications. Instead of calling locationManagerDelegate?.locationManager(locationManager, didUpdateLocations: [location])
in func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool
, one could probably call there
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void)
XCUIDevice.shared.location = XCUILocation(location: CLLocation(latitude: 52.5200, longitude: 13.4050))
To make sure the change is actually visually there I needed to also add sleep after:sleep(1)
– Iliac