美文网首页
使用Swift协议来提升代码的可测试性

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

作者: plantseeds | 来源:发表于2019-08-06 13:14 被阅读0次

本文翻译自《Improving code testability with Swift protocols》

lego_bricks.png

作为开发者,面临的最大挑战之一就是实现高可测试性的代码。这些测试是非常有用的,它不仅保证已完成的代码正常工作,并且确保添加新功能时不会影响旧功能。当你在团队中工作时,会有许多人修改同一个项目,因此确保代码的完整性就更加重要了。

有许多种测试方法,它们应该是清晰、明确、简单的。那么,为什么许多开发者不写测试呢?主要的 excuse 原因 是时间不够。我认为,其中很大的因素是由于我们的代码 在不同层、类、外部依赖框架之间的关系过于耦合。

我想证明创建一个框架的抽象层或者解耦类并不是一件困难的事。

本文代码 GitHub

场景

假设我们需要开发一个获取用户定位的应用,我们需要使用到 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 case .authorizedWhenInUse = CLLocationManager.authorizationStatus() {
            locationManager.startUpdatingLocation()
        } else {
            locationManager.requestWhenInUseAuthorization()
        }
    }
}

extension ViewController: CLLocationManagerDelegate {
    
    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        if case .authorizedWhenInUse = status {
            manager.startUpdatingLocation()
        }
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        userLocation = locations.last
        manager.stopUpdatingLocation()
    }
}

ViewController 有一个属性 locationManager,它是 CLLocationManager 的实例,用来请求用户的位置或权限。ViewController 遵守 CLLocationManagerDelegate 协议,接收 locationManager 输出的信息。

在这里,我们可以看到 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 (System Under Test) 和一个可能的测试用例。我们请求用户的位置,并将它存到变量 userLocation 中。

问题开始浮现了,虽然 CLLocationManager 负责请求用户位置,但它不是同步处理并返回结果的。所以当我们检查存储的位置属性时,userLocation 仍然为空。另外,我们也可能还未得到获取定位的权限,在这种场景下,userLocation 也将为空。

现在,我们有一些可能的解决方案:

  • 测试 ViewController 但不测试与位置相关的任何内容。
  • 创建 CLLocationManager 的子类并模拟方法,或者尝试正确的执行它并将 CLLocationManager 与我们的类解耦。

我选择后者。

面向协议编程来解决

“At the heart of Swift’s design are two incredibly powerful ideas: protocol-oriented programming and first class value semantics” - Apple

面向协议编程 对开发人员来说是非常强大的工具,而Swift无疑是一种面向协议的语言。所以我的建议是使用协议解决这些依赖关系。

首先,要抽象 CLLocation,先定义一个带有我们需要的变量和函数的协议。

typealias Coordinate = CLLocationCoordinate2D

protocol UserLocation {
    var coordinate: Coordinate { get }
}

extension CLLocation: UserLocation {}

现在,我们可以得到一个不需要 CoreLocationlocation。所以如果我们分析 ViewController,会发现其实也并不是真的需要 CLLocationManager,我们只是需要一个能够提供用户位置的提供者。因此,让我们来创建一个包含我们所需内容的协议,只要是遵守该协议的东西都能作为我们的位置提供者。

enum UserLocationError: Error {
    case canNotBeLocated
}

typealias UserLocationCompletionBlock = (Result<UserLocation, UserLocationError>) -> Void

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

UserLocationProvider 协议,它声明了我们所需的请求用户位置的方法,并将结果通过回调函数返回。

我们准备创建一个 UserLocationService,它将遵守提供用户位置的协议。通过这种方式,我们解决了 CoreLocationViewController 之间的依赖。但是...等等,UserLocationService 仍需要通过 CLLocationManager 来请求用户位置......。问题似乎还未解决😅。

再次使用协议来解决这个问题,只需创建一个新的协议来指定什么是位置提供者。

protocol LocationProvider {
    var isUserAuthorized: Bool { get }
    func requestWhenInUseAuthorization()
    func startUpdatingLocation()
}

extension CLLocationManager: LocationProvider {
    var isUserAuthorized: Bool {
        return CLLocationManager.authorizationStatus() == .authorizedWhenInUse
    }
}

扩展 CLLocationManager,让它遵守我们的新协议。

现在,我们准备好创建 UserLocationService 🎉了,它看起来像下面这样:

class UserLocationService: NSObject, UserLocationProvider {
    
    private var provider: LocationProvider
    private var locationCompletionBlock: UserLocationCompletionBlock?
    
    init(provider: LocationProvider) {
        self.provider = provider
        super.init()
    }
    
    func findUserLocation(then: @escaping UserLocationCompletionBlock) {
        self.locationCompletionBlock = then
        if provider.isUserAuthorized {
            provider.startUpdatingLocation()
        } else {
            provider.requestWhenInUseAuthorization()
        }
    }
}

extension UserLocationService: CLLocationManagerDelegate {
    
    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        if status == .authorizedWhenInUse {
            provider.startUpdatingLocation()
        }
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        if let location = locations.last {
            locationCompletionBlock?(.success(location))
        } else {
            locationCompletionBlock?(.failure(.canNotBeLocated))
        }
    }
}

UserLocationService 有自己的位置提供者,但它并关心也不知道这个提供者具体是谁,因为它只需要请求位置时获取到用户所在的位置就够了,其余的不是他的责任。

因为我们将使用 CoreLocation,所以让 UserLocationService 遵守 CLLocationManager 协议。但是我们在测试代码中并不会用到这个协议。

我们可以在 UserLocationProvider 协议中添加任何类型的代理,但对于我们的例子来说,我认为它会显得太多余了。

在开始测试之前,看看用 UserLocationProvider 代替 CLLocationManager 之后,ViewController 的新面貌。

class ViewController: 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] (result) in
            switch result {
            case .success(let location):
                self?.userLocation = location
            case .failure:
                print("User can not be located 😔 ")
            }
        }
    }
}

可以看到,ViewController 拥有更少的代码,更少的职责,更易于测试了。

Tests

让我们开始测试,首先我们需要创建一些模拟类用来测试 ViewController

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

class UserLocationProviderMock: UserLocationProvider {
    
    var locationResult: Result<UserLocation, UserLocationError>?
    
    func findUserLocation(then: @escaping UserLocationCompletionBlock) {
        if let result = locationResult {
            then(result)
        }
    }
}

使用这些模拟类,可以注入任何我们需要的结果,我们将模拟 UserLocationProvider 的工作方式。因此,我们将重点放在我们的真实目标 ViewController 上。

class ViewControllerTests: XCTestCase {
    
    var sut: ViewController!
    var locationProvider: UserLocationProviderMock!

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

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

    func testRequestUserLocation_NotAuthorized_ShouldFail() {
        // Given
        locationProvider.locationResult = .failure(.canNotBeLocated)
        
        // When
        sut.requestUserLocation()
        
        // Then
        XCTAssertNil(sut.userLocation)
    }
    
    func testRequestUserLocation_Authorized_ShouldReturnUserLocation() {
        // Given
        locationProvider.locationResult = .success(UserLocationMock())
        
        // When
        sut.requestUserLocation()
        
        // Then
        XCTAssertNotNil(sut.userLocation)
    }
}

我们创建了两个测试用例,一个用例检查是否有获取用户位置的权限,该位置提供者没有提供位置。另一个是相反的用例,如果有权限,将获取到用户的位置。就像你看到的那样,测试通过了!✅ 💪

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

LocationProvider 需要被mock,因为它不是我们测试的目标对象。

class LocationProviderMock: LocationProvider {
    
    var isRequestWhenInUseAuthorizationCalled = false
    var isStartUpdatingLocationCalled = false

    var isUserAuthorized: Bool = false
    
    func requestWhenInUseAuthorization() {
        isRequestWhenInUseAuthorizationCalled = true
    }
    
    func startUpdatingLocation() {
        isStartUpdatingLocationCalled = true
    }
}

可以创建许多测试,其中之一是向提供者验证我们是否有权限,如果没有,则请求授权;如果有,就请求位置。

class UserLocationServiceTests: XCTestCase {
    
    var sut: UserLocationService!
    var locationProvider: LocationProviderMock!

    override func setUp() {
        super.setUp()
        locationProvider = LocationProviderMock()
        sut = UserLocationService(provider: 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。我的同事 Hugo Peral 也写了一篇文章 Saving time with Sourcery 来解释如何使用 Sourcery 节省测试时间。或者 John Sundell 的这篇 Mocking in Swift,它提供了有关如何制作mock的更多细节。

最后,感谢您阅读这篇文章。如果您觉得它对您有用或者您认为对某人有用,请分享它 😉。如果您有任何疑问或者改进意见,请随时在下面发表评论。

相关文章

网友评论

      本文标题:使用Swift协议来提升代码的可测试性

      本文链接:https://www.haomeiwen.com/subject/kkpidctx.html