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

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

有不少种测试,它们不该该使事情变得困难或复杂。为何那么多的开发者不肯意作呢?主要的缘由是没有时间。我以为咱们的代码最大的问题之一就是层与层之间、类与外部依赖之间的耦合太过紧密。github

我想证实建立一个框架的抽象层或解耦类不该该是困难的任务。编程

示例场景

想象咱们须要开发一个须要用户位置的应用。所以咱们须要CoreLocation.swift

咱们的ViewController是这样的:api

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,用来接收代理事件。app

咱们看到咱们的ViewControllerCoreLocation耦合了以及与职责分离有关的其余问题。框架

不管如何,让咱们为ViewController建立测试。这多是一个很好的例子:less

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和一个测试方法。而后咱们请求了用户位置并把它存储在userLocationide

问题出现了。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)
    }
}
复制代码

咱们建立了两个测试,一个检查若是咱们没有请求位置的受权,提供者就不提供任何东西。另外一个,相反的状况,若是咱们被受权,咱们应该得到用户的位置。正如您所看到的,测试经过了!!

除了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 的更多细节。

相关文章
相关标签/搜索