호댕의 iOS 개발

[iOS] Networking Test (Stub) + RxSwift 본문

Software Engineering/iOS

[iOS] Networking Test (Stub) + RxSwift

호르댕댕댕 2022. 4. 24. 00:55

기존 프로젝트를 다시 MVVM + RxSwift로 리팩토링하면서 다시 Networking 작업을 하고 있다. 

저번에는 네트워크와 관련된 테스트 코드를 짜지 못했었는데 이번에는 Stub을 이용해 네트워크에 연결되지 않는 상황에서 테스트를 할 수 있도록 구현했다. 

(일단 여기선 RxSwift만을 사용하고 기타 Moya나 Alamofire같은 네트워킹 관련 라이브러리는 사용하지 않았습니다)

 

여기서 사용하는 Stub은 테스트 더블이라 볼 수 있다. 즉, 테스트를 직접 진행하기 어려운 경우, 이를 대신해 테스트를 진행할 수 있는 객체를 따로 생성하는 것이다.

 

🤔 테스트 더블을 사용한 테스트 이유

그럼 네트워크 관련 테스트에서 Stub을 사용하는 이유는 뭘까?

  • 네트워크를 사용할 때보다 테스트 속도가 빠르다
  • 네트워크 연결이라는 예측이 불가능한 요소를 제거할 수 있다
  • 데이터를 불필요하게 등록하거나 삭제, 수정하는 일을 방지할 수 있다

일단 이번 프로젝트에선 이런 이유로 Stub을 사용해 테스트를 했다. 

 

🧪 테스트 방법

그렇다면 지금부터 Stub 객체를 어떻게 생성하고 이를 통해 어떻게 테스트를 진행했는지 알아보자! 

🌐 Network를 담당하는 객체 생성

일단 StubURLSession을 사용하든, 실제 URLSession을 사용하든 이를 사용할 수 있는 객체를 생성해줘야 한다.

struct NetworkProvider {
    private let session: URLSessionProtocol
    private let disposeBag = DisposeBag()
    
    init(session: URLSessionProtocol = URLSession.shared) {
        self.session = session
    }
    •••
    // 네트워킹 관련 처리 메서드 구현
    •••
 }

여기서 중요한 것은 URLSession을 갈아끼울 수 있도록 session 프로퍼티를 생성하고 해당 프로퍼티에는 URLSessionProtocol만 올 수 있도록 하는 것이다. 

protocol URLSessionProtocol {
    func dataTask(with request: URLRequest,
                  completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
}

extension URLSession: URLSessionProtocol { }

이렇게 URLSessionProtocol을 준수할 경우 반드시 dataTask 메서드를 구현하도록 프로토콜로 추상화를 해놓은 뒤 URLSession이 해당 프로토콜을 채택하도록 하여 기존 URLSession 또한 사용할 수 있도록 처리를 해주었다. 

 

그래서 위에서 생성한 NetworkProvider 객체를 생성할 때 실제 네트워킹 작업을 하면 URLSession.shared를 사용하도록 하고, Stub 객체를 활용한 테스트를 할 경우 따로 이를 사용할 수 있도록 구현하는 것이다. 

 

 

🤖 Stub 객체 생성 

그럼 이제 테스트 더블을 위한 Stub타입을 구현해보자. 

여기서 Stub은 URLSessionProtocol을 준수하고, dataTask(request:completionHandler:)를 구현해줘야 한다. 

이를 통해 NetworkProvider를 사용할 때 StubURLSession을 사용할 수 있도록 하는 것이다. 

 

일단 dataTask 메서드의 경우 URLSessionDataTask를 반환하기 때문에 이 또한 Stub을 위해 만들어줘야 한다. 

class StubURLSessionDataTask: URLSessionDataTask {
    var resumeDidCall: () -> Void = {}
    
    override func resume() {
        resumeDidCall()
    }
    
    override func cancel() {}
}

 

여기서 resume 메서드는 아래 코드에서처럼 URLSessionTask를 실행하는 메서드이다. 

여기선 단순히 어떤 파라미터도 받지 않고 Void를 return하는 아무 동작도 하지 않는 메서드로 구현을 해놓았다. 

 


🤯 여기서 발생한 문제

그리고 Rx를 사용하게 되면서 task.cancel을 하게 되었기 때문에 cancel 메서드도 override하여 어떤 동작도 하지 않도록 해주었다. 

cancel 메서드를 override해주지 않을 경우 다음과 같은 에러가 발생했다. 

Exception NSException * "-[OpenMarket_MVVM_Rx.StubURLSessionDataTask _onqueue_cancel]: unrecognized selector sent to instance 0x156f075f0" 0x00006000021327f0

unrecognized selector 에러는 보통 버튼을 제대로 연결해주지 않은 경우 발생하는 에러였는데, 여기서 발생하여 처음에는 StubURLSessionTask를 생성하는 과정에서 문제가 있나 생각을 했었다. 

 

실제로 URLSessionDataTask는 init이 iOS 13부터 deprecated되기도 했고 말이다. 

// URLSessionDataTask Jump To Definition
@available(iOS 7.0, *)
open class URLSessionDataTask : URLSessionTask {
	@available(iOS, introduced: 7.0, deprecated: 13.0, message: "Please use -[NSURLSession dataTaskWithRequest:] or other NSURLSession methods to create instances")
    public init()
    •••
}

하지만 항상 답은 에러 콘솔에 있었다... 

 

breakpoint를 찍으며 확인해본 결과 task.cancel이 호출되고 에러가 발생했다. 

 

cancel 내부는 애플이 구현해놓은 코드라 직접 확인할 수는 없었지만 이를 통해 StubURLSessionDataTask에서 호출할 수 없는 프로퍼티나 메서드를 호출했고 에러가 발생했다고 생각했다. 그래서 cancel 메서드를 재정의해주니 문제가 해결되었다.


그럼 계속 Stub 객체를 만드는 것에 대해 살펴보자. 

 

그 후 URLSessionProtocol을 준수하는 StubURLSession 타입을 구현해주었다. 

class StubURLSession: URLSessionProtocol {
    var isRequestSuccess: Bool
    var sessionDataTask: StubURLSessionDataTask?

    init(isRequestSuccess: Bool = true) {
        self.isRequestSuccess = isRequestSuccess
    }

    func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
        let sucessResponse = HTTPURLResponse(url: request.url!,
                                             statusCode: 200,
                                             httpVersion: "2",
                                             headerFields: nil)
        let failureResponse = HTTPURLResponse(url: request.url!,
                                              statusCode: 402,
                                              httpVersion: "2",
                                              headerFields: nil)

        let sessionDataTask = StubURLSessionDataTask()

        let dataString = #""OK""#
        let data = dataString.data(using: .utf8)

        if isRequestSuccess {
            sessionDataTask.resumeDidCall = {
                completionHandler(data, sucessResponse, nil)
            }
        } else {
            sessionDataTask.resumeDidCall = {
                completionHandler(nil, failureResponse, nil)
            }
        }
        self.sessionDataTask = sessionDataTask
        
        return sessionDataTask
    }
}

일단 네트워크 없이 테스트를 할 타입이기 때문에 request가 성공했는지, 실패했는지를 직접 적어줄 수 있도록 isRequestSuccess 프로퍼티를 구현해주었다. 

(해당 코드의 경우 우아한 형제들 기술 블로그를 참고했습니다)

 

그리고 성공 & 실패할 경우 응답도 미리 구현해서 요청에 따라 해당 응답을 내보내도록 했다. 

 

🧑🏻‍💻 Test 코드 작성하기!

그럼 마지막으로 지금까지 구현한 Stub 객체를 통해 어떻게 테스트를 했는지 알아보자. 

class StubNetworkProviderTests: XCTestCase {
    let stubSession: URLSessionProtocol! = StubURLSession()
    var sut: NetworkProvider!
    var disposeBag: DisposeBag!

    override func setUpWithError() throws {
        try super.setUpWithError()
        sut = NetworkProvider(session: stubSession)
        disposeBag = DisposeBag()
    }

    override func tearDownWithError() throws {
        try super.tearDownWithError()
        sut = nil
        disposeBag = nil
    }

    func test_getHealthChecker가_정상작동_하는지() {
        let observableData = sut.request(api: HealthCheckerAPI())
        _ = observableData.subscribe(onNext: { data in
                let resultString = String(data: data, encoding: .utf8)
                let successString = #""OK""#
                XCTAssertEqual(resultString, successString)
        }).disposed(by: disposeBag)
    }
    
    func test_getHealthChecker가_정상실패_하는지() {
        let expectation = XCTestExpectation(description: "getHealthChecker 비동기 테스트")
        sut = NetworkProvider(session: StubURLSession(isRequestSuccess: false))

        let observableData = sut.request(api: HealthCheckerAPI())
        _ = observableData.subscribe(onError: { error in
            let statusCodeError = NetworkError.statusCodeError
            XCTAssertEqual(error as? NetworkError, statusCodeError)
            expectation.fulfill()

        }).disposed(by: disposeBag)

        wait(for: [expectation], timeout: 10)
    }
}

일단 stubSession 객체를 생성하고 테스트 함수가 실행될 때 일전에 만들었던 NetworkProvider 객체를 생성하며 stubSession을 사용하도록 했고 끝나면 메모리에서 내려오도록 disposeBag을 생성했다. 

테스트가 끝나면 모두 nil을 할당하도록 해주었다. 

 

그 후 request한 상수를 subscribe해주어 해당 data를 확인해주었다. 

 

 

 

🙏 참고한 블로그 

- https://wody.tistory.com/10

 

[Swift] 네트워크와 무관한 URLSession Unit Test

안녕하세요 Wody 입니다. 오늘은 네트워크와 무관한 URLSession의 Unit Test에 대해 공부했습니다. 구글링을 해봤는데 대부분의 예제는 Alamofire를 이용하고 있어서 조금 어려웠는데 다른 분들께 도움이

wody.tistory.com

- https://techblog.woowahan.com/2704/

 

iOS Networking and Testing | 우아한형제들 기술블로그

{{item.name}} Why Networking? Networking은 요즘 앱에서 거의 필수적인 요소입니다. 설치되어 있는 앱들 중에 네트워킹을 사용하지 않는 앱은 거의 없을 겁니다. API 추가가 쉽고 변경이 용이한 네트워킹

techblog.woowahan.com

 

Comments