好好利用 Swift Protocols 簡單增進程式碼的可測試性


本篇原文(標題: Improving code testability with Swift protocols )刊登於作者 Medium,由 Juanpe Catalán 所著,並授權翻譯及轉載。

swift-protocols

對開發者來說,讓程式碼達到高度的可測試性可以說是一大挑戰。測試是非常有用的,可以確保你撰寫的程式碼運作起來符合需求,而且在添加新功能時也不會發生問題。同時,在一個團隊裡工作時,會有很多人修改程式碼,所以確保程式碼的完整度 (integrity) 也是很重要的。

雖然測試的方式有很多,但它們都不是複雜或難用的。那為什麼很多開發者都不測試程式碼呢?主要的原因(藉口)是沒時間。我相信最大的問題是程式碼在層級、類別、以及外部框架的依賴性之間過於耦合。

在這篇文章中,我希望向大家證明,建立框架的抽象層或是解耦類別並不困難!讓我們開始吧!

情境

想像我們需要開發一個 app,它需要知道使用者的地理位置,因此我們需要使用 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()
    }
}

它有一個 CLLocationManager 作為 locationManager,用來請求使用者的地理位置、或是有需要的話向使用者請求權限。同時,它也在遵從 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 管理這些請求,但這並不是一個同步的流程,所以當我們確認儲存的位置時,它仍然是 nil。而且,我們可能沒有請求地理位置的權限,也就是說在個例子裡,地理位置也會是 nil

現在,讓我們看看一些可行的解決方法!讓我們不測試任何與地理位置相關的東西,來測試 ViewController 吧。建立一個 CLLocationManager 的子類別,然後我們可以模擬方法、或嘗試正確地執行方法,並從類別中解耦 CLLocationManager。在此,我會選擇後者。

協定導向程式設計 (Protocol Oriented Programming, POP) 來救援

Swift 的設計核心是兩個非常強大的概念:協定導向程式設計與類別數值語義 (Class Value Semantics)。
-Apple

協定導向程式設計對開發者來說是一個強大的工具,而 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。這個協定規定我們只需要一個方法來請求使用者的位置資訊,而請求的結果會透過我們提供的回呼 (Callback) 來回傳。

我們已準備好建立一個 UserLocationService 來符合協定,並向我們提供位置資訊。藉由這個方式,我們解決了類別中 CoreLocation 的相依性問題。不過,似乎還有一些問題我們還沒解決 😅。

協定再次來救援了,我們只需建立一個新協定,來指定位置資訊提供者:

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。但是,我們在測試中會看到怎樣的情況呢?其實我們並不真的需要它來確認類別是否運作正常。

我們可以添加任何種類的委派 (Delegate) 到協定裡,不過,我想對這次的範例來說這樣可能太多了。

在我們開始測試之前,來看看使用 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 程式碼較少、擔負的責任較少、可測試性更佳。

測試

讓我們開始測試吧!首先,我們會建立一些用來測試 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 不是這個測試的目標,所以它需要另外的模擬。

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)
    }
}

小結

你可以想像到,要解耦程式碼有很多方式,而這次的文章只是其中一種,但這是個範例很好地證明了測試並不困難。

還記得文章開頭的圖片嗎?圖片中你可以看到樂高積木,這完美地解釋了甚麼是解耦及抽象化元件。在最後,它被定義為一種特定的連接方式(不過顏色並不重要)。

或許最煩悶的工作就是建立模擬資料,不過現在已經有程式庫與工具來簡化這項工作,像是 Sourcery。另外,我的同事 Hugo Peral 亦撰寫了一篇文章,說明如何使用 Sourcery 來節省測試時間;而 John Sundell這篇文章也提供了製作模擬資料的細節。

感謝你閱讀這篇文章。如果你覺得這篇文章有幫助的話,歡迎向其他人分享 😉。如果有任何疑問或建議,歡迎你在下面留言。

本篇原文(標題: Improving code testability with Swift protocols )刊登於作者 Medium,由 Juanpe Catalán 所著,並授權翻譯及轉載。
作者簡介:Juanpe Catalán,iOS 開發人員和產品經理。他使用 Swift 和 Objective-C 語法,十分熟悉 Cocos2D、NodeJS、Angular 等,喜歡乾淨的程式碼,並致力為 Open Source 社區貢獻。他亦是 Github 一個流行開源庫@SkeletonView 的創辦人。歡迎在 Twitter 或電郵到 [email protected] 與我聯絡。
譯者簡介:楊敦凱-目前於科技公司擔任 iOS Developer,工作之餘開發自有 iOS App 同時關注網路上有趣的新玩意、話題及科技資訊。平時的興趣則是與自身專業無關的歷史、地理、棒球。來信請寄到:[email protected]

此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。

blog comments powered by Disqus
Shares
Share This