WWDC18 Testing Tips & Tricks 속 4가지 주제 중, Mocking with Protocols 세션을 정리해보았습니다.
해당 세션은 Core Location 활용 시의 외부 의존성(CLLocationManager, CLLocationManageDelegate)을 끊어주어 실제 API 호출 없이 Core Location을 Test하는 코드를 설명하고 있습니다.
위와 같은 과정은 프로토콜 의존성 주입을 통해서 구현을 했는데, 세션의 내용을 따라가면서 어떻게 테스트를 진행하는 지 알아보겠습니다.
(추가로 해당 세션을 기반으로 실제 프로젝트에도 적용해보겠습니다!)
Mocking with Protocols
Core Location을 구현할 때는 주로 위와 같이 CLLocationManager() 프로퍼티를 갖고 있는 Core Location manager 객체를 정의합니다. 초기화 시점에 CLLocationManager의 다른 특성들(정확성, delegate)등 또한 설정해줍니다.
그리고 callBack 클로져를 정의하여 checkCurrentLocation 함수를 통해서 현재 위치를 받아올 수 있게 구현을 합니다.
그리고 delegate 메소드를 통해서 위에서 정의해 놓은 callBack 클로져에 받아온 location(현재 위치)값을 넣어줍니다.
이렇게 구현할 경우, view controller나 다른 모델에서 checkCurrentLocation함수를 통해서 클로져 안에 담겨있는 location 값을 활용할 수 있는 것이죠.
그리고 아래와 같이 Unit Test를 진행할 수 있습니다.
그러나, 위와 같이 구현한 Unit Test는 아래와 같은 문제가 생깁니다.
- request location method가 언제 불리는 지 알 수가 없다.
- 사용자의 위치 정보 수집 승인을 받아야한다. 즉, 디바이스의 상태에 의존적이다.
이와 같은 문제점을 해결할 수 있는 방법은 두 가지 방법이 있습니다.
- 서브 클래싱
- 프로토콜을 활용하기
서브 클래싱을 활용하면 작동이 될 수 있지만, 내가 원하는 것과 같이 행동하지 않을 수 있고, 몇몇 SDK 클래스들은 서브 클래싱을 할 수 없게 정의 되어있습니다.
그렇다면, 두 번째 방법 프로토콜을 활용하는 방법은 어떻게 하는 것일까요?
먼저 위와 같이 CLLocationManager 타입이 하는 역할을 정의 해놓은 LocationFetcher 프로토콜을 정의합니다.
LocationFetcher 프로토콜은 아래와 같은 필수 구현 사항을 가지고 있습니다.
- delegate
- desiredAccuracy
- requestLocation()
위 구현사항 다 CLLocationManager가 가지고 있는 메소드와 프로퍼티 값이죠. 따라서 CLLocationManager는 별도의 구현 없이 LocationFetcher 프로토콜을 채택할 수 있습니다.
그러면 기존의 CLLocationManager 타입으로 받고 있던 locationManager 프로퍼티를 LocationFetcher 타입으로로 변경할 수 있습니다.
그리고, 초기화 시점에 default 값으로 CLLocationManager 객체를 넣어줍니다.(CLLocationManager는 LocationFetcher 프로토콜을 채택했으므로 가능합니다)
그리고, 나머지 메소드도 locationFetcher로 변경을 해줍니다.
이렇게 우리는 CLLocationManager를 해당 타입을 추상화한 LocationFetcher 프로토콜로 변경해주었습니다.
위와 같이 CLLocationManager와의 높은 의존성은 끊어주었지만, 아직 CLLocationManagerDelegate과의 의존성을 끊어주지 못했습니다. 의존성을 끊어주는 방법은 아래에 이어서 알아보겠습니다.
CLLocationManagerDelegate도 비슷하게 LocationFetcherDelegate이라는 프로토콜을 정의해줍니다.
그리고 LocationFetcherDelegate의 메소드에서 기존의 CLLocationManagerDelegate과 동일한 모양의 메소드를 필수 구현 사항을 정의를 해줍니다.(이렇게 구현한 이유도 뒤에 나옵니다!)
여기서 중요한 부분이 있습니다!
저희는 LocationFetcherDelegate을 채택한 locationFetcherDelegate을 CLLocationManagerDelegate 대신 활용할 것입니다.
이렇게 하는 이유 locationFetcherDelegate으로 기존의 delegate을 감싸기 위함입니다.
즉, locationFetcherDelegate을 활용해도 CLLocationManagerDelegate을 통한 API 호출은 필요합니다.
따라서 계산 속성 구문을 통해서 locationFetcherDelegate을 설정하면, 들어온 새로운 값으로 CLLocationManagerDelegate의 delegate을 같이 설정해주는 것입니다.
locationFetcherDelegate에 접근할 때는 delegate(CLLocationMangerDelegate)을 캐스팅하여 LocationFetcherDelegate으로 바꿔서 LocationFetcherDelegate의 메소드를 활용할 수 있게 구현을 했습니다.
(이해가 안되더라도, 계속 밑으로 진행하시다보면 이해가 되실 겁니다!)
그렇다면, 다시 코드로 돌아가보겠습니다.
위와 같이 LocationFetcherDelegate도 채택을 하여, 해당 메소드를 통해서 받아오는 위치 값으로 클로져의 input 값을 넣어둡니다.
그렇다면, 위의 메소드는 어떻게 호출이 되는 것일까요?
위와 같이 실제 CLLocationMangerDelegate 메소드를 통해서 LocationFetcherDelegate의 메소드를 호출하는 것입니다.
쉽게 말해서, CLLocationManagerDelegate을 통해서 받아온 location 값을 통해서 LocationFetcherDelegate 메소드를 호출하여 input 값으로 해당 location 값을 넣어주는 것이죠!
이를 통해서 저희는 CurrentLocationProvider와 밀접하게 의존하고 있던 CLLocationManagerDelegate을 LocationFetcherDelegate을 활용하여 의존성을 떨어뜨릴 수 있게되었습니다.
테스트 코드 작성해보기
실제로 테스트는 어떻게 짜는 확인해보겠습니다.
저희는 CLLocationManager를 추상화하고 있는 새로운 LocationFetcher 프로토콜을 정의해주었습니다.
그리고 우리는 LocationFetcher 프로토콜을 채택한 MockLocationFetcher를 구현할 수 있습니다.
해당 타입은 CLLocationManager와 같이 실제로 API 호출을 통해서 네트워킹을 하지는 못하지만, CLLocatioManager와 똑같은 메소드를 가지고 있습니다.
결국 우리는 API호출 없이 Core Location 테스트를 하고 싶은 것이기에, Core Location Manager와 같은 역할을 하는(실제로 네트워킹은 하지 않는) Mock을 만들어서 Unit Test를 하는 것이죠!
위와 같이 LocationFetcher 프로토콜을 채택한 MockLocationFetcher 타입을 정의해줍니다.
그리고, LocationFetcherDelegate의 메소드를 호출하여 해당 메소드의 input 값으로 location 값을 넣어줍니다.
(실제 API 호출이되면, 진짜 location이 들어가겠죠!)
최종적인 테스트 코드는 위와 같습니다. 해당 테스트 코드를 조금만 더 자세하게 봐보겠습니다.
위와 같은 코드를 통해서 locationFetcher 프로퍼티에 있는 클로져(handleRequestLocation)의 return 값에 임의의 Location 값을 넣어줍니다.
사실 이 부분은 API 호출이 통해서 CLLocation을 받아와서 채워지는 값이지만, 저희는 MockFetcher를 만들었기에 임이의로 Location 값을 넣어줄 수 있는 것입니다.
그리고, 이렇게 구현한 locationFetcher 프로퍼티 값으로 LocationProvider 객체를 생성합니다.
그리고 최종적으로 checkCurrentLocation 메소드를 통해서 실제로 위에서 설정한 location 값이 제대로 들어왔는 지 확인하는 것입니다.
여기서 @escaping 클로져가 사용되어서 조금 헷갈릴 수 있으니 checkCurrentLocation 함수를 다시 봐보겠습니다.
checkCurrentLocation 함수를 다시 한번 봐보면 currentLocationCheckCallBack 클로져를 설정해주는 것입니다.
그래서 CallBack 속 input 값으로 들어온 location을 isPointerOfInterest함수에 넣어 return된 Bool 값을 넣어줍니다.
이렇게 최종적으로 내가 넣어준 임의의 location 값이 제대로 들어오는 가를 Bool 값으로 판단을 할 수 있게 됩니다.
최종정리를 해보자면 프로토콜을 활용하여 Mock을 만드는 것은 외부 인터페이스를 표현하는 프로토콜을 만드는 것입니다.
또한, 외부 클래스를 해당 프로토콜을 채택시키고, 외부 클래스들을 해당 클래스로 싹 다 바꿔줍니다.
그리고 외부 참조를 프로토콜 타입으로 바꾸는 것입니다. 이렇게 프로토콜을 활용한 의존성 주입을 통해서 Core Location 테스트를 할 수 있었습니다.
실제 프로젝트에 적용해보기
위의 내용을 통해서 내용 설명을 충분히 된 것 같기에, 실제 적용은 간단하게 이야기해보겠습니다.
final class LocationManager: NSObject {
typealias LocationCallBack = ((CLLocation?, Error?) -> Void)
private var locationFetcher: LocationFetcher
var locationCallBack: LocationCallBack?
init(locationFetcher: LocationFetcher = CLLocationManager()) {
self.locationFetcher = locationFetcher
super.init()
self.locationFetcher.locationFetcherDelegate = self
}
func fetchCurrentLocation(completion: @escaping LocationCallBack) {
self.locationCallBack = completion
}
func updateLocation() {
locationFetcher.startUpdatingLocation()
}
}
위와 같이 LocationManager를 정의해두었습니다. 그런데 저는 앞선 예와는 다르게 CallBack을 typealias로 error와 CLLocation을 둘 다 넣어줄 수 있게 해두었습니다.
또한, fetchCurrentLocation 메소드의 경우, completion 파라미터로 locationCallBack 프로퍼티 값을 설정하는 메소드로 정의했습니다.
추가적으로 앞선 예와 동일하게 LocationFetcher 프로토콜을 정의해두었습니다.
protocol LocationFetcher {
var locationFetcherDelegate: LocationFetcherDelegate? {get set}
func startUpdatingLocation()
func requestWhenInUseAuthorization()
func requestAlwaysAuthorization()
}
Delegate의 경우에도, LocationFetcherDelegate을 정의해주고 LocationManager가 채택하도록 했습니다.
protocol LocationFetcherDelegate: AnyObject {
func locationFetcher(_ fetcher: LocationFetcher, didUpdateLocations locations: [CLLocation])
func locationFetcher(_ fetcher: LocationFetcher, didFailWithError error: Error)
func locationFetcher(
_ fetcher: LocationFetcher,
didChangeAuthorization authorization: CLAuthorizationStatus
)
}
extension LocationManager: LocationFetcherDelegate {
func locationFetcher(_ fetcher: LocationFetcher, didUpdateLocations locations: [CLLocation]) {
if let location = locations.first {
locationCallBack?(location, nil)
} else {
locationCallBack?(nil, LocationError.canNotBeLocated)
}
}
func locationFetcher(_ fetcher: LocationFetcher, didFailWithError error: Error) {
locationCallBack?(nil, error)
}
func locationFetcher(
_ fetcher: LocationFetcher,
didChangeAuthorization authorization: CLAuthorizationStatus
) {
switch authorization {
case .authorizedWhenInUse:
fetcher.startUpdatingLocation()
case .denied, .restricted:
// alert 구현
print("denied")
case .notDetermined:
fetcher.requestWhenInUseAuthorization()
default:
return
}
}
}
그리고, CLLocationManagerDelegate은 위의 메소드를 호출하게 구현했습니다.
extension LocationManager: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
locationFetcher(manager, didUpdateLocations: locations)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
locationFetcher(manager, didFailWithError: error)
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
locationFetcher(manager, didChangeAuthorization: manager.authorizationStatus)
}
}
마지막으로 CLLocationManager의 delegate을 extension을 활용하여 설정해두었습니다.
extension CLLocationManager: LocationFetcher {
var locationFetcherDelegate: LocationFetcherDelegate? {
get {
return delegate as? LocationFetcherDelegate
}
set {
delegate = newValue as? CLLocationManagerDelegate
}
}
}
그리고, 아래와 같이 MockLocationFetcher를 정의해주었습니다.
class MockLocationFetcher: LocationFetcher {
var locationFetcherDelegate: GoodMorning.LocationFetcherDelegate?
var locationCallBackValue: CLLocation?
var locationErrorCallBackValue: Error?
func startUpdatingLocation() {
if let locationErrorCallBackValue {
locationFetcherDelegate?.locationFetcher(self, didFailWithError: locationErrorCallBackValue)
} else {
guard let location = locationCallBackValue else { return }
locationFetcherDelegate?.locationFetcher(self, didUpdateLocations: [location])
}
}
func requestWhenInUseAuthorization() {}
func requestAlwaysAuthorization() {}
}
startUpdatingLocation() 메소드는 locationCallBackValue와 locationErrorCallBackValue 값에 따라서 locationFetcherDelegate 메소드를 호출합니다.
만약 locationErrorCallBackValue가 nil, 즉 error가 없다면, 정상적으로 업데이트된 위치 정보를 던지는 locationFetcher(_:, didUpdateLocations:)를 호출합니다.
만약 locationErrorCallBackValue가 nil이 아니라면, 위치 정보를 받아올 수 없을 때 호출되는 메소드인 locationFetcher(_:,didFailWithError:)를 호출합니다.
최종적으로 Unit Test 코드는 아래와 같이 구현할 수 있습니다.
final class CoreLocationTest: XCTestCase {
var sut: LocationManager!
var locationFetcher: MockLocationFetcher!
override func setUpWithError() throws {
super.setUp()
locationFetcher = MockLocationFetcher()
sut = LocationManager(locationFetcher: locationFetcher)
}
override func tearDownWithError() throws {
super.tearDown()
sut = nil
locationFetcher = nil
}
func test_임의의_위치를_넣어줬을때_제대로_위치_정보를_가져오는지_확인() {
let expectationLocation = CLLocation(latitude: 10, longitude: 10)
var resultLocation: CLLocation?
sut.fetchCurrentLocation { location, error in
resultLocation = location
}
locationFetcher.locationCallBackValue = CLLocation(latitude: 10, longitude: 10)
locationFetcher.locationErrorCallBackValue = nil
sut.updateLocation()
XCTAssertEqual(resultLocation?.coordinate.latitude, expectationLocation.coordinate.latitude)
}
func test_네트워크_오류로_로케이션을_받아올_수_없을_때_에러를_던지는지_확인() {
var coreLocationError: Error?
sut.fetchCurrentLocation { location, error in
coreLocationError = error
}
locationFetcher.locationErrorCallBackValue = LocationError.canNotBeLocated
sut.updateLocation()
XCTAssertNotNil(coreLocationError)
}
}
'iOS' 카테고리의 다른 글
[iOS] JSONEncoding과 URLEncoding의 차이점(Alamofire/Moya) (1) | 2024.03.05 |
---|---|
[iOS] CollectionView Reordering (feat. WWDC 20) (3) | 2023.09.15 |
[iOS] 공식문서로 보는 Core Location (0) | 2023.07.16 |
[iOS] delegate는 항상 weak var로 선언해야 될까? (0) | 2023.06.12 |
[iOS] Diffabledatasource의 identifier는 왜 Hashable 해야 할까? (0) | 2023.06.08 |