使用 Swift 协议提高代码的可测试性

674 阅读6分钟

作为开发者,我们最大的挑战就是提升代码的可测试性。对于你开发的代码按照预期的方式执行以及开发新功能时没有别的功能被破坏来说,这些测试是非常有用的。同样,当你在一个多人协作开发的团队中时也是非常有用的。所以确保你代码的完整性是非常重要的。

有很多种测试,它们不应该使事情变得困难或复杂。为什么那么多的开发者不愿意做呢?主要的原因是没有时间。我觉得我们的代码最大的问题之一就是层与层之间、类与外部依赖之间的耦合太过紧密。

我想证明创建一个框架的抽象层或解耦类不应该是困难的任务。

示例场景

想象我们需要开发一个需要用户位置的应用。因此我们需要CoreLocation.

我们的ViewController是这样的:

import UIKit
import CoreLocation

class ViewController: UIViewController {

    var locationManager: CLLocationManager
    var userLocation: CLLocation?

    init(locationProvider: CLLocationManager = CLLocationManager()) {
        self.locationManager = locationProvider
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
       locationManager.delegate = self
    }

    func requestUserLocation() {
        if CLLocationManager.authorizationStatus() == .authorizedWhenInUse {
            locationManager.startUpdatingLocation()
        } else {
            locationManager.requestWhenInUseAuthorization()
        }
    }
}

extension ViewController: CLLocationManagerDelegate {

    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        if status == .authorizedWhenInUse {
            manager.startUpdatingLocation()
        }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        userLocation = locations.last
        manager.stopUpdatingLocation()
    }
}

它有一个locationManager,请求用户的位置或请求授权(如果合适的话)。它同时遵循了CLLocationManagerDelegate,用来接收代理事件。

我们看到我们的ViewControllerCoreLocation耦合了以及与职责分离有关的其他问题。

无论如何,让我们为ViewController创建测试。这可能是一个很好的例子:

class ViewControllerTests: XCTestCase {

    var sut: ViewController!

    override func setUp() {
        super.setUp()
        sut = ViewController(locationProvider: CLLocationManager())
    }

    override func tearDown() {
        sut = nil
        super.tearDown()
    }

    func testRequestUserLocation() {
        sut.requestUserLocation()
        XCTAssertNotNil(sut.userLocation)
    }
}

我们可以看到sut和一个测试方法。然后我们请求了用户位置并把它存储在userLocation

问题出现了。CLLocationManager管理了请求而它并不是一个同步操作,所以检查的userLocation是空的。我们也有可能没有权限请求位置,这种情况下,位置也是nil

现在,我们有一些可能的解决方案。让我们在不测试任何与位置相关的东西的情况下测试ViewController,创建CLLocationManager的子类并模拟这些方法,或者尝试正确地进行测试并将CLLocationManager与我们的类解耦。我选择后者。

POP

“At the heart of Swift’s design are two incredibly powerful ideas: protocol-oriented programming and first class value semantics” - Apple 【Swift设计的核心是两个非常强大的概念:面向协议的编程和一流的值语义】

对开发者来说POP是一个强大的工具。Swift 毫无疑问是面向协议的语言。所以我的提议是用协议来解决这些依赖。

首先,为了抽象CLLocation,我们将定义一个协议,其中只包含代码所需的变量或函数。

typealias Coordinate = CLLocationCoordinate2D

protocol UserLocation {
    var coordinate: Coordinate { get }
}

extension CLLocation: UserLocation { }

现在我们不需要CoreLocation就可以获取位置。所以如果我们分析ViewController,我们可以看到我们并不真的需要CLLocationManager,只需要在我们请求时提供用户位置的人。因此我们创建一个包含我们需要的协议,任何遵循这个协议的将成为提供者。

enum UserLocationError: Swift.Error {
    case canNotBeLocated
}

typealias UserLocationCompletionBlock = (UserLocation?, UserLocationError?) -> Void

protocol UserLocationProvider {
    func findUserLocation(then: @escaping UserLocationCompletionBlock)
}

在本例中,我们创建了UserLocationProvider。该协议指定,我们只需要一个方法来请求用户的位置,结果将通过我们提供的回调。

我们准备创建一个UserLocationService,它遵守该协议并为我们提供位置。顺便我们解决了CoreLocation的依赖问题。但是等等,UserLocationService需要通过CLLocationManager来请求位置,似乎问题还是没有被解决。

同样,只需创建一个新协议来指定我们的位置提供者:

protocol LocationProvider {
var isUserAuthorized: Bool { get }
func requestWhenInUseAuthorization()
func requestLocation()
}
extension CLLocationManager: LocationProvider {
var isUserAuthorized: Bool {
return CLLocationManager.authorizationStatus() == .authorizedWhenInUse
    }
}

我们拓展CLLocationManager来遵循新的协议。

现在,我们准备好创建UserLocationService,如下

class UserLocationService: NSObject, UserLocationProvider {

    fileprivate var provider: LocationProvider
    fileprivate var locationCompletionBlock: UserLocationCompletionBlock?

    init(with provider: LocationProvider) {
        self.provider = provider
        super.init()
    }

    func findUserLocation(then: @escaping UserLocationCompletionBlock) {
        self.locationCompletionBlock = then
        if provider.isUserAuthorized {
            provider.requestLocation()
        } else {
            provider.requestWhenInUseAuthorization()
        }
    }
}

extension UserLocationService: CLLocationManagerDelegate {

    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        if status == .authorizedWhenInUse {
            provider.requestLocation()
        }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        manager.stopUpdatingLocation()
        if let location = locations.last {
            locationCompletionBlock?(location, nil)
        } else {
            locationCompletionBlock?(nil, .canNotBeLocated)
        }
    }
}

UserLocationService有自己的位置提供者,但他不知道它是谁,他也没必要知道,他只需要请求的时候拿到用户位置就行了,剩下的不是他的责任。

需要扩展来符合CLLocationManagerDelegate协议,因为我们将使用CoreLocation。但是在测试中,我们并不需要它来验证我们的类是否正常工作。

我们可以在协议中添加任何类型的委托,但是对于这个例子,我认为够了。

在开始测试之前,我们来看看用UserLocationProvider替代CLLocationManagerViewController

class ViewControllerWithoutCL: UIViewController {

    var locationProvider: UserLocationProvider
    var userLocation: UserLocation?

    init(locationProvider: UserLocationProvider) {
        self.locationProvider = locationProvider
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func requestUserLocation() {
        locationProvider.findUserLocation { [weak self] location, error in
            if error == nil {
                self?.userLocation = location
            } else {
                print("User can not be located 😔")
            }
        }
    }
}

测试

让我们继续测试。首先,我们将创建一些模拟类来测试ViewController

struct UserLocationMock: UserLocation {
    var coordinate: Coordinate {
        return Coordinate(latitude: 51.509865, longitude: -0.118092)
    }
}

class UserLocationProviderMock: UserLocationProvider {

    var locationBlockLocationValue: UserLocation?
    var locationBlockErrorValue: UserLocationError?

    func findUserLocation(then: @escaping UserLocationCompletionBlock) {
        then(locationBlockLocationValue, locationBlockErrorValue)
    }
}

使用这个我们可以注入任何我们需要的结果的模拟数据,我们将可以模拟UserLocationProvider如何工作。因此我们可以关注我们真正的目标ViewController

class ViewControllerWithoutCLTests: XCTestCase {

    var sut: ViewControllerWithoutCL!
    var locationProvider: UserLocationProviderMock!

    override func setUp() {
        super.setUp()
        locationProvider = UserLocationProviderMock()
        sut = ViewControllerWithoutCL(locationProvider: locationProvider)
    }

    override func tearDown() {
        sut = nil
        locationProvider = nil
        super.tearDown()
    }

    func testRequestUserLocation_NotAuthorized_ShouldFail() {
        // Given
        locationProvider.locationBlockLocationValue = UserLocationMock()
        locationProvider.locationBlockErrorValue    = UserLocationError.canNotBeLocated

        // When
        sut.requestUserLocation()

        // Then
        XCTAssertNil(sut.userLocation)
    }

    func testRequestUserLocation_Authorized_ShouldReturnUserLocation() {
        // Given
        locationProvider.locationBlockLocationValue = UserLocationMock()

        // When
        sut.requestUserLocation()

        // Then
        XCTAssertNotNil(sut.userLocation)
    }
}

我们创建了两个测试方法,一个检查在我们没有获得请求位置权限时,locationProvider 会不会提供用户位置。另一个,则相反,如果我们有权限,我们会不会获得用户的位置。正如您所看到的,测试通过了!!

除了ViewController,我们还创建了一个额外的类UserLocationService,因此我们也应该覆盖它。

LocationProvider应该被 mock,尽管它不是此次测试的目标。

class LocationProviderMock: LocationProvider {

    var isRequestWhenInUseAuthorizationCalled = false
    var isRequestLocationCalled = false

    var isUserAuthorized: Bool = false

    func requestWhenInUseAuthorization() {
        isRequestWhenInUseAuthorizationCalled = true
    }

    func requestLocation() {
        isRequestLocationCalled = true
    }
}

可以创建许多测试,比如验证提供者在我们没被授权的情况下,我们请求位置时,是否有调用授权,以及相反情况。

class UserLocationServiceTests: XCTestCase {

    var sut: UserLocationService!
    var locationProvider: LocationProviderMock!

    override func setUp() {
        super.setUp()
        locationProvider = LocationProviderMock()
        sut = UserLocationService(with: locationProvider)
    }

    override func tearDown() {
        sut = nil
        locationProvider = nil
        super.tearDown()
    }

    func testRequestUserLocation_NotAuthorized_ShouldRequestAuthorization() {
        // Given
        locationProvider.isUserAuthorized = false

        // When
        sut.findUserLocation { _, _ in }

        // Then
        XCTAssertTrue(locationProvider.isRequestWhenInUseAuthorizationCalled)
    }

    func testRequestUserLocation_Authorized_ShouldNotRequestAuthorization() {
        // Given
        locationProvider.isUserAuthorized = true

        // When
        sut.findUserLocation { _, _ in }

        // Then
        XCTAssertFalse(locationProvider.isRequestWhenInUseAuthorizationCalled)
    }
}

总结

可以想象,有许多方法可以解耦代码,而本文只是其中之一。但是我认为这是一个很好的例子来说明测试并不是一项困难的任务。

也许最需要偷懒的任务之一是创建 mock ,但是已经有了库和工具来帮助完成这项工作,比如 Sourcery这里有一些文章来解释如何使用 Sourcery 节省测试时间。或者这篇文章它给出了关于如何创建 mock 的更多细节。