아리의 iOS 탐구생활

[RxSwift/Alamofire] Alamofire를 Mocking해서 네트워크와 무관한 테스트 하기 본문

Swift/iOS

[RxSwift/Alamofire] Alamofire를 Mocking해서 네트워크와 무관한 테스트 하기

Ari Lee 2022. 8. 14. 00:01
반응형

와.. 블로그글 너무 오랜만에 쓴다..

 

맨날 코드에 이리치이고... 저리치이며 바쁘게 살면서...혼자 메모장이나 TIL에 끄적거리다가...

'와, 이건 정말 꼭 포스팅해야해!!!' 라는 의욕이 솟구쳐올라서 작성하게 되었다.

다들 도움이 많이 되었으면 좋겠다는 마음으로... 열심히 적어봤다.

초보 개발자 화이팅!!!!!!!

 

 

 

📚 글을 읽기 전에 필요한 개발 지식

  • URLSession
  • 의존성 주입(DI)
  • Alamofire
  • RxSwift (조금만 알아도 되요... 비중이 적음.)

 

 

 

 

 

유닛 테스트를 할 때, 라이브러리인 Alamofire는 어떻게 테스트하면 좋을까?

검색했더니 URLProtocol 타입을 이용해서, 네트워크 요청 결과를 Mocking해서 테스트를 하는 것 같다.

 

그렇다면 나도 도전...!!!

 

먼저 Alamofire가 주입 가능한 형태인지 살펴봐야 한다.

 

/// `Session` creates and manages Alamofire's `Request` types during their lifetimes. It also provides common
/// functionality for all `Request`s, including queuing, interception, trust management, redirect handling, and response
/// cache handling.
open class Session {
    /// Shared singleton instance used by all `AF.request` APIs. Cannot be modified.
    public static let `default` = Session()
    /// Creates a `Session` from a `URLSessionConfiguration`.

위를 살펴보면 Alamofire는 Seesion이라는 static 변수를 사용하여 네트워크를 요청한다.
이 Session이라는 친구는 URLSessionConfiguration를 사용해서 요청한다.
URLSessionConfiguration는 protocolClasses라는 속성이 존재하는데, 이것을 통해서 의존성 주입을 시도해볼 수 있다.

 

  • URLSessionConfiguration
    • URLSession을 사용하여 데이터를 업로드/다운로드할 때 사용할 행동/정책을 정의한다.
    • 데이터를 업로드/다운로드할 때 항상 configuration 객체를 만드는 것이 우선시되어야 한다.

 

	let session: Session = {
            let configuration: URLSessionConfiguration = {
                let configuration = URLSessionConfiguration.default
                
                // 여기다 가짜 URLProtocol을 주입시키는 것!
                configuration.protocolClasses = [MockURLProtocol.self] 
                
                
                return configuration
            }()
            return Session(configuration: configuration)
        }()

자, 그렇다면 Alamofire가 의존성을 주입할 수 있다는 사실을 알았으니… 가짜 URLProtocol을 만들어야 한다.

 

근데 이게 뭘까…?

 

🤔 URLProtocol은 무엇인가...?

WWDC 2018 Testing Tips & Tricks

  • URL 데이터 로딩을 다루는 추상클래스이다.
  • 네트워크 연결을 열고, request를 쓰고, response를 주는 역할을 한다.
  • 보통은 단독으로 쓰이지 않고 상속을 받도록 설계되어져있다.
  • 그래서 URL 로딩 시스템을 확장할 수 있게 해준다.

따라서 URLProtocol을 사용하면 Mock 객체를 만들고, Mock Response를 만들어서 테스트를 진행할 수 있게 된다!

그렇다면 이제 URLProtocol을 Mocking 하기 위해서 하나씩 살펴보며 구현해보자!

 

final class MockURLProtocol: URLProtocol {

    enum ResponseType {
        case error(Error)
        case success(HTTPURLResponse)
    }
    
    static var responseType: ResponseType!
    static var dtoType: MockDTOType!

}

extension MockURLProtocol {
    
    enum MockError: Error {
        case none
    }
    
    static func responseWithFailure() {
        MockURLProtocol.responseType = MockURLProtocol.ResponseType.error(MockError.none)
    }
    
    static func responseWithStatusCode(code: Int) {
        MockURLProtocol.responseType = MockURLProtocol.ResponseType.success(HTTPURLResponse(url: URL(string: "http://any.com")!, statusCode: code, httpVersion: nil, headerFields: nil)!)
    }
    
    static func responseWithDTO(type: MockDTOType) {
        MockURLProtocol.dtoType = type
    }
}

extension MockURLProtocol {
    
    enum MockDTOType {
        case search
        case starred
        case user
        case empty
        case oauth
        case oauthBadRequest
        case oauthRedirectURLMismatch
        case oauthIncorrectClientCredentials
        
        var fileName: String {
            switch self {
            case .search: return "GithubAPI_Response_Search.json"
            case .starred: return "GithubAPI_Response_Starred.json"
            case .user: return "GithubAPI_Response_User.json"
            case .empty: return ""
            case .oauth: return  "GithubOAuth_Response.json"
            case .oauthBadRequest: return "GithubOAuth_BedVerificationCode.json"
            case .oauthRedirectURLMismatch: return "GithubOAuth_RedirectURIMismatch.json"
            case .oauthIncorrectClientCredentials: return "GithubOAuth_IncorrectClientCredentials.json"
            }
        }
    }
    
}

먼저 나는 가짜 데이터를 심어주기 위해서 MockURLProtocol 내부에 위와 같은 코드를 심어주었다.
저걸 이용해서 가짜 리스폰스, 가짜 DTO를 넣어줄 것이다.

 

그리고!!!

 

URLProtocol를 서브클래싱 하려면 필수 메서드를 오버라이드 해야한다. 하나씩 천천히 구현해보자 ㅎㅎ

 

    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }

우선 이 녀석은 프로토콜이 파라미터로 전달받은 요청을 처리할 수 있는지 여부를 결정하는 메소드다.
우리는 가짜로 네트워크 테스트를 할거니까 무조건 처리할 수 있다고 true로 설정해준다.

 

 

 

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

이 canonicalRequest 메소드는 표준 버전의 URLRequest를 반환하는 메소드이다.
표준 버전…? 이게 뭐야? 의미하는 바를 정확하게는 뭔지… 모르겠다.
가짜니까 표준이고 뭐고 그냥 전달받은 파라미터를 그대로 반환한다.

 

 

 

    override class func requestIsCacheEquivalent(_ a: URLRequest, to b: URLRequest) -> Bool {
        return false
    }

캐시 처리 목적으로 두 요청이 동일한지 여부를 나타내는 메소드이다.
캐싱이 필요한 순간은 아니니까 false를 무조건 반환하도록 구현한다.

 

 

 

    private lazy var session: URLSession = {
        let configuration: URLSessionConfiguration = URLSessionConfiguration.ephemeral
        return URLSession(configuration: configuration)
    }()
    
    private(set) var activeTask: URLSessionTask?

    override func startLoading() {
        let response = setUpMockResponse()
        let data = setUpMockData()
        
        // 가짜 리스폰스 내보내기
        client?.urlProtocol(self, didReceive: response!, cacheStoragePolicy: .notAllowed)
        
        // 가짜 데이터 내보내기
        client?.urlProtocol(self, didLoad: data!)
        
        // 로드 끝났어!!!!
        self.client?.urlProtocolDidFinishLoading(self)
        activeTask = session.dataTask(with: request.urlRequest!)
        activeTask?.cancel()
    }

    private func setUpMockResponse() -> HTTPURLResponse? {
        var response: HTTPURLResponse?
        switch MockURLProtocol.responseType {
        case .error(let error)?:
            client?.urlProtocol(self, didFailWithError: error)
        case .success(let newResponse)?:
            response = newResponse
        default:
            fatalError("No fake responses found.")
        }
        return response!
    }
    
    private func setUpMockData() -> Data? {
        let fileName: String = MockURLProtocol.dtoType.fileName

	   // 번들에 있는 json 파일로 Data 객체를 뽑아내는 과정.
        guard let file = Bundle.main.url(forResource: fileName, withExtension: nil) else {
            return Data() 
        }
        return try? Data(contentsOf: file)
    }

대망의 startLoading 메소드다.
요청에 대해서 피드백을 해주는 공간이라고 보면 된다.
우리는 가짜 데이터를 반환하도록 해줄거니까, 가짜 데이터를 내보낼 수 있도록 코드를 짜줘야 한다.
나같은 경우는 세션을 비공개 세션으로 만들어서 그걸 통해 dataTask를 실행하도록 구현해주었다.

 

  • Ephemeral session
    • 델리게이트 없이 비공개(private) 세션
    • Delegate없이 비공개 세션을 만들 때 사용한다.
    • 사파리나 크롬에서 시크릿 모드를 만들 때 사용한다.
    • 쿠키나 세션정보가 남아있지 않게 된다.

 

진짜 네트워크라면 요청을 하고, 기다리게 되지만 우리는 빨리 Response를 받고싶기 때문에 task를 시작하자마자 cancel 해준다.

 

 

 

    override func stopLoading() {
        activeTask?.cancel()
    }

마지막으로 stopLoading을 오버라이드 해주면 MockURLProtocol 완성!!!

 

자 그러면 테스트에 들어가보자!!!

 

 

그 전에 우선 내가 테스트를 진행할 객체는 이렇게 생겼다.

import Foundation
import Alamofire
import RxSwift

struct DefaultAPIProvider: APIProvider {
    
    func request<T: APIRequest>(_ request: T) -> Single<T.Response> {
        return Single.create { single in
            AF.request(request)
                .validate(statusCode: 200...299)
                .responseDecodable(of: T.Response.self) { data in
                switch data.result {
                case .success(let response):
                    single(.success(response))
                case .failure(let error):
                    single(.failure(error))
                }
            }
            return Disposables.create()
        }
    }
}

 

근데 의존성 주입이 안되어있네? 바로 해주자!

 

import Foundation
import Alamofire
import RxSwift

struct DefaultAPIProvider: APIProvider {
    
    private let session: Session
    
    init(session: Session = Session.default) {
        self.session = session
    }
    
    func request<T: APIRequest>(_ request: T) -> Single<T.Response> {
        return Single.create { single in
            session.request(request)
                .validate(statusCode: 200...299) // responseCode 검사...
                .responseDecodable(of: T.Response.self) { data in // 디코딩까지한다~~~
                switch data.result {
                case .success(let response):
                    single(.success(response))
                case .failure(let error):
                    single(.failure(error))
                }
            }
            return Disposables.create()
        }
    }
}

 

이렇게 session이라는 속성값을 정의하고 이니셜라이저를 통해 주입받도록 코드를 수정해주었다.

 

 

자… 이제 진짜로 테스트 작성해보러 가볼까…?

 

import XCTest
import RxSwift
import Alamofire
@testable import MyApp

class APIProviderTests: XCTestCase {

    var sut: APIProvider!
    var disposeBag: DisposeBag!
    
    override func setUp() {
        super.setUp()
        let session: Session = { // 가짜다!! 이눔아!!!
            let configuration: URLSessionConfiguration = {
                let configuration = URLSessionConfiguration.default
                configuration.protocolClasses = [MockURLProtocol.self] // 내가만든 가짜 프로토콜 주입!
                return configuration
            }()
            return Session(configuration: configuration)
        }()
        sut = DefaultAPIProvider(session: session) // 내가 만든 네트워크 타입에도 주입!
        disposeBag = DisposeBag()
    }
    
    override func tearDown() {
        super.tearDown()
        sut = nil
        disposeBag = nil
    }
}

가짜 session을 정의해서 내가 만든 네트워크 타입에 주입시켜준다.
그리고 RxSwift를 사용해서 테스트를 진행할거니깐 DisposeBag도 초기화해주도록 한다.

 

 

 

	func test_request_responseCode_200() {
        // given
        MockURLProtocol.responseWithDTO(type: .search)
        MockURLProtocol.responseWithStatusCode(code: 200)
        let request = SearchRequest(keyword: "1", page: 1, perPage: 1)
        let expectation = XCTestExpectation(description: "Performs a request")
                
        // when
        sut.request(request)
            .subscribe { event in
                switch event {
                case .success(let response):
                
        	    // then
                    XCTAssertEqual(response.items.count, 8)
                    XCTAssertEqual(response.items.first?.name, "ACNH-wiki")
                    XCTAssertEqual(response.items.first?.owner.login, "leeari95")
                    expectation.fulfill()
                case .failure(let error):
                    debugPrint(error)
                    XCTFail()
                }
            }.disposed(by: disposeBag)
        
        wait(for: [expectation], timeout: 5)

그리고 앞서 구현해두었던 MockURLProtocol을 이용해서 가짜 데이터를 설정해주고
RxSwift를 활용해서 비동기 테스트를 진행해주면 된다.

 

네트워크 연결과 무관하게 아름답게 작동한다…굿

 

이걸로 Alamofire를 Mocking해서 유닛 테스트를 진행하는 방법을 마치겠다.

 

테스트 코드는 통과할 때마다 너무 짜릿해서 재밌다!!! 성취감 미쳐~~~~ (커버리지 채우는 재미도 쏠쏠...)

 

💻 Full Code

더보기
import Foundation

final class MockURLProtocol: URLProtocol {
    
    enum ResponseType {
        case error(Error)
        case success(HTTPURLResponse)
    }
    
    static var responseType: ResponseType!
    static var dtoType: MockDTOType!
    
    private lazy var session: URLSession = {
        let configuration: URLSessionConfiguration = URLSessionConfiguration.ephemeral
        return URLSession(configuration: configuration)
    }()
    
    private(set) var activeTask: URLSessionTask?
    
    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }
    
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }
    
    override class func requestIsCacheEquivalent(_ a: URLRequest, to b: URLRequest) -> Bool {
        return false
    }
    
    override func startLoading() {
        let response = setUpMockResponse()
        let data = setUpMockData()
        
        // 가짜 리스폰스 내보내기
        client?.urlProtocol(self, didReceive: response!, cacheStoragePolicy: .notAllowed)
        
        // 가짜 데이터 내보내기
        client?.urlProtocol(self, didLoad: data!)
        
        // 로드 끝났어!!!!
        self.client?.urlProtocolDidFinishLoading(self)
        activeTask = session.dataTask(with: request.urlRequest!)
        activeTask?.cancel()
    }

    
    private func setUpMockResponse() -> HTTPURLResponse? {
        var response: HTTPURLResponse?
        switch MockURLProtocol.responseType {
        case .error(let error)?:
            client?.urlProtocol(self, didFailWithError: error)
        case .success(let newResponse)?:
            response = newResponse
        default:
            fatalError("No fake responses found.")
        }
        return response!
    }
    
    private func setUpMockData() -> Data? {
        let fileName: String = MockURLProtocol.dtoType.fileName

	   // 번들에 있는 json 파일로 Data 객체를 뽑아내는 과정.
        guard let file = Bundle.main.url(forResource: fileName, withExtension: nil) else {
            return Data() 
        }
        return try? Data(contentsOf: file)
    }
    
    override func stopLoading() {
        activeTask?.cancel()
    }
}

extension MockURLProtocol {
    
    enum MockError: Error {
        case none
    }
    
    static func responseWithFailure() {
        MockURLProtocol.responseType = MockURLProtocol.ResponseType.error(MockError.none)
    }
    
    static func responseWithStatusCode(code: Int) {
        MockURLProtocol.responseType = MockURLProtocol.ResponseType.success(HTTPURLResponse(url: URL(string: "http://any.com")!, statusCode: code, httpVersion: nil, headerFields: nil)!)
    }
    
    static func responseWithDTO(type: MockDTOType) {
        MockURLProtocol.dtoType = type
    }
}

extension MockURLProtocol {
    
    enum MockDTOType {
        case search
        case starred
        case user
        case empty
        case oauth
        case oauthBadRequest
        case oauthRedirectURLMismatch
        case oauthIncorrectClientCredentials
        
        var fileName: String {
            switch self {
            case .search: return "GithubAPI_Response_Search.json"
            case .starred: return "GithubAPI_Response_Starred.json"
            case .user: return "GithubAPI_Response_User.json"
            case .empty: return ""
            case .oauth: return  "GithubOAuth_Response.json"
            case .oauthBadRequest: return "GithubOAuth_BedVerificationCode.json"
            case .oauthRedirectURLMismatch: return "GithubOAuth_RedirectURIMismatch.json"
            case .oauthIncorrectClientCredentials: return "GithubOAuth_IncorrectClientCredentials.json"
            }
        }
    }
    
    
}

 

더보기
import Foundation
import Alamofire
import RxSwift

struct DefaultAPIProvider: APIProvider {
    
    private let session: Session
    
    // 의존성 주입
    init(session: Session = Session.default) {
        self.session = session
    }
    
    func request<T: APIRequest>(_ request: T) -> Single<T.Response> {
        return Single.create { single in
            session.request(request)
                .validate(statusCode: 200...299) // responseCode 검사...
                .responseDecodable(of: T.Response.self) { data in // 디코딩까지한다~~~
                switch data.result {
                case .success(let response):
                    single(.success(response))
                case .failure(let error):
                    single(.failure(error))
                }
            }
            return Disposables.create()
        }
    }
}

 

더보기
import XCTest
import RxSwift
import Alamofire
@testable import MyApp

class APIProviderTests: XCTestCase {

    var sut: APIProvider!
    var disposeBag: DisposeBag!
    
    override func setUp() {
        super.setUp()
        let session: Session = {
            let configuration: URLSessionConfiguration = {
                let configuration = URLSessionConfiguration.default
                configuration.protocolClasses = [MockURLProtocol.self]
                return configuration
            }()
            return Session(configuration: configuration)
        }()
        sut = DefaultAPIProvider(session: session)
        disposeBag = DisposeBag()
    }
    
    override func tearDown() {
        super.tearDown()
        sut = nil
        disposeBag = nil
    }
    
    func test_request_responseCode_200() {
        // given
        MockURLProtocol.responseWithDTO(type: .search)
        MockURLProtocol.responseWithStatusCode(code: 200)
        let request = SearchRequest(keyword: "1", page: 1, perPage: 1)
        let expectation = XCTestExpectation(description: "Performs a request")
                
        // when
        sut.request(request)
            .subscribe { event in
                switch event {
                case .success(let response):
                
                    // then
                    XCTAssertEqual(response.items.count, 8)
                    XCTAssertEqual(response.items.first?.name, "ACNH-wiki")
                    XCTAssertEqual(response.items.first?.owner.login, "leeari95")
                    expectation.fulfill()
                case .failure(let error):
                    debugPrint(error)
                    XCTFail()
                }
            }.disposed(by: disposeBag)
        
        wait(for: [expectation], timeout: 5)
    }
}

 

 

Reference

 

Apple Developer Documentation

 

developer.apple.com

 

Mocking Alamofire ~ Baking Swift: A blog post about Swift development

When using third party libraries, it can be challenging writing test code to allow mocking. Especially for code that includes networking, you do not want to test your network connection or your backend service. You want to test your logic around the networ

jeroenscode.com

 

Testing Tips & Tricks - WWDC18 - Videos - Apple Developer

Testing is an essential tool to consistently verify your code works correctly, but often your code has dependencies that are out of your...

developer.apple.com

 

Unit Test 작성하기 - 야곰닷넷

Unit Test 작성하기 테스트를 작성하면 좋다는 말을 많이 들어보셨을 것 같습니다.  하지만 막상 테스트를 작성해보려고 […]

yagom.net

 

반응형
Comments