在 iOS 测试中以编程方式模拟 GPS 位置

Programmatically simulate GPS location in iOS tests

我想在 Swift 中编写 UI 测试,在我们的应用程序中制作地图不同位置的屏幕截图。为此,我需要在测试期间模拟假的 GPS 数据。

有一些像这个 (https://blackpixel.com/writing/2016/05/simulating-locations-with-xcode-revisited.html) 的解决方案,它们使用 GPX 文件并在 Xcode 中使用 Debug > Simulate Location 模拟位置,但我需要它是完全自动化的。理想的是类似于 Android 中的 LocationManager

你不能。 CLLocationManager 在委托的帮助下为您提供位置,您可以通过任何方式设置此位置。

您可以制作一个 CLLocationManager 模拟器 class,它随时间提供一些位置。 或者您可以使用带时间戳的 GPX 同步您的测试。

我在编写 UI 测试时遇到过类似的问题,因为模拟器/系留设备无法完成您可能想要的一切。我所做的是编写模拟所需行为的模拟(我通常无法控制的行为)。

用自定义位置管理器替换 CLLocationManager 将使您能够完全控制位置更新,因为您可以通过 CLLocationManagerDelegate 方法以编程方式发送位置更新:locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]).

创建一个 class MyLocationManager,使其成为 CLLocationManager 的子 class,并让它覆盖您调用的所有方法。不要在覆盖的方法中调用 super,因为 CLLocationManager 不应该实际接收方法调用。

class MyLocationManager: CLLocationManager {
  override func requestWhenInUseAuthorization() {
    // Do nothing.
  }

  override func startUpdatingLocation() {
    // Begin location updates. You can use a timer to regularly send the didUpdateLocations method to the delegate, cycling through an array of type CLLocation.
  }

  // Override all other methods used.

}

delegate 属性 不需要被覆盖(也不可能),但您可以作为 CLLocationManager 的子class 访问它。

要使用 MyLocationManager,您应该传入启动参数,告诉您的应用它是否是 UITest。在您的测试用例的 setUp 方法中插入这行代码:

app.launchArguments.append("is_ui_testing")

将 CLLocationManager 存储为 属性,即测试时的 MyLocationManager。不测试时,CLLocationManager 将正常使用。

static var locationManger: CLLocationManager = ProcessInfo.processInfo.arguments.contains("is_ui_testing") ? MyLocationManager() : CLLocationManager()

仅出于(单元)测试的目的,您可以调配 startUpdatingLocation 方法以指示 CLLocationManager 实例按您希望的方式运行。

这是一个可能的实现:

// Just a wrapper to allow storing weak references in an array
struct WeakRef<T: AnyObject> {
    weak var value: T?
}

extension CLLocationManager {
    
    // Replace the `startUpdatingLocation` method with our own
    private static var swizzled = false
    static func swizzle() {
        guard !swizzled else { return }
        swizzled = true
        method_exchangeImplementations(class_getInstanceMethod(self, #selector(startUpdatingLocation))!,
                                       class_getInstanceMethod(self, #selector(swizzledStartUpdatingLocation))!)
    }
    
    // When a CLLocationManager is asked to `startUpdatingLocation`,
    // it adds itself to the list of registered instances
    // Using weak references to allow managers to deallocate when needed
    static var registeredInstances = [WeakRef<CLLocationManager>]()
    @objc func swizzledStartUpdatingLocation() {
        Self.registeredInstances.append(WeakRef(value: self))
        if let mockedLocation = Self.mockedLocation {
            delegate?.locationManager?(self, didUpdateLocations: [mockedLocation])
        }
    }
    
    // This is the mocked location, when it changes
    // all registered managers notify their delegates with the new mocked location
    static var mockedLocation: CLLocation? {
        didSet {
            guard let mockedLocation = mockedLocation else { return }
            registeredInstances.forEach {
                guard let manager = [=10=].value else { return }
                manager.delegate?.locationManager?(manager, didUpdateLocations: [mockedLocation])
            }
        }
    }
}

当然,这只是一个基本的实现 stopUpdatingLocation 也应该进行调整以删除管理器。 CLLocationMangager的其他功能也可以在扩展中implemented/swizzled