[iOS] 네트워크에 의존하지 않는 Test

시작하며

REST API를 사용한 프로젝트를 구현하며, 비즈니스 로직을 짜며 해당 로직에 대해서 어떻게 Test를 할지 고민하며, 찾은 자료들을 기록하려고 한다


Network에 의존하지 않는 테스트?

효율적이고 좋은 테스트의 조건인 F.I.R.S.T 중 하나인 Repeatable 은 반복되는 테스트 속에서 같은 결과를 나타냄을 뜻한다. 하지만 Network 와 같이 외부 의존성을 가진 객체에 대한 테스트는 쉽지 않다. Network 에 문제가 생기거나 인터넷 disconnecting 과 같은 문제들로 테스트 결과가 바뀔 수 있기 때문이다

그럼 우리는 테스트를 어떻게 진행하면 좋을까??

Test Doubles (테스트 더블)

앞의 이런 문제들을 겪은 다른 개발자들은 이러한 문제를 해결하기 위해서테스트 더블이라는 개념을 만들었다.

Double [dú:bǝl]
1. [중성형 명사] 영화 스턴트맨, 대역자.
2. [중성형 명사] 구별할 수 없을 정도로 닮은 사람.
3. [중성형 명사] 음악 (17-18세기의) 변주곡의 일종.

Double 은 독일어로 대역자, 스턴트맨으로 번역된다. 즉, 우리는 외부 의존성을 다른 객체를 통해서 대체하는 방법을 통해 위 문제를 해결해야만 한다. 네트워크를 하는 객체를 네트워킹 하는 척하는 객체로 바꾸듯이 말이다

그럼 바로 Test Doubles 를 활용한 Network에 의존하지 않는 테스트를 진행해보자!!

1. URLSessionProtocol 활용

해당 방법은 우아한 형제들 기술블로그 - iOS Networking and Tests 를 통해서 배우게 되었다

먼저 모두들 HTTP 통신을 하기 위해서 URLSessiondataTask(with:) 사용해봤을 것이다.
그리고, Alamofire 를 활용하지 않았다면 NetworkManager 와 같은 타입을 만들어 URLSessiondataTask(with:)Response 에 대한 콜백 함수를 정의하였을 것이다. 아래 처럼!

class NetworkManager {

    let session: URLSession
    init(session: URLSession = .shared) {
        self.session = session
    }

    func fetchRandomJoke(completion: @escaping (Result<Joke, Error>) -> Void) {
        let request = URLRequest(url: JokesAPI.randomJokes.url)

        let task: URLSessionDataTask = session
            .dataTask(with: request) { data, urlResponse, error in
                guard let response = urlResponse as? HTTPURLResponse,
                      (200...399).contains(response.statusCode) else {
                    completion(.failure(error ?? APIError.unknownError))
                    return
                }

                if let data = data,
                    let jokeResponse = try? JSONDecoder().decode(JokeReponse.self, from: data) {
                    completion(.success(jokeResponse.value))
                    return
                }
                completion(.failure(APIError.unknownError))
        }

        task.resume()
    }
}

이제 그럼 URLSession 을 대신할 Test Doubles 를 생성해보자!

MockURLSession

일단 기존의 URLSession 을 추상화시켜주기 위해 Protocol 타입을 하나 생성해보자!

URLSession 의 dataTask(with:) 와 동일한 시그니쳐의 메서드를 선언하고, URLSession 이 프로토콜을 구현하도록 정의만 추가합니다.

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

extension URLSession: URLSessionProtocol {

}

이후 NetworkManager 를 아래와 같이 변경시켜 줍니다

class NetworkManager {
    let session: URLSessionProtocol
    init(session: URLSessionProtocol = URLSession.shared) {
        self.session = session
    }
    //,,,
}

이렇게 되면 URLSessionProtocol 을 채택한URLSession.shared 를 기본으로 초기화하면서, 필요시에는 URLSessionProtocol를 채택한 다른 타입을 주입받을 수 있게 된다

이제 본격적으로 Test Doubles 를 작성해보자!

class MockURLSession: URLSessionProtocol {

    var makeRequestFail = false
    init(makeRequestFail: Bool = false) {
        self.makeRequestFail = makeRequestFail
    }

    var sessionDataTask: MockURLSessionDataTask?

    // dataTask 를 구현합니다.
    func dataTask(with request: URLRequest, 
                  completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {

        // 성공시 callback 으로 넘겨줄 response
        let successResponse = HTTPURLResponse(url: JokesAPI.randomJokes.url,
                                              statusCode: 200, 
                                              httpVersion: "2",
                                              headerFields: nil)
        // 실패시 callback 으로 넘겨줄 response
        let failureResponse = HTTPURLResponse(url: JokesAPI.randomJokes.url,
                                              statusCode: 410, 
                                              httpVersion: "2", 
                                              headerFields: nil)

        let sessionDataTask = MockURLSessionDataTask()

        // resume() 이 호출되면 completionHandler() 가 호출되도록 합니다.
        sessionDataTask.resumeDidCall = {
            if self.makeRequestFail {
                completionHandler(nil, failureResponse, nil)
            } else {
                completionHandler(JokesAPI.randomJokes.sampleData, successResponse, nil)
            }
        }
        self.sessionDataTask = sessionDataTask
        return sessionDataTask
    }
}
class MockURLSessionDataTask: URLSessionDataTask {
    override init() 
    var resumeDidCall

    override func resume() {
        resumeDidCall()
    }
}

자, 이제 우리는 MockURLSessionmakeRequestFail 프로퍼티를 Flag로 사용하여 실패하는 Networking을 만들 것인지, 성공하는 Networking을 만들 것인지 MockURLSession 인스턴스화 과정에서 정할 수 있다. 각 테스트 명세마다 다 다르게 할 수 있다는 말이다!!

테스트 진행

class NetworkManagerTests: XCTestCase {

    var sut: NetworkManager!

    override func setUpWithError() throws {
        sut = .init(session: MockURLSession())
    }

    func test_fetchRandomJoke() {
        let expectation = XCTestExpectation()
        let response = try? JSONDecoder().decode(JokeReponse.self, 
                                                 from: JokesAPI.randomJokes.sampleData)

        sut.fetchRandomJoke { result in
            switch result {
            case .success(let joke):
                XCTAssertEqual(joke.id, response?.value.id)
                XCTAssertEqual(joke.joke, response?.value.joke)
            case .failure:
                XCTFail()
            }
            expectation.fulfill()
        }

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

    func test_fetchRandomJoke_failure() {
        sut = .init(session: MockURLSession(makeRequestFail: true))
        let expectation = XCTestExpectation()

        sut.fetchRandomJoke { result in
            switch result {
            case .success:
                XCTFail()
            case .failure(let error):
                XCTAssertEqual(error.localizedDescription, "unknownError")
            }
            expectation.fulfill()
        }

        wait(for: [expectation], timeout: 2.0)
    }
}
  • sutsystem under test의 줄임말로, 테스트를 진행할 대상을 뜻한다. 우리는 NetworkManager를 테스트할 예정이기에 sut 변수에 NetworkManager 인스턴스를 생성했다
  • XCTestExpectation() 의 경우 URLSession이 비동기적으로 작동하는 특성 때문에, expectation을 생성하고 expectation이 만족될 때까지 테스트 메서드가 끝나지 않도록 하기 위해 선언하였다
  • sut를 초기화하는 과정에서 실패하는 네트워킹 과정을 의도적으로 만들기 위해서 sessionMockURLSession(makeRequestFail: true)를 넣어주는 것을 볼 수 있다
  • 우리가 의도한 결과는 failure 이기 때문에, result 타입이 Error 가 맞는지 비교해주는 XCTAssertEqual(_,_)를 통해서 테스트를 구현한 것을 볼 수 잇다


2. URLProtocol 활용

캠프 중 많은 리뷰어에게 문의를 해봤는데 아직까지는 위 처럼 MockURLSessionProtocol을 활용해서 테스트를 진행하는 경우가 많다고는 한다. 하지만 URLSessionDataTaskinit 생성자가 iOS13 부터 Deprecated 되기 때문에 해당 방법은 대안을 찾아야만 한다

AppleWWDC2018 Testing Tips and Tricks에서 URLProtocol을 사용해 네트워크 요청을 가로채는 방법을 소개하였는데, 더 이상 기존의 위에서 접근한 방식을 사용하지 않아도 된다.

WWDC 영상 속Networking 과정을 간단하게 보면 위에서 거쳤던 과정과 같은 것을 볼 수 있다.

해당 장면에서 URLProtocol subclasses를 설명할 때, 사회자는 이러한 말을 하는데

"Behind the scenes though, there's another lower-level API URLProtocol which performs the underlying work of opening network connection, writing the request, and reading back a response.
URLProtocol is designed to be subclassed giving an extensibility point for the URL loading system."

이는 URLProtocol이 URL loading system에 대한 확장성을 가지도록 고안되어 있다는 내용이다

URLProtocol 은 실제 추상(abstract)클래스로 프로토콜별 URL 데이터 load를 처리한다.

import Foundation

class MockURLProtocol: URLProtocol {
  // request를 받아서 mock response(HTTPURLResponse, Data?)를 넘겨주는 클로저 생성
  static var requestHandler: ((URLRequest) -> (HTTPURLResponse?, Data?, Error?))?
  
  // 매개변수로 받는 request를 처리할 수 있는 프로토콜 타입인지 검사하는 함수
  override class func canInit(with request: URLRequest) -> Bool {
    return true
  }
  
  // Request를 canonical(표준)버전으로 반환하는데,
  // 거의 매개변수로 받은 request를 그래도 반환
  override class func canonicalRequest(for request: URLRequest) -> URLRequest {
    return request
  }
  
  // test case별로 mock response를 생성하고,
  // URLProtocolClient로 해당 response를 보내는 부분
  override func startLoading() {
    guard let handler = MockURLProtocol.requestHandler else {
      fatalError()
    }
    
    // request를 매개변수로 전달하여 handler를 호출하고, 반환되는 response와 data, error를 저장
    let (response, data, error) = handler(request)
    
    // 저장한 error를 client에게 전달
    if let error = error {
      client?.urlProtocol(self, didFailWithError: error)
    }
    
    // 저장한 response를 client에게 전달
    if let response = response {
      client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
    }
    
    // 저장한 data를 client에게 전달
    if let data = data {
      client?.urlProtocol(self, didLoad: data)
    }
    
    // request가 완료되었음을 알린다
    client?.urlProtocolDidFinishLoading(self)
  }
  
  override func stopLoading() {
    // request가 취소되거나 완료되었을 때 호출되는 부분
  }
}

request 요청을 시작하면, 시스템 내부적으로 해당 request를 처리할 수 있는 등록되어 있는 프로토콜 클래스가 있는지 검사하고, 만약 클래스가 있다면 네트워크 작업을 완료할 책임을 해당 클래스에게 부여한다

따라서 여기서 network layer 를 가로챌 수 있다. URLProtocolextension 하여 mock 클래스를 생성하고, 유닛 테스트에 필요한 모든 메서드를 재정의하면 된다.

따라서 아래와 같이 startLoading() 메서드가 재정의한다.

그래서 이렇게 우리가 원하는대로 움직일 URL Loading System을 Custom해서 이걸 URLSession에 적용시켜주자!!

let configuration = URLSessionConfiguration.default
configuration.protocolClasses = [MockURLProtocol.self]
let urlSession = URLSession.init(configuration: configuration)
    
openMarketProvider = OpenMarketAPIProvider(session: urlSession)
expectation = XCTestExpectation()

여기서 API에 통신을 할 객체에 무조건 session을 주입시켜 줘야된다!!!
그래야 이렇게 Mock으로 작성한 URLProtocol SubClass를 적용한 URLSession을 적용시켜줄 수 있다!!

참고자료

Unit Testing URLSession using URLProtocol
WWDC 2018 - Testing Tips & Tricks
URLProtocol
Jake Yeon - Unit Test Networking

좋은 웹페이지 즐겨찾기