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

4 분 소요


목차

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

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

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

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


참고

iOS Networking and Testing(우아한 형제들 기술 블로그)
힐페TV - ViewModel을 무조건 믿을 수 있는 방법이 있다???(삐슝빠슝)(강남언니 기술 블로그)


우리 프로젝트는 Moya를 사용하고 있다. Alamofire를 한 번 더 추상화한 HTTP 통신 라이브러리로, 마음에 든다…

Moya 간단 소개

enum MyAPI {
    case api1
    case api2(id: Int)
}

요런 식으로 API들을 enum으로 정의하고

extension MyAPI: TargetType {
    var baseURL: URL
    var path: String
    var method: Moya.Method
    var sampleData: Data
    var task: Task
    var headers: [String : String]?
}

TargetType을 conform하여 구현하면 된다.

  • baseURL
    • 말 그대로 베이스 url. 개발 서버와 릴리즈 서버가 다른 경우 플래그에 따라 다르게 리턴해주는 등 하면 됨
  • path
    • 해당 API의 패스. 베이스 url에 덧붙여지는 부분
  • method
    • GET, POST, PATCH, DELETE 등 HTTP 메소드
  • sampleData
    • 예측되는 리스폰스의 샘플 데이터
  • task
    • HTTP 태스크. 요청 시 파라미터 같이 보내거나할 때 쓰는 부분
  • headers
    • HTTP 요청 시 헤더 부분. Authorization 등 여기서 채우면 됨
let provider = MoyaProvider<MyAPI>()
provider.rx.request(.api1)
    .subscribe(onSuccess: {})
    .disposed(by: disposeBag)

요런 식으로 네트워크 요청을 만들어서 Single을 받아볼 수 있음!!


Moya In Testing

그럼 모야를 어떻게 테스트할 수 있을까
딱 보면 저 provider가 메인인 걸 알 수 있다.

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)

MoyaProvider의 init 부분이다. 주로 사용할 부분만 보자면

  • endpointClosure
    • 위에서 통신에 필요한 여러가지(헤더, 메소드 등)를 configure한 Target을, 실제 리퀘스트에 사용 가능하게 콘크리트 엔드포인트를 만드는 클로저
  • stubClosure
    • never, immediately, delayed가 있다. immediately를 사용할 경우 바로 반환, delayed를 사용할 경우 지연되게 받을 수 있음
  • plugins
    • 여러 플러그인이 사용 가능하다. 주로 MoyaInterceptor 많이 쓰는 듯

그래서?

let customEndpointClosure = { (target: MyAPI) -> Endpoint in
    return Endpoint(url: URL(target: target).absoluteString,
                    sampleResponseClosure: { .networkResponse(201, target.sampleData) },
                    method: target.method,
                    task: target.task,
                    httpHeaderFields: target.headers)
}
let testProvider =  MoyaProvider<MyAPI>(endpointClosure: customEndpointClosure,
                                        stubClosure: MoyaProvider.immediatelyStub)

이렇게 커스텀으로 목업된 엔드포인트를 만드는 endpointClosure를 넘겨주고, stubClosureimmediatelyStub로 하면 테스트용 프로바이더 생성 끝!!

let customEndpointClosure = { (target: MyAPI) -> Endpoint in
    var statusCode: Int?
    var data: Data?
    switch target {
    case .api2(let id):
        switch id {
            /// Wrong data
            case -1: data = Data(MyTests.wrongJsonData.utf8)
            
            /// Server error
            case -500: statusCode = 500
        
        /// Normal data
        default:
            data = Data(MyTests.normalData.utf8)
        }
    default:
        break
    }
    return Endpoint(url: URL(target: target).absoluteString,
                    sampleResponseClosure: { .networkResponse(statusCode ?? 201, data ?? Data()) },
                    method: target.method,
                    task: target.task,
                    httpHeaderFields: target.headers)
}

나는 주로 테스팅할 때 이런 식으로 api의 파라미터로 구분해서 에러 데이터나 정상적인 데이터를 리턴하도록 하니까 편한 거 같음

이제 이 프로바이더는 테스트 가능함!!


근데


문제?

그냥 저 프로바이더만 테스팅할 건 아니잖음??

우리는 뷰모델을 테스트 해야 함

class MyRepository<MyAPI: TargetType> {
    var shared = MyRepository()
    var provider: MoyaProvider<MyAPI>
    
    private init() {
        self.provider = MoyaProvider<MyAPI>()
    }
    
    func getAPI1() -> Single<Model1> {
        return provider.rx
            .request(.api1)
            .map(Model1.self)
    }
}

기존에 요런 식으로 레포지토리를 만들고

struct MyVM {
    let repository = MyRepository.shared
}

뷰모델에서 이런 식으로 사용하고 있었는데

여기서!! 문제점
바로바로 싱글톤과 DIP 위반


싱글톤이 왜 문제냐

  • 장점
    • 할당 비용 이득
      • 같은 객체를 여러번 사용하는 경우 이걸 재활용하면 메모리 공간은 물론 할당에 드는 오버헤드도 줄일 수 있다.
    • 데이터 공유
      • 여러 클래스에서 같은 인스턴스를 접근하므로 데이터 공유가 가능함.

싱글톤 처음 봤을 땐 오 간단한데 짱 좋아 보이는데? 했는데 진짜 큰 문제가 있다;;

  • 단점
    • 클라이언트가 콘크리트 클래스에 의존
      • DIP 위반
    • 테스트하기가 어려움
      • 위에 이어서, 객체간 의존도가 높아질 수밖에 없음.

테스트 코드를 좀 짜려 해도 진짜 그거 관련된 부분만 딱딱해져서 어떻게 손을 댈 수가 없다!!

그래서… 싱글톤은 없애기로 했다.
이유는 장점이 별로 안 장점이라서… 네트워크 레포지토리는 데이터 공유가 필요 없음 단점이 더 크다
FileManager.default 개념으로 빠르게 접근 가능하게 기존 static 인스턴스는 놔둬도 되긴 할 듯


바꾼 후

struct MyVM: ViewModel {
    var disposeBag = DisposeBag()
    let repository: MyRepository
    
    init(_ provider: MyProvider<MyAPI>? = nil) {
        repository = MyRepository(provider)
    }
}
class MyRepository<MyAPI: TargetType> {
    var provider: MoyaProvider<MyAPI>
    
    init(_ provider: MoyaProvider<MyAPI>? = nil) {
        self.provider = provider ?? MoyaProvider<MyAPI>()
    }

    func getAPI1() -> Single<Model1> {
        return provider.rx
            .request(.api1)
            .map(Model1.self)
    }
}

왜 DI DI 하는지!! 이유가 다 있다 이거 때문이지

여기다 DIP도 따르도록, 레포지토리도 콘크리트인 MyRepository가 아니라 추상화된 프로토콜을 추가하고 그걸 따르도록 해야할까도 생각해봤는데
이미 MyRepository 자체가 목업과 실제가 표현이 가능한 애라서… 굳이 싶어서 안 함


테스트 코드

전제

class MyVM: ViewModel {
    struct Input {
        let id: Observable<Int>
    }
    
    struct Output {
        let result: Observable<Model1>
    }

    func transform(input: Input) -> Output {
        let result = input.id.flatMap(repository.getAPI1)
        return Output(result: result.asObservable())
    }
}

정말 간단간단하게 뷰모델이 그냥 이렇다 치면

0.

override func setUpWithError() throws {
    let customEndpointClosure = { (target: MyAPI) -> Endpoint in
        // ...
    }
    let testProvider =  MoyaProvider<MyAPI>(endpointClosure: customEndpointClosure,
                                            stubClosure: MoyaProvider.immediatelyStub)
    
    vm = MyVM(testProvider)
    scheduler = TestScheduler(initialClock: 0)
    disposeBag = DisposeBag()
}

테스트 프로바이더로 세팅 해주고

1.

func testNetwork() throws {
    let idEvents: [Recorded<Event<Int>>] = [
        .next(0, 1),
        .next(10, 2),
        .next(20, 3)
    ]
    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, Model1(value: EXPECTED_1)),
                                   .next(10, Model1(value: EXPECTED_2)),
                                   .next(20, Model1(value: EXPECTED_3))])
    // ...
}

이전 포스트와 똑같이 이제 테스트하면 됨!!


굿


태그: ,

카테고리:

업데이트:

댓글남기기