對開發者來說,讓程式碼達到高度的可測試性可以說是一大挑戰。測試是非常有用的,可以確保你撰寫的程式碼運作起來符合需求,而且在添加新功能時也不會發生問題。同時,在一個團隊裡工作時,會有很多人修改程式碼,所以確保程式碼的完整度 (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
的輸出。
這裡我們可以看到 ViewController
與 CoreLocation
產生耦合,以及其他與責任分離有關的問題。
不論如何,讓我們來為 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
而不是 CLLocationManager
的 ViewController
是怎樣的:
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 的這篇文章也提供了製作模擬資料的細節。
感謝你閱讀這篇文章。如果你覺得這篇文章有幫助的話,歡迎向其他人分享 😉。如果有任何疑問或建議,歡迎你在下面留言。