Xcode UnitTest ③ - RxSwift + Moya 네트워크 딜레이 테스팅!!

6 분 소요


목차

1: Xcode UnitTest ① - RxTest!! 테스트 스케줄러

2: Xcode UnitTest ② - RxSwift + Moya!! 네트워크도 목업으로 테스트

3: Xcode UnitTest ③ - RxSwift + Moya 네트워크 딜레이 테스팅!!

4: Xcode UnitTest ④ - 병렬 처리!! 퍼포먼스 테스팅(measure)


전 편에 이어서…
삽질했던 과정들 나열


문제 1

근데… 네트워크 딜레이는 어떻게 테스트할 수 있을까??

예를 들어서… 탭을 누르면 해당 탭에 맞는 새 데이터를 네트워크에서 로드해서 보여줘야 하는데,
사용자가 탭을 마구마구 빠르게 누르면 어떻게 될까??
네트워크 요청은 늦게 오는데 탭은 이미 바뀌어 있으면 현재 탭에 맞지 않는 정보를 보여주게 될 지도 모른다.
이런 거를 Back Pressure(배압) 문제라고 한다네여
그래서 그런 부분을 해결해 놓은 뷰모델을 여차저차 만들었다치자!!

근데 어케 테스트하지??

let customEndpointClosure = { (target: MyAPI) -> Endpoint in
    return Endpoint(...)
}
let testProvider =  MoyaProvider<MyAPI>(endpointClosure: customEndpointClosure,
                                        stubClosure: MoyaProvider.immediatelyStub)

전 편에서 이렇게 테스트 프로바이더를 만들었었는데, immediatelyStub을 사용해서 샘플 데이터를 바로 반환할 수 있었다.

아 그 delayedStub 그거 쓰면 안 됨?? 했지만…
그건 측정되지 않는다. 스케줄러가 다르니까!!

비슷한 고민을 한 2018년 글을 찾았다 ⇒ How to use delayed stub with RxTest.TestScheduler?(Github Issue)
근데 딱히 라이브러리 쪽에서 해결된 건 없어 보임.


해결 1-1

스케줄러가 달라서 문제면 스케줄러를 입력 받으면 되는 거 아닐까?? 해서

extension ObservableType {
    func addTestableDelay(_ delayTime: Int, scheduler: SchedulerType)
    -> Observable<Element> {
        return flatMap { element in
            Observable<Element>.create { observer in
                _ = Observable<Int>.timer(RxTimeInterval.seconds(delayTime), scheduler: scheduler)
                    .subscribe(onNext: { _ in
                        observer.onNext(element)
                    })
                return Disposables.create()
            }
        }
    }
}

이렇게 스케줄러를 입력 받으면 타이머에 지정해서 딜레이를 줄 수 있겠다고 생각함.
실제로 저거 붙여서 테스트 해 보니 잘 됨!! 오


해결 1-2

근데 저거를 덕지덕지 붙이지 말고 테스트프로바이더 생성할 때 해결되면 얼마나 좋을까??
프로바이더 생성 시에 받는 stubClosure를 어떻게 건드려서 간단하게 못할까??

1

그래서 찾아본 stub 쪽(MoyaProvider 파일)
StubBehavior에 스케줄러를 받는 enum 어케 추가하면 될 거 같기도 하고 싶었으나

2

stub을 실제로 쓰는 부분을 보면(MoyaProvider 파일)
이렇게 종류에 따라서 immediate면 그냥 보내고 delayed면 딜레이해서 보낸다.
여기서 delay가 있을 때 콜백큐 또는 없으면 메인큐에서 시간을 죽였다가 보내는 걸 볼 수 있는데, RxTest의 TestScheduler로는 이쪽으로 뭔가 건드려서 해보기가 불가능함.
애초에 이 파일은 Rx와 관련 없는 곳이기도 하고…

그래서 Rx extension이 있는 곳을 찾아와 봄

3

Moya 라이브러리 내부의 request 함수를 보면 이렇게 생겼다(MoyaProvider+Rx 파일)
얘를 살짝 바꿔서

extension Reactive where Base: MoyaProviderType {
    func request(_ token: Base.Target, callbackQueue: DispatchQueue? = nil, delayTime: Int? = nil, scheduler: SchedulerType? = nil) -> Single<Response> {
        guard let delayTime = delayTime, let scheduler = scheduler else {
            return request(token, callbackQueue: callbackQueue)
        }
        
        return Single.create { [weak base] single in
            let cancellableToken = base?.request(token, callbackQueue: callbackQueue, progress: nil) { result in
                Observable<Int>.timer(RxTimeInterval.seconds(delayTime), scheduler: scheduler)
                    .subscribe(onNext: { _ in
                        switch result {
                        case let .success(response):
                            single(.success(response))
                        case let .failure(error):
                            single(.failure(error))
                        }
                    })
            }
            return Disposables.create {
                cancellableToken?.cancel()
            }
        }
    }
}

비슷하게 생긴 함수 request(_ token: Base.Target, callbackQueue: DispatchQueue?, delayTime: Int?, scheduler: SchedulerType?)를 한 번 추가해 봄니다…

delayTime과 scheduler가 없다면 기존 request 함수를 요청하고,
있다면? 위의 addTestableDelay()와 비슷하게 타이머로 한 번 딜레이를 줘서 single 결과를 보내줘 봄

이것도 테스트 시 딜레이 잘 됨!!

문제 2

근데 또 문제인 게
이 코드를 모든 네트워크 요청 쪽에다가 붙여줄 수는 없지 않음??

struct MyVM: ViewModel {
    var disposeBag = DisposeBag()
    let repository: MyRepository
                                               // added!!
    init(_ provider: MyProvider<MyAPI>? = nil, delayTime: Int? = nil, scheduler: SchedulerType? = nil) {
        repository = MyRepository(provider, delayTime: delayTime, scheduler: scheduler)
    }
}
class MyRepository<MyAPI: TargetType> {
    var provider: MoyaProvider<MyAPI>

    // added!!
    var delayTime: Int?
    var scheduler: SchedulerType?
    
                                                 // added!!
    init(_ provider: MoyaProvider<MyAPI>? = nil, delayTime: Int? = nil, scheduler: SchedulerType? = nil) {
        self.provider = provider ?? MoyaProvider<MyAPI>()
        self.delayTime = delayTime
        self.scheduler = scheduler
    }

    func getAPI1() -> Single<Model1> {
        return provider.rx
            .request(.api1, delayTime: delayTime, scheduler: scheduler) // added!!
            .map(Model1.self)
    }
}

갑자기 이런 걸 온데 때만데 다 갖다 붙이려니 좀 아득해지는데…
실제 릴리즈에는 쓰이지도 않는 정보들을…


해결 2-1

class DelayTestableProvider<Target: TargetType>: MoyaProvider<Target> {
    var delayTime: Int?
    var scheduler: SchedulerType?
    
    init(endpointClosure: @escaping EndpointClosure = MoyaProvider.defaultEndpointMapping,
         requestClosure: @escaping RequestClosure = MoyaProvider<Target>.defaultRequestMapping,
         stubClosure: @escaping StubClosure = MoyaProvider.neverStub,
         callbackQueue: DispatchQueue? = nil,
         session: Session = MoyaProvider<Target>.defaultAlamofireSession(),
         plugins: [PluginType] = [],
         trackInflights: Bool = false,
         delayTime: Int? = nil,
         scheduler: SchedulerType? = nil) {
        self.delayTime = delayTime
        self.scheduler = scheduler
        super.init(endpointClosure: endpointClosure, requestClosure: requestClosure, stubClosure: stubClosure, callbackQueue: callbackQueue, session: session, plugins: plugins, trackInflights: trackInflights)
    }
}

그래서 그냥 이렇게 된 거 커스텀 프로바이더를 만들어 보자 싶었다
delayTime과 scheduler를 추가로 받음

extension Reactive where Base: DelayTestableProvider<MyAPI> {
    func request(_ token: Base.Target, callbackQueue: DispatchQueue? = nil) -> Single<Response> {
        guard let delayTime = base.delayTime, let scheduler = base.scheduler else {
            return Single.create { ... }
        }
        return Single.create { [weak base] single in
            let cancellableToken = base?.request(token, callbackQueue: callbackQueue, progress: nil) { result in
                Observable<Int>.timer(RxTimeInterval.seconds(delayTime), scheduler: scheduler)
                    .subscribe(onNext: { _ in
                        switch result {
                        case let .success(response):
                            single(.success(response))
                        case let .failure(error):
                            single(.failure(error))
                        }
                    })
            }
            return Disposables.create {
                cancellableToken?.cancel()
            }
        }
    }
}

그리고 기존 request 함수와 비슷하게 Base만 바꿔서 만들어 봄

extension에서 제네릭 쓰는 방법?

처음에 extension Reactive where Base: DelayTestableProvider<Target: TargetType> 이런 식으로 썼었는데, Target이 없다고 오류가 나더라

그래서 찾아보니 -> 깃헙 이슈

extension Reactive {
    public func foo<T>() -> Signal<T, NoError> where Base: MyGeneric<T> {
        // …
    }
}

이런 방법이 가능하다!!! where 절에서는 못하는 듯

extension Reactive {
    func request<Target: TargetType>(_ token: Base.Target, callbackQueue: DispatchQueue? = nil) -> Single<Response> where Base: DelayTestableProvider<Target> {
        return ...
    }
}

위 코드는 이런 식으로 될 듯

근데 TargetType을 제네릭으로 하든, 위 코드처럼 특정 API로 명시하든 저 함수가 호출이 안 된다!!
무조건 기존 request 함수를 호출하게 됨

우째서지… 메소드 디스패치 쪽 좀 더 공부해봐야 할 듯


해결 2-2

결국 그냥 request 함수를 기존과 다르게 재정의 함…
하는 김에 모든 네트워크 요청에서 공통적으로 해줬던 에러 핸들링 부분(status code에 따른 에러 등)도 그 request 함수에서 한 번에 해줬는데
리팩토링도 되고 나름 일석이조 된 듯ㅋㅋ


테스팅

아무튼 길고 긴 과정을 지나 이제 진짜 테스팅할 시간

네트워크 딜레이가 50이 걸린다고 가정하면

event time id 리스폰스 도착 시간 result
0 1 50 EXPECTED_1
100 2 150 (skipped)
105 3 155 EXPECTED_3
200 4 250 EXPECTED_4

이렇게 사용자가 100, 105에서 너무 빠르게 입력을 바꿨을 경우, 100에서 요청해서 150에 도착한 건 무시하고 155만 제대로 방출하는 지 테스트해볼까요

func testDelay() throws {
    let delayTime = 50
    let testProvider =  DelayTestableProvider<MyAPI>(endpointClosure: customEndpointClosure,
                                                     stubClosure: MoyaProvider.immediatelyStub,
                                                     delayTime: delayTime,
                                                     scheduler: scheduler)
    
    let vm = MyVM(testProvider)
    
    let idEvents: [Recorded<Event<Int>>] = [
        .next(0, 1),
        .next(100, 2),
        .next(105, 3),
        .next(200, 4)
    ]
    let output = vm.transform(input: .init(id: scheduler.createHotObservable(idEvents).asObservable()))
    let result = scheduler.createObserver(Model1.self)
    
    output.result.bind(to: result).disposed(by: disposeBag)
    
    
    scheduler.start()

    XCTAssertEqual(result.events, [.next(0 + delayTime, Model1(value: EXPECTED_1)),
                                   // .next(100 + delayTime, Model1(value: EXPECTED_2)), skipped
                                   .next(105 + delayTime, Model1(value: EXPECTED_3)),
                                   .next(200 + delayTime, Model1(value: EXPECTED_4))])
    // ...
}

이제 딜레이도 적용해서 테스팅이 가능하다!!!
뿌듯함…



이거 테스트할 수 있으니까 너무 좋음
덕분에 완벽한 줄만 알았던 내 로직에 에러도 많이 찾고, 고치고, 신뢰할 수 있게 되었다

사실 딱 한 가지 또 문제점이 아직 있는데
각 테스트 케이스를 하나씩 따로 돌리면 문제 없이 스케줄러가 딜레이 되어 동작하는데
한 번에 여러 개를 돌릴 때는 예상대로 안 돌아간다.

고건 왜 그런지 잘 모르겠음… 그래서 그냥 딜레이 테스트 케이스들은 따로 돌려주고 있다ㅋㅋ 다음에 좀 더 고민해 보는 걸로

+ tearDownWithError()의 중요성(추가)

해당 이유를 스케줄러의 딜레이로 추정했었지만, 각 테스트 케이스가 끝난 후 캐시를 제대로 삭제하지 않아 생긴 문제였다!!(캐시가 있을 때 네트워크 딜레이가 안 생기게 하는 테스트였어서…)
스케줄러에 딜레이 주는 거는 아무 문제 없음!!

따라서
setUpWithError()에서 각 테스트 케이스가 시작 되기 전 각각 설정 해준다면,
tearDownWithError()에서 각 테스트 케이스가 끝나고 테스트하느라 설정했거나 추가된 부분들을 없애줘야 한다.

왜냐하면 한 번에 묶어서 실행될 때에 테스트 케이스들은 각각 따로 실행되긴 하지만, 휴대폰의 상태가 유지되기 때문


바빠서 한동안 못 봤었는데 해결하니 좋네여ㅎㅎ


태그: ,

카테고리:

업데이트:

댓글남기기