티스토리 뷰

iOS 초보자의 글이므로 틀린 부분이 있다면 댓글로 바로잡아 주시면 감사하겠습니다. 🙇🏻‍♂️

 

저희 앱은 기본적으로 클린 아키텍처를 채택했습니다.

 

GitHub - boostcampwm-2022/iOS05-Segno: 다시 이곳의 추억에서부터🎧, 세뇨(Segno)

다시 이곳의 추억에서부터🎧, 세뇨(Segno). Contribute to boostcampwm-2022/iOS05-Segno development by creating an account on GitHub.

github.com

 

 

이는 앱의 개발 뿐 아니라 추후 진행할 유닛 테스트까지 염두해 두었기 때문이었습니다. 일반적으로 테스트하려는 객체가 모호할 때 대신 테스트를 진행할 테스트 더블을 만들어주어야 하는데요. 클린 아키텍처 구조로 코드를 작성하면 이미 인터페이스를 만들어 놓았기 때문에 인터페이스 분리작업을 하지 않아도 되는 장점이 있습니다.

위의 장점을 활용했을 때와, 활용하지 않았을 때를 지금부터 비교해보도록 하겠습니다.

 

1) 클린 아키텍처의 구조적 장점을 활용하지 않았을 때

final class GetAddressUseCaseTest: XCTestCase {
    var scheduler: TestScheduler!
    var useCase: GetAddressUseCase!
    var repository: LocationRepository!
    var address: TestableObserver<String>!
    var disposeBag: DisposeBag!
    
    override func setUpWithError() throws {
        try super.setUpWithError()
        scheduler = TestScheduler(initialClock: 0)
        repository = LocationRepositoryImpl() 
        useCase = GetAddressUseCaseImpl(repository: repository)
        address = scheduler.createObserver(String.self)
        disposeBag = DisposeBag()
    }
		...
    func test_getAddressByCoordeinates() throws {
        // given
        let location = Location(
            latitude: 37.247935,
            longitude: 127.076536
        )
        let expectation = XCTestExpectation(description: "getAddress")
        repository.addressSubject
            .bind(to: address)
            .disposed(by: disposeBag)
        let correctAddress = Recorded.events(.next(0, "경기도 수원시 영통구 영통동 1024-3"))

        // when
        useCase.getAddress(by: location)
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now()+3) {
            expectation.fulfill()
        }
        wait(for: [expectation], timeout: 9)
		
        // then
        XCTAssertEqual(address.events, correctAddress)
    }
}

위의 코드에서는 실제 LocationRepositoryImpl 구현체를 도입하게 되어, 유즈케이스를 테스트한다는 그 취지에 맞지 않게 실제 LocationRepositoryImpl 내 메서드의 동작까지도 테스트하게 됩니다. 여기서 LocationRepositoryImpl 내부를 살짝 살펴보겠습니다.

 

LocationRepository.swift

protocol LocationRepository {
	...
}

final class LocationRepositoryImpl: NSObject, LocationRepository {
    
...
    
    func getAddress(by location: Location) {
        let cllocation = CLLocation(latitude: location.latitude, longitude: location.longitude)
        getAddress(location: cllocation)
    }
    
    func getAddress(location: CLLocation) {
        let geocoder = CLGeocoder()
        let locale = Locale(identifier: "Ko-kr")
        geocoder.reverseGeocodeLocation(location, preferredLocale: locale) { placemarks, error in
            ...
            
            self.addressSubject.onNext("특정 주소")
        }
    }
}

LocationRepository에는 많은 메서드가 있는데 그 중에서도 getAddress(location: Location) 메서드는 CLGeocoder 클래스의 reverseGeocodeLocation 라는 async 메서드를 내부에서 실행합니다. 테스트를 하기 위해서는  reverseGeocodeLocation 메서드에 파라미터로 전달하는 클로저 내부에서 생성되는 “특정 주소” 값을 이용해야 했습니다. 하지만 테스트 함수에서 별다른 처리를 해놓지 않으면 비동기 요청이 완료되기도 전에 테스트가 종료됩니다. 이를 방지하기 위해 DispatchQueue의 asyncAfter를 인위적으로 도입하여, 비동기 요청이 끝나기도 전에 테스트가 종료되는 불상사를 막아야 했습니다.

 

위와 같이 테스트를 작성하면 테스트는 정상적으로 이루어집니다. 하지만 이렇게 테스트를 작성하면 유즈케이스만을 테스트 하고자 했던 원래의 의도와 달리 레포지토리의 동작까지 테스트하게 됩니다. 이는 계층을 분리하여 테스트를 쉽게 하고자 했던 원래의 취지와 어긋나는 일이었습니다. 또한 LocationRepositoryImpl() 전체를 들고 있기에 실제 유즈케이스 테스트에 필요하지도 않은 온갖 메서드들을 덜렁덜렁 가지고 있게 되죠.

이에 따라 StubRepository를 도입하여, UseCase의 역할에 집중한 테스트로 리팩토링을 진했습니다.

 

2) 클린 아키텍처의 구조적 장점을 활용했을 때

final class GetAddressUseCaseTest: XCTestCase {
    
    final class StubRepository: LocationRepository {
        var locationSubject: PublishSubject<Location>
        var addressSubject: PublishSubject<String>
        var errorStatus: PublishSubject<LocationError>
        var errorObservable: Observable<LocationError> {
            errorStatus.asObservable()
        }
        let fakeReturnValue = "경기도 수원시 영통구 영통동 1024-3"
        
        init() {
            locationSubject = PublishSubject<Location>()
            addressSubject = PublishSubject<String>()
            errorStatus = PublishSubject<LocationError>()
        }
        
        func getAddress(by location: Location) {
            let cllocation = CLLocation(
                latitude: location.latitude,
                longitude: location.longitude
            )
            getAddress(location: cllocation)
        }
        func getAddress(location: CLLocation) {
            addressSubject.onNext(fakeReturnValue)
        }
        func getLocation() { }
        func stopLocation() { }
    }
	...
}

LocationRepository 프로토콜을 채택한 StubRepository를 만들었습니다. Stub은 테스트 더블의 한 종류로 호출 시 미리 정해진 가짜 값을 반환하여 나머지 동작을 계속 진행할 수 있게 해줍니다. 이에 착안하여 StubRepository에서는 미리 돌려줄 주소 값을 fakeReturnValue 로 미리 정의하여 getAddress(location: CLLocation) 실행 시 이 값을 addressSubject에 onNext로 전달합니다. 유즈케이스를 테스트하는데 불필요한 reverseGeocodeLocation 함수를 실행하지 않아 유즈케이스를 테스트한다는 원래 의도에 적합한 테스트가 만들어졌습니다.

 

전체 코드

final class GetAddressUseCaseTest: XCTestCase {
    
    final class StubRepository: LocationRepository {
        var locationSubject: PublishSubject<Location>
        var addressSubject: PublishSubject<String>
        var errorStatus: PublishSubject<LocationError>
        var errorObservable: Observable<LocationError> {
            errorStatus.asObservable()
        }
        let fakeReturnValue = "경기도 수원시 영통구 영통동 1024-3"
        
        init() {
            locationSubject = PublishSubject<Location>()
            addressSubject = PublishSubject<String>()
            errorStatus = PublishSubject<LocationError>()
        }
        
        func getAddress(by location: Location) {
            let cllocation = CLLocation(
                latitude: location.latitude,
                longitude: location.longitude
            )
            getAddress(location: cllocation)
        }
        func getAddress(location: CLLocation) {
            addressSubject.onNext(fakeReturnValue)
        }
        func getLocation() { }
        func stopLocation() { }
    }

    var scheduler: TestScheduler!
    var useCase: GetAddressUseCase!
    var repository: LocationRepository!
    var address: TestableObserver<String>!
    var disposeBag: DisposeBag!
    
    override func setUpWithError() throws {
        try super.setUpWithError()
        scheduler = TestScheduler(initialClock: 0)
        repository = StubRepository()
        useCase = GetAddressUseCaseImpl(repository: repository)
        address = scheduler.createObserver(String.self)
        disposeBag = DisposeBag()
    }
    
    override func tearDownWithError() throws {
        try super.tearDownWithError()
        scheduler = nil
        repository = nil
        useCase = nil
        address = nil
        disposeBag = nil
    }

    func test_getAddress() throws {
        // given
        let location = Location(
            latitude: 37.247935,
            longitude: 127.076536
        )

        repository.addressSubject
            .bind(to: address)
            .disposed(by: disposeBag)

        let correctAddress = Recorded.events(.next(0, "경기도 수원시 영통구 영통동 1024-3"))
        
        // when
        useCase.getAddress(by: location)
        
        // then
        XCTAssertEqual(address.events, correctAddress)
    }
}
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함