使用 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 的更多細節。

相關文章
相關標籤/搜索