사이드 프로젝트를 진행하면서 Core Location을 사용해 현재 위치를 불러오는 기능을 구현하고, 이에 대한 테스트 코드를 작성하려고 했습니다.
LocationService 클래스 정의
위치 서비스 관련 동작을 처리하기 위해 LocationService라는 이름의 클래스를 정의해주었습니다.
해당 클래스는 Core Location의 CLLocationManager 인스턴스를 활용하여 위치 서비스 관련 메서드를 호출하고, CLLocationManagerDelegate를 채택하여 위치 서비스의 결과를 받아서 처리할 수 있습니다.
해당 프로젝트에서 필요한 동작은 현재 위치의 위도,경도값을 받아오는 것 하나 뿐이기 때문에 간단하게 필요한 내용을 아래와 같이 작성해주었습니다.
import CoreLocation
// MARK: - LocationService 정의
final class LocationService: NSObject {
typealias LocationCallback = (CLLocation?) -> Void
private let locationManager: CLLocationManager
private var fetchLocationCallBack: LocationCallback?
init(locationManager: CLLocationManager = CLLocationManager()) {
self.locationManager = locationManager
super.init()
self.locationManager.delegate = self
}
// 현재 위치를 불러오기
func fetchCurrentLocation(completion: @escaping LocationCallback) {
fetchLocationCallBack = completion
locationManager.requestLocation()
}
}
// MARK: - CLLocationManagerDelegate 델리게이트 구현
extension LocationService: CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
switch manager.authorizationStatus {
case .authorizedWhenInUse:
print("위치 서비스 사용 가능")
case .restricted, .denied:
print("위치 서비스 사용 불가")
// TODO: - "위치 서비스를 사용하려면 승인이 필요합니다." 팝업 띄우기
case .notDetermined:
print("권한 설정 필요")
locationManager.requestWhenInUseAuthorization() // 권한 요청
default:
break
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let location = locations.last {
fetchLocationCallBack?(location)
fetchLocationCallBack = nil
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
fetchLocationCallBack?(nil)
fetchLocationCallBack = nil
// TODO: - 실패 시 UI처리 필요
}
}
현재 위치를 받아오는 동작 코드를 더 자세하게 살펴보겠습니다.
LocationService 클래스의 fetchCurrentLocation(completion:) 메서드를 호출하면 파라미터로 넘겨준 탈출 클로저를 통해 위치 값을 받아올 수 있습니다.
- Core Location에서 현재 위치를 받아오려면 CLLocationManagerDelegate를 채택한 델리게이트 객체의 locationManager(_:didUpdateLocations:) 메서드 내부에서 인자로 전달받은 locations 값을 통해 위치 정보를 추출해줘야 합니다.
- 이를 위해 CLLocation 타입의 위치 값을 전달할 콜백을 정의해주었고, fetchCurrentLocation(completion:)를 통해 전달받은 클로저를 해당 콜백에 할당해주었습니다. 그 후 CLLocationManager의 requestLocation() 메서드가 호출됩니다.
final class LocationService: NSObject {
// 위치 값을 인자로 전달하는 콜백 타입
typealias LocationCallback = (CLLocation?) -> Void
private var fetchLocationCallBack: LocationCallback?
// 현재 위치를 불러오기
func fetchCurrentLocation(completion: @escaping LocationCallback) {
fetchLocationCallBack = completion
locationManager.requestLocation()
}
}
- CLLocationManager의 requestLocation() 메서드가 호출되면, CLLocationManagerDelegate의 델리게이트 메서드 내부에서 인자로 받은 locations 값을 콜백 클로저로 넘겨주게 됩니다. (실패 시에는 nil 전달)
extension LocationService: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let location = locations.last {
fetchLocationCallBack?(location)
fetchLocationCallBack = nil
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
fetchLocationCallBack?(nil)
fetchLocationCallBack = nil
}
}
테스트 코드 작성
이 코드를 통해 테스트하고 싶은 것은 CLLocationManager가 위치 값을 제대로 반환하는지 입니다.
이를 검증하기 위한 테스트코드를 아래와 같이 작성해주었습니다.
import XCTest
import CoreLocation
@testable import Data
final class LocationServiceTests: XCTestCase {
private var sut: LocationService!
override func setUpWithError() throws {
super.setUp()
sut = LocationService()
}
override func tearDownWithError() throws {
super.tearDown()
sut = nil
}
func test_fetchCurrentLocation을_호출하면_위치정보를_얻음() {
let expectedLocation = CLLocation(latitude: 37.6134436427887, longitude: 126.926493082645)
let completionExpectation = expectation(description: "fetchCurrentLocation completion expectation")
sut.fetchCurrentLocation { location in
completionExpectation.fulfill()
if let location = location {
XCTAssertEqual(location.coordinate.latitude, expectedLocation.coordinate.latitude)
XCTAssertEqual(location.coordinate.longitude, expectedLocation.coordinate.longitude)
}
}
wait(for: [completionExpectation], timeout: 1)
}
}
expectedLocation에 예상 결과값을 넣고, fetchCurrentLocation() 메서드를 호출하여 콜백으로 전달된 인자를 통해 값을 검증합니다.
func test_fetchCurrentLocation을_호출하면_위치정보를_얻음() {
let expectedLocation = CLLocation(latitude: 37.6134436427887, longitude: 126.926493082645)
let completionExpectation = expectation(description: "fetchCurrentLocation completion expectation")
sut.fetchCurrentLocation { location in
if let location = location {
completionExpectation.fulfill()
XCTAssertEqual(location.coordinate.latitude, expectedLocation.coordinate.latitude)
XCTAssertEqual(location.coordinate.longitude, expectedLocation.coordinate.longitude)
}
}
wait(for: [completionExpectation], timeout: 1)
}
하지만 이 테스트는 실패합니다.
왜냐하면 해당 테스트 동작 중에는 실제 기기의 위치값이 필요한데, 테스트 중에 그 값을 받아올(주입시킬) 방법이 없기 때문입니다.
그렇기 때문에 CoreLocation의 인터페이스를 직접적으로 사용하지 않으면서 테스트할 수 있도록 수정이 필요합니다.
Core Location 인터페이스 추상화
1. CLLocationManager의 인터페이스를 정의하여 추상화하기
먼저, 실제 동작 시 사용하는 CLLocationManager의 프로퍼티/메서드를 그대로 인터페이스 프로토콜로 정의해줍니다.
LocationManagerInterface 프로토콜을 생성한 뒤, LocationService 클래스에서 사용되는 CLLocationManager 객체와 동일한 프로퍼티와 메서드를 요구하도록 작성했습니다.
protocol LocationManagerInterface {
var locationManagerDelegate: CLLocationManagerDelegate? { get set }
func requestWhenInUseAuthorization()
func requestLocation()
}
이제 LocationService 클래스에서 CLLocationManager에 대한 참조를 LocationManagerInterface의 참조로 변경해줍니다.
또한 델리게이트 지정을 delegate가 아닌, 인터페이스 프로토콜에 정의한 locationManagerDelegate로 해줍니다.
final class LocationService: NSObject {
private var locationManager: LocationManagerInterface
init(locationManager: LocationManagerInterface = CLLocationManager()) {
self.locationManager = locationManager
super.init()
self.locationManager.locationManagerDelegate = self
}
}
이렇게 함으로써 LocationService의 CLLocationManager에 대한 의존성을 끊어줄 수 있습니다.
하지만 아직 CLLocationManager가 인터페이스 프로토콜을 채택하지 않았기 때문에 프로토콜을 채택하도록 해줍니다.
프로토콜의 요구사항들은 어차피 CLLocationManager에 이미 정의된 내용이기 때문에 별도로 구현할 필요가 없습니다.
extension CLLocationManager: LocationManagerInterface {}
하지만 locationManagerDelegate를 아직 정의하지 않았기 때문에 이를 새로 정의해주어야 합니다.
2. CLLocationManagerDelegate의 인터페이스를 정의하여 추상화하기
LocationManagerInterface를 채택하는 과정에서 한 단계 작업을 더 해줘야 합니다.
왜냐하면 LocationService는 CLLocationManagerDelegate로 인해 CoreLocation 시스템 델리게이트에 아직 강하게 연결되어 있기 때문입니다.
이 의존성을 끊어주기 위해 LocationManagerDelegate 델리게이트를 새로 정의해줍니다.
CLLocationManagerDelegate에서 사용되는 메서드들과 형식을 맞춰서 정의해줍니다.
manager 파라미터의 타입은 CLLocationManager가 아닌 LocationManagerInterface가 됩니다.
protocol LocationManagerDelegate: AnyObject {
func locationManagerAbstract(_ manager: LocationManagerInterface, didChangeAuthorization status: CLAuthorizationStatus)
func locationManagerAbstract(_ manager: LocationManagerInterface, didUpdateLocations locations: [CLLocation])
func locationManagerAbstract(_ manager: LocationManagerInterface, didFailWithError error: Error)
}
이제 CLLocationManager에서 locationManagerDelegate를 구현해줍니다.
CLLocationManager의 delegate로 접근할 수 있도록 타입캐스팅하는 코드를 접근자와 설정자에 작성해주었습니다.
이렇게 하면 locationManagerDelegate를 통해 CLLocationManager의 delegate로 접근하게 되고, LocationService의 CLLocationManagerDelegate에 대한 의존성을 끊어줄 수 있습니다.
CoreLocation의 시스템 델리게이트를 활용해 데이터를 받아올 수 있으면서도 delegate은 직접 접근하지 않게 되는 것이죠. 덕분에 CoreLocation을 직접 사용하지 않는 임의의 mock 클래스를 만들어 동작을 테스트할 수 있습니다.
protocol LocationManagerInterface {
var locationManagerDelegate: LocationManagerDelegate? { get set }
...
}
extension CLLocationManager: LocationManagerInterface {
var locationManagerDelegate: LocationManagerDelegate? {
get {
return delegate as? LocationManagerDelegate
}
set {
delegate = newValue as? CLLocationManagerDelegate
}
}
}
3. 추상화된 델리게이트를 채택하는 코드로 수정
이제 CLLocationManager 프로토콜 정보를 새로 정의한 프로토콜로 변경해주어야 합니다.
1. 먼저 LocationService 클래스가 LocationManagerDelegate를 준수하도록 코드를 작성해줍니다.
- 기존에 CLLocationManagerDelegate를 채택하며 구현한 코드를 LocationManagerDelegate 형식에 맞게 옮겨줍니다.
extension LocationService: LocationManagerDelegate {
func locationManagerAbstract(_ manager: LocationManagerInterface, didChangeAuthorization status: CLAuthorizationStatus) {
switch status {
case .authorizedWhenInUse:
print("위치 서비스 사용 가능")
case .restricted, .denied:
print("위치 서비스 사용 불가")
// TODO: - "위치 서비스를 사용하려면 승인이 필요합니다." 팝업 띄우기
case .notDetermined:
print("권한 설정 필요")
locationManager.requestWhenInUseAuthorization() // 권한 요청
default:
break
}
}
func locationManagerAbstract(_ manager: LocationManagerInterface, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
fetchLocationCallBack?(location)
fetchLocationCallBack = nil
}
func locationManagerAbstract(_ manager: LocationManagerInterface, didFailWithError error: Error) {
fetchLocationCallBack?(nil)
fetchLocationCallBack = nil
// TODO: - 실패 시 UI처리 필요
}
}
2. 기존의 CLLocationManagerDelegate의 델리게이트 메서드들이 각각 대응하는 LocationManagerDelegate의 메서드를 호출하도록 수정해줍니다.
extension LocationService: CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
locationManagerAbstract(manager, didChangeAuthorization: manager.authorizationStatus)
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
locationManagerAbstract(manager, didUpdateLocations: locations)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
locationManagerAbstract(manager, didFailWithError: error)
}
}
추상화된 인터페이스 기반의 테스트코드 작성
이제 LocationManagerInterface를 활용하여 직접 제어가 가능한 클래스를 사용할 수 있기 때문에 mock을 생성하고, 주입하고자 하는 값들을 넣어줄 수 있습니다.
CLLocationManager 대신 동작할 Mock 객체를 생성해주었습니다.
- locationToReturn 클로저를 통해 임의의 위치값을 넘겨줍니다.
// CLLocationManager 대신 동작할 Mock 객체
struct LocationManagerMock: LocationManagerInterface {
var locationManagerDelegate: LocationManagerDelegate?
var locationToReturn: (() -> CLLocation)?
func requestLocation() {
guard let location = locationToReturn?() else { return }
locationManagerDelegate?.locationManagerAbstract(self, didUpdateLocations: [location])
}
func requestWhenInUseAuthorization() {}
}
Mock 객체를 활용하여 테스트 코드를 아래와 같이 작성해주었습니다.
각 코드의 동작은 아래와 같습니다.
- mock 인스턴스 생성
- CLLocationManager의 requestLocation()메서드가 호출됐을 때 받아올 것이라고 기대되는 임의의 위치값을 전달해줍니다.
- LocationService 인스턴스 생성 시 이니셜라이저를 통해 mock 인스턴스를 locationManager로 주입해줍니다.
- 특정 클래스가 아닌 인터페이스에 따라 locationManager를 만들어줌으로써 의존성이 역전됩니다.
func test_fetchCurrentLocation을_호출하면_위치정보를_얻음() {
// 1
var locationManagerMock = LocationManagerMock()
// 2
locationManagerMock.locationToReturn = {
return CLLocation(latitude: 37.6134436427887, longitude: 126.926493082645)
}
// 3
let sut = LocationService(locationManager: locationManagerMock)
let expectedLocation = CLLocation(latitude: 37.6134436427887, longitude: 126.926493082645)
let completionExpectation = expectation(description: "fetchCurrentLocation completion expectation")
sut.fetchCurrentLocation { location in
if let location = location {
completionExpectation.fulfill()
XCTAssertEqual(location.coordinate.latitude, expectedLocation.coordinate.latitude)
XCTAssertEqual(location.coordinate.longitude, expectedLocation.coordinate.longitude)
}
}
wait(for: [completionExpectation], timeout: 1)
}
이렇게 해준 뒤에는 테스트 코드가 통과하는 것을 확인할 수 있습니다.
CLLocationManager와 동일하게 동작하지만 실제 기기의 위치값을 받아올 필요가 없는 LocationManagerMock 클래스를 사용해서 위치 정보를 받았을 때 콜백으로 제대로 넘겨지는지를 테스트할 수 있게 되었습니다.
이와 동일한 방식으로 임의의 값을 넘겨주면서 인증, 에러 등의 다른 상황도 테스트할 수 있습니다.
리팩토링 후 최종 코드
테스트 코드 내용을 Given-When-Then 형식에 맞게 리팩토링하여 가독성을 높여주었고, 위치 정보 불러오기를 실패했을 경우를 테스트하는 코드를 추가적으로 작성해보았습니다.
📌 LocationService 코드 수정
이를 위해 기존 LocationService의 콜백에서 CLLocation값과 Error 타입의 값을 인자로 넘겨주도록 수정했습니다.
- 커스텀 에러 타입인 LocationError를 정의하고, 콜백에 Error 타입의 인자를 추가해주었습니다.
enum LocationError: Error {
case fetchFailed
}
final class LocationService: NSObject {
typealias LocationCallback = (CLLocation?, Error?) -> Void // 위치 값을 인자로 전달하는 콜백 타입
...
}
- 이에 맞게 LocationManagerDelegate 구현부에서 콜백으로 인자를 넘겨주는 부분을 수정해줍니다.
- 위치 정보 불러오기 성공 시에는 에러에 nil 값을 넘겨주고, 실패 시에는 새로 정의한 에러 타입의 값을 넘겨줍니다.
extension LocationService: LocationManagerDelegate {
...
func locationManagerAbstract(_ manager: LocationManagerInterface, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
fetchLocationCallBack?(location, nil)
fetchLocationCallBack = nil
}
func locationManagerAbstract(_ manager: LocationManagerInterface, didFailWithError error: Error) {
fetchLocationCallBack?(nil, LocationError.fetchFailed)
fetchLocationCallBack = nil
}
}
📌 테스트 코드 수정
이제 다른 테스트도 추가하기 위해 테스트 코드를 수정해주겠습니다.
- 권한 상태에 따른 테스트를 위해 테스트 시 반환될 권한값도 정의해주었습니다. 👉 authorizationStatusToReturn
- requestWhenInUseAuthorization() 호출 시 해당 값을 넘겨주어 권한 상태에 따른 동작을 테스트할 수 있습니다.
- locationToReturn의 반환값 유무에 따라 requestLocation()의 동작을 나눠주었습니다. 반환된 위치 정보가 없는 경우에 에러 처리 코드가 호출되는지 테스트할 수 있습니다.
// CLLocationManager 대신 동작할 Mock 객체
struct LocationManagerMock: LocationManagerInterface {
var locationManagerDelegate: LocationManagerDelegate?
var locationToReturn: (() -> CLLocation)? // 테스트 시 전달할 위치값
var authorizationStatusToReturn: CLAuthorizationStatus = .notDetermined // 테스트 시 전달할 권한 상태
func requestLocation() {
if let location = locationToReturn?() {
locationManagerDelegate?.locationManagerAbstract(self, didUpdateLocations: [location])
} else {
locationManagerDelegate?.locationManagerAbstract(self, didFailWithError: LocationError.fetchFailed)
}
}
func requestWhenInUseAuthorization() {
locationManagerDelegate?.locationManagerAbstract(self, didChangeAuthorization: authorizationStatusToReturn)
}
}
위치 정보를 받아오지 못한 경우의 테스트 코드를 추가해주었습니다.
또한 테스트 코드 내용을 Given-When-Then 구조로 맞춰주었습니다.
final class LocationServiceTests: XCTestCase {
private var sut: LocationService!
override func setUpWithError() throws {
super.setUp()
}
override func tearDownWithError() throws {
super.tearDown()
sut = nil
}
func test_fetchCurrentLocation을_호출시_위치가_반환됐을_때_위치정보가_제대로_전달되는지_확인() {
// Given
var locationManagerMock = LocationManagerMock()
locationManagerMock.locationToReturn = {
return CLLocation(latitude: 37.6134436427887, longitude: 126.926493082645) // locationManager가 반환할 위치값
}
sut = LocationService(locationManager: locationManagerMock)
let expectedLocation = CLLocation(latitude: 37.6134436427887, longitude: 126.926493082645) // 기대값
var result = CLLocation(latitude: 0.0, longitude: 0.0) // 반환 결과를 받을 변수
let completionExpectation = expectation(description: "fetchCurrentLocation completion expectation")
// When
sut.fetchCurrentLocation { location, _ in
if let location = location {
result = location
completionExpectation.fulfill()
}
}
wait(for: [completionExpectation], timeout: 1)
// Then
XCTAssertEqual(result.coordinate.latitude, expectedLocation.coordinate.latitude)
XCTAssertEqual(result.coordinate.longitude, expectedLocation.coordinate.longitude)
}
func test_fetchCurrentLocation을_호출시_위치가_반환되지_않으면_에러를_던지는지_확인() {
// Given
var locationManagerMock = LocationManagerMock()
locationManagerMock.locationToReturn = {
return nil
}
sut = LocationService(locationManager: locationManagerMock)
var result: (location:CLLocation?, error:Error?)
let completionExpectation = expectation(description: "fetchCurrentLocation completion expectation")
// When
sut.fetchCurrentLocation { location, error in
result.location = location
result.error = error
completionExpectation.fulfill()
}
wait(for: [completionExpectation], timeout: 1)
// Then
XCTAssertNil(result.location)
XCTAssertNotNil(result.error)
}
}
📌 추가 리팩토링
2개 테스트 코드의 Given 부분을 보면 중복되는 코드가 많습니다.
LocationService와 LocationManagerMock 인스턴스를 생성하는 부분은 setUpWithError() 에서 처리해주는 것이 좋을 것 같습니다.
Given 단계에서는 테스트 시 넘겨줄 값을 지정하기 위해 LocationManagerMock 인스턴스를 생성하고 locationToReturn에 값을 할당해줍니다. LocationService에서는 델리게이트 할당이 이니셜라이저에서 이루어지기 때문에 mock 인스턴스를 생성한 이후에, 그 mock 인스턴스를 LocationService의 이니셜라이저로 넘겨주면서 LocationService를 초기화해주어야 합니다.
그렇기 때문에 mock 인스턴스 생성 → sut 생성 의 순서로 코드를 작성할 수밖에 없습니다.
locationToReturn은 mock 객체에서 새로 정의한 프로퍼티이기 때문에 LocationService에서는 접근할 수가 없습니다. (LocationManagerInterface에서 요구하지 않는 프로퍼티임)
그렇다고 테스트 시에만 사용하는 locationToReturn 프로퍼티를 인터페이스 프로토콜에 추가로 정의해주는 것은 실제 동작 시에 불필요한 프로퍼티가 추가되는 것이기 때문에 불필요하다고 생각됩니다.
그래서 저는 mock 객체용 인터페이스를 추가적으로 정의해주었습니다.
새로운 인터페이스 프로토콜은 LocationManagerInterface 프로토콜을 상속받도록 구현했습니다.
protocol LocationManagerMockInterface: LocationManagerInterface {
var locationToReturn: (() -> CLLocation?)? { get set }
var authorizationStatusToReturn: CLAuthorizationStatus { get set }
}
그리고 mock 객체의 채택 프로토콜을 수정하고 타입도 struct → class로 변경해주었습니다. 이는 인스턴스의 참조를 사용하기 위해서인데, 자세한 이유는 아래에서 설명하겠습니다.
// CLLocationManager 대신 동작할 Mock 객체
class LocationManagerMock: LocationManagerMockInterface {
...
}
테스트 코드에서는 setUpWithError()에서 먼저 sut 인스턴스를 초기화해줍니다. 이니셜라이저를 통해 LocationManagerMock 인스턴스를 프로퍼티로 할당해줍니다.
sut 인스턴스의 초기화 이후에 mock 인스턴스를 할당해주면 델리게이트 지정이 되지 않습니다. 왜냐하면 델리게이트 할당이 sut(LocationService) 객체의 초기화 단계에서 이루지기 때문입니다.
setUpWithError()에서 인스턴스 초기화가 이루어지기 때문에 이제 각 테스트 코드의 Given 단계에서는 인스턴스를 생성할 필요 없이 반환값의 세팅만 해주면 됩니다.
override func setUpWithError() throws {
super.setUp()
sut = LocationService(locationManager: LocationManagerMock())
}
override func tearDownWithError() throws {
super.tearDown()
sut = nil
}
mock 객체의 locationToReturn 프로퍼티는 새로운 인터페이스 프로토콜의 요구사항이기 때문에 기존 locationManager로 바로 접근하게 되면 접근할 수가 없습니다. 그렇기 때문에 다운캐스팅을 진행해줍니다.
다운캐스팅 이후에는 locationToReturn 프로퍼티로 접근이 가능해집니다. 하지만 다운캐스팅된 locationManagerMock는if var 구문 내부에서만 사용할 수 있기 때문에 앞에서 mock 객체를 참조 타입인 클래스로 수정해준 것입니다. 이렇게 하면 if var 구문 내부에서의 수정이 locationManager 인스턴스에 반영됩니다.
func test_fetchCurrentLocation을_호출시_위치가_반환됐을_때_위치정보가_제대로_전달되는지_확인() {
// Given
if var locationManagerMock = sut.locationManager as? LocationManagerMockInterface {
locationManagerMock.locationToReturn = {
return CLLocation(latitude: 37.6134436427887, longitude: 126.926493082645) // locationManager가 반환할 위치값
}
}
let expectedLocation = CLLocation(latitude: 37.6134436427887, longitude: 126.926493082645) // 기대값
var result = CLLocation(latitude: 0.0, longitude: 0.0) // 반환 결과를 받을 변수
let completionExpectation = expectation(description: "fetchCurrentLocation completion expectation")
// When
sut.fetchCurrentLocation { location, _ in
if let location = location {
result = location
completionExpectation.fulfill()
}
}
wait(for: [completionExpectation], timeout: 1)
// Then
XCTAssertEqual(result.coordinate.latitude, expectedLocation.coordinate.latitude)
XCTAssertEqual(result.coordinate.longitude, expectedLocation.coordinate.longitude)
}
최종적으로 작성된 테스트 코드는 아래와 같습니다.
import XCTest
import CoreLocation
@testable import Data
protocol LocationManagerMockInterface: LocationManagerInterface {
var locationToReturn: (() -> CLLocation?)? { get set }
var authorizationStatusToReturn: CLAuthorizationStatus { get set }
}
// CLLocationManager 대신 동작할 Mock 객체
class LocationManagerMock: LocationManagerMockInterface {
var locationManagerDelegate: LocationManagerDelegate?
var locationToReturn: (() -> CLLocation?)? // 테스트 시 전달할 위치값
var authorizationStatusToReturn: CLAuthorizationStatus = .notDetermined // 테스트 시 전달할 권한 상태
func requestLocation() {
if let location = locationToReturn?() {
locationManagerDelegate?.locationManagerAbstract(self, didUpdateLocations: [location])
} else {
locationManagerDelegate?.locationManagerAbstract(self, didFailWithError: LocationError.fetchFailed)
}
}
func requestWhenInUseAuthorization() {
locationManagerDelegate?.locationManagerAbstract(self, didChangeAuthorization: authorizationStatusToReturn)
}
}
final class LocationServiceTests: XCTestCase {
private var sut: LocationService!
override func setUpWithError() throws {
super.setUp()
sut = LocationService(locationManager: LocationManagerMock())
}
override func tearDownWithError() throws {
super.tearDown()
sut = nil
}
func test_fetchCurrentLocation을_호출시_위치가_반환됐을_때_위치정보가_제대로_전달되는지_확인() {
// Given
if var locationManagerMock = sut.locationManager as? LocationManagerMockInterface {
locationManagerMock.locationToReturn = {
return CLLocation(latitude: 37.6134436427887, longitude: 126.926493082645) // locationManager가 반환할 위치값
}
}
let expectedLocation = CLLocation(latitude: 37.6134436427887, longitude: 126.926493082645) // 기대값
var result = CLLocation(latitude: 0.0, longitude: 0.0) // 반환 결과를 받을 변수
let completionExpectation = expectation(description: "fetchCurrentLocation completion expectation")
// When
sut.fetchCurrentLocation { location, _ in
if let location = location {
result = location
completionExpectation.fulfill()
}
}
wait(for: [completionExpectation], timeout: 1)
// Then
XCTAssertEqual(result.coordinate.latitude, expectedLocation.coordinate.latitude)
XCTAssertEqual(result.coordinate.longitude, expectedLocation.coordinate.longitude)
}
func test_fetchCurrentLocation을_호출시_위치가_반환되지_않으면_에러를_던지는지_확인() {
// Given
if var locationManagerMock = sut.locationManager as? LocationManagerMockInterface {
locationManagerMock.locationToReturn = {
return nil
}
}
var result: (location:CLLocation?, error:Error?)
let completionExpectation = expectation(description: "fetchCurrentLocation completion expectation")
// When
sut.fetchCurrentLocation { location, error in
result.location = location
result.error = error
completionExpectation.fulfill()
}
wait(for: [completionExpectation], timeout: 1)
// Then
XCTAssertNil(result.location)
XCTAssertNotNil(result.error)
}
}
테스트도 무사히 통과하게 되었습니다.
아쉬운 점
- 이렇게 수정하면서 결과적으로 각각의 테스트 코드 내부에서 인스턴스는 매번 생성하지 않게 되었습니다. 코드 길이도 같이 줄어들 것이라 기대했지만 타입 캐스팅 때문에 코드의 길이가 줄어들지는 않았습니다.
- 또한 위치 권한 상태에 따른 동작도 테스트해보려 했지만 Core Location에서 내부적으로 설정되어 있는 locationManagerDidChangeAuthorization(_ manager: CLLocationManager) 메서드의 호출 조건이 새로운 CLLocationManager 클래스 생성 시와 권한 상태가 변경됐을 때라서, 이를 mock 객체로 제어하는 방법이 떠오르지 않아 진행하지 못했습니다.
테스트코드를 처음 작성해봤는데, 처음부터 너무 어려운 테스트 코드를 시도한게 아닐까 싶은 생각이 들었습니다. 나름 자료를 찾아보면서 참고하며 작성한 코드지만 정말 테스트코드로서 의미가 있는건가에 대한 확신은 덜했던 것 같습니다.
그래도 이번 경험을 통해 어느 부분에서 테스트가 필요한 것인지, 어떻게 테스트하는 것인지 등을 어느 정도 짚고 넘어갈 수 있었다고 생각합니다.
이번에는 동작을 구현한 다음 테스트 코드로 검증을 했지만, 이후에는 TDD 기반으로 개발을 진행해보고 싶다는 생각이 들었습니다.
참고자료
https://dzone.com/articles/improving-testability-of-cllocationmanager
https://longlivedrgn-miro.tistory.com/61
'iOS' 카테고리의 다른 글
[iOS] SwiftUI의 TextField에 Clear Button 추가하기 (0) | 2024.05.28 |
---|---|
[iOS] Build input file cannot be found: '~/Info.plist' 에러 발생 시 (0) | 2024.05.05 |
[iOS] Core Location 사용해보기 (0) | 2024.04.23 |
[iOS] 외국 밈(meme)에서 많이 쓰는 폰트 적용해보기 (SwiftUI) (0) | 2024.04.18 |
[iOS] M1 맥북 + Xcode 15 환경에서 SwiftLint 적용 시 오류 처리 (0) | 2024.02.28 |