iOS Networking and Testing

Dec.20.2020 조원

APP QA/Testing

Why Networking?

Networking은 요즘 앱에서 거의 필수적인 요소입니다.
설치되어 있는 앱들 중에 네트워킹을 사용하지 않는 앱은 거의 없을 겁니다.
API 추가가 쉽고 변경이 용이한 네트워킹 모듈을 개발하는것이 중요한 이유죠.

Why Testing?

미드 <빅뱅이론> 보셨나요?
쉘든 쿠퍼 라는 주인공은 16살에 박사학위를 취득할 만큼 천재이면서 동시에 대단히 특이한 사람입니다. 주변에서 "Are you crazy?" 라는 말을 자주 듣습니다.

그때 마다 쉘든이 하는 말이 있죠.

  • 쉘든은 자기가 미치지 않았다는 걸 어떻게 알았을까요? 엄마와 함께 병원에 가서 테스트를 했거든요.
  • 우리의 코드가 틀리지 않았다는 걸 어떻게 알 수 있을까요? 우리의 코드는 테스트를 통과했으니까요. ✅

가장 기본적인 방식부터 시작해 만화경 iOS App에서 사용하고 있는 네트워킹 방식에 대해서 공유해 볼까 합니다. 테스트에 중점을 두겠습니다.

iOS에서 네트워킹은 기본적으로 URLSession 을 사용합니다. 프로젝트의 복잡도가 높아지면 조금 더 추상화 되고 간편한 방식이 필요하게 되죠. 이때 많이들 사용하는 라이브러리가 Alamofire 입니다. AlamofireURLSession 위에 구현되었습니다. 그 기반은 여전히 URLSession 입니다. 개발하다 보면 boilderplate 코드가 거슬리게 되고 Alamofire 위에 또 다른 추상화에 대한 욕구를 느끼게 됩니다. Alamofire를 한번 더 추상화하여 구현된 라이브러리가 바로 Moya 입니다. Moya는 사용하기 쉽고 테스트하기 쉽습니다.

URLSession 부터 시작해 간단한 구현과 테스트 방식을 설명하고 만화경에서 Moya를 어떻게 사용하는지 공유하려고 합니다.

  • 테스트를 위해 사용할 API 는 http://www.icndb.com/api 입니다. Chuck Norris 스타일의 조크를 DB로 만든 서비스 입니다. 웃기기 때문에… 지루하면 웃고 넘길 수 있지 않을까요? 처음 들어보시는 분은 구글링 해보세요 – "척 노리스의 사실"
  • 예를 들면 이런 스타일의 유머 입니다. "사실: 척 노리스는 독서를 하지 않는다. 그가 책을 째려보면 책들이 알아서 내용을 불게 된다."

그럼 지금부터 시작해 보겠습니다.

URLSession API 는 iOS 에서 네트워킹을 구현하는 가장 기본적인 방법입니다.

An object that coordinates a group of related, network data-transfer tasks.

  • URLSessionTask 는 서버에 데이터를 요청하는 하나의 Task를 표현합니다.
  • URLSessiondataTask(with:) 메서드를 사용해 URLSessionTask 를 생성할 수 있습니다.
  • http://api.icndb.com/jokes/random 으로 request에 대한 response는 다음과 같은 json 입니다.
$ curl "http://api.icndb.com/jokes/random" | python3 -m json.tool
...
{
   "type": "success",
       "value": {
       "id": 448,
       "joke": "When Chuck Norris throws exceptions, it's across the room.",
       "categories": []
   }
}
  • json 을 표현하는 Swift struct를 작성합니다. json 을 표현하기 위해 Decodable Type을 사용합니다.
struct JokeReponse: Decodable {
    let type: String
    let value: Joke
}

struct Joke: Decodable {
    let id: Int
    let joke: String
    let categories: [String]
}
  • 간단한 방식으로 API provider를 구현해 보겠습니다. URLSession.dataTask(with:) 는 completion callback에서 결과를 처리 할 수도 있고 URLSessionDataDelegate 를 사용할 수도 있습니다. 예제에서는 callback 을 사용하겠습니다.
enum JokesAPI {
    case randomJokes

    static let baseURL = "https://api.icndb.com/"
    var path
    var url
}

enum APIError: LocalizedError {
    case unknownError
    var errorDescription
}

class JokesAPIProvider {

    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()
    }
}

URLSessiondataTask(with:) 를 이용해 URLSessionDataTask 를 생성한 후 task.resume() 메서드를 호출해 요청을 보냅니다. resume() 외에도 suspend(), cancel() 등의 메서드가 존재하고 필요에 따라 사용할 수 있습니다.

  • 결과를 확인해 보겠습니다.

JokesAPIProvider에 대한 테스트를 작성해 보겠습니다.

fetchRandomJoke(completion:)을 테스트를 하는 여러가지 방법이 있을 수 있습니다.

  • 실제 네트워크를 호출하고 그 결과를 테스트
  • Swift protocol을 사용해 MockURLSession 만들고 이를 이용한 테스트

첫 번째 방식은 실제 네트워크 호출을 해야 하기 때문에 느리고 네트워크 상황에 따라 결과가 달라질 수 있습니다. 테스트 기기에 테스트만을 위한 서버를 세팅할 수도 있습니다. 혹은 OHHTTPStubs 를 사용할 수도 있죠. 제가 원했던것은 빠르고 간단한 unit test 이기 때문에 이 방법은 패스 합니다.

두 번째 방식으로 URLSession 을 테스트 해보겠습니다.

  • URLSessiondataTask(with:) 와 동일한 시그니쳐의 메서드를 선언합니다. 그리고 URLSession 이 프로토콜을 구현하도록 정의만 추가합니다.
protocol URLSessionProtocol {
    func dataTask(with request: URLRequest, 
                  completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
}
extension URLSession
  • JokesAPIProviderURLSession 생성자 주입시 프로토콜타입으로 받도록 합니다.
class JokesAPIProvider {
    ...
    let session: URLSessionProtocol

    init(session: URLSessionProtocol = .shared) {
        self.session = session
    }
    ...
}
  • 테스트를 위한 sampleData 를 선언해 줍니다. json파일을 사용하도록 할 수도 있겠네요.

enum JokesAPI {
    ... 
    var sampleData: Data {
        Data(
            """
            {
                "type": "success",
                    "value": {
                    "id": 459,
                    "joke": "Chuck Norris can solve the Towers of Hanoi in one move.",
                    "categories": []
                }
            }
            """.utf8
        )
    }
}

이제 MockURLSessionMockURLSessionDataTask를 구현하고 테스트를 작성하면 됩니다.

  • MockURLSessionTest Double 이라고 합니다.
  • Test Double은 테스트 할 때 production object 를 대신합니다.
  • Test Double 에는 Mock, Stub, Spy, Fake, Dummy 등이 있습니다. 자세한 내용은 여기 를 참고해 주세요.
  • Test Double 중에 Mock 은 호출에 대해 예상하는 결과를 받을 수 있도록 미리 프로그램 된 오브젝트입니다.
  • MockURLSessionDataTask 를 구현하고 resume() 메서드가 호출되면 프로퍼티로 선언된 클로져가 호출되도록 합니다.

class MockURLSessionDataTask: URLSessionDataTask {
    override init() 
    var resumeDidCall

    override func resume() {
        resumeDidCall()
    }
}
  • MockURLSession 을 작성합니다.
  • URLSessionProtocol 을 구현하고 생성자에서 request 를 실패하도록 만드는 플래그를 받게 합니다.
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
    }
}
  • dataTask(with:) 내부에서 MockURLSessionDataTask를 생성하고 resumeDidCall 클러져가 호출될때 dataTask(with:)completionHandler 에 성공 혹은 실패에 따른 파라미터를 넘겨 줍니다.

이제 테스트를 위한 준비가 끝났습니다.

테스트를 작성합니다. async 메서드를 테스트하기 위해 XCTestExpectation을 사용합니다.

  • 각 테스트 메서드가 실행 되기 전에 호출되는 setUpWithError() 메서드에서 sut(System under test) 를 생성해 줍니다.
  • 테스트를 위해 테스트 되는 target을 @testable import Joking 과 같은 방식으로 import 합니다.
  • MockURLSession 생성시 실패하는 request 에 대한 테스트를 명시합니다.

class JokesAPIProviderTests: XCTestCase {

    var sut: JokesAPIProvider!

    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)
    }
}
  • Command + U 로 테스트를 실행합니다.
  • Product > Scheme > Edit Scheme > Test > Options 메뉴에서 Gather Coverage 옵션을 켜면 테스트 커버리지를 측정할 수 있습니다.

URLSession 을 사용할 때 unit테스트를 어떻게 작성하는지 알아봤습니다.

위에서도 설명했지만 AlamofireURLSession 위에 구현되어 조금더 편한 interface를 제공합니다. 예를 들면 이런식이죠.

func fetchRandomJoke(completion: @escaping (Result<Joke, AFError>) -> Void) {
    AF.request(JokesAPI.randomJokes.url)
        .responseDecodable { (response: DataResponse<Joke, AFError>) in
            completion(response.result)
        }
}

URLSession 을 사용할 때 보다 코드의 양이 훨 씬 줄어듭니다. 하지만 Alamofire 역시 test를 위해 Mock 을 직접 작성해 줘야합니다.
Moya 는 test 가 훨씬 쉽습니다.
Moya 는 stub 기능이 있습니다. sampleData 만 제공하면 됩니다.

그럼 만화경에서 어떻게 Moya를 사용하는지 공유하겠습니다

  • 이번에는 API요청시에 파라미터를 넘겨보겠습니다. 이름과 카테고리를 파라미터로 사용합니다. 아래처럼 Response를 받게 됩니다.
$ curl "http://api.icndb.com/jokes/random?firstName=Hong&lastName=Gro&limitTo=[nerdy]" | python3 -m json.tool
...
{
    "type": "success",
    "value": {
        "id": 479,
        "joke": "Hong Gro does not need to know about class factory pattern. He can instantiate interfaces.",
        "categories": [
            "nerdy"
        ]
    }
}
  • Moya의 기본적인 사용방법은 이렇습니다. enum을 선언하고 사용될 target 들을 작성합니다. MoyaProvider<TargetType>
    아래의 예 에서는 하나의 타겟만 사용합니다. 이번에는 파라미터를 넘겨주도록 해보겠습니다. JokesAPIMoyaTargetType 프로토콜을 구현하도록 하면 끝입니다.
import Moya

enum JokesAPI {
    case randomJokes(_ firstName: String? = nil, 
                     _ lastName: String? = nil, 
                     _ categories: [String] = [])
}

extension JokesAPI: TargetType {

    var baseURL
    var path

    var task: Task {
        switch self {
        case .randomJokes(let firstName, let lastName, let categories):
            var params: [String: Any?] = [
                "firstName": firstName,
                "lastName": lastName
            ]

            if categories.isEmpty == false {
                params["limitTo"] = "(categories)"
            }

            return .requestParameters(
                parameters), 
                encoding: URLEncoding.queryString
            )
        }
    }

    var headers
    var method

    var sampleData: Data {
        switch self {
        case .randomJokes(let firstName, let lastName, let categoris):
            let firstName = firstName ?? "Chuck"
            let lastName = lastName ?? "Norris"

            return Data(
                """
                {
                   "type": "success",
                       "value": {
                       "id": 107,
                       "joke": "(firstName) (lastName) can retrieve anything from /dev/null.",
                       "categories": (categoris)
                   }
                }
                """.utf8
            )
        }
    }
}
  • JokesAPIProvider에서 위에 선언한 TargetType을 사용하면 됩니다.
class JokesAPIProvider {

    let provider: MoyaProvider<JokesAPI>
    init(provider: MoyaProvider<JokesAPI> = .init()) {
        self.provider = provider
    }

    func fetchRandomJoke(firstName: String? = nil,
                         lastName: String? = nil,
                         categories: [String] = [],
                         completion: @escaping (Result<Joke, Error>) -> Void) {
        provider.request(.randomJokes(firstName, lastName, categories)) { result in
            switch result {
            case .success(let moyaResponse):
                completion(.success(try! moyaResponse.map(JokeReponse.self).value))
            case .failure(let moyaError):
                completion(.failure(moyaError))
            }
        }
    }
}
  • 여기서는 기본적인 사용방법이고 만화경에서는 ProviderProtocol 을 선언하고 Plugin이나 기타 기본적인 작업들을 처리하도록 구현했습니다. Moya에서 제공하는 PluginType 프로토콜을 사용해 커스텀한 Plugin 을 직접 구현할 수도 있습니다.

이제 Moya 를 사용한 코드에 대해 테스틀 작성해 보겠습니다.

  • 먼저 Moya라이브러리의 MoyaProvider init을 보시죠.

  • Provider를 초기화 할 때 AlamofireSession, callbackQueue 등을 주입할 수 있습니다. 테스트를 위해서는 stubClosureMoyaProvider.immediatelyStub 로 설정합니다.
  • ProviderProtocol 을 구현하게 되면 providerinit method를 구현하면 됩니다.
  • protocolextension 으로 providerconstruct 하는 static 메서드를 구현하고 테스트 시에 sampleStatusCode 를 바꾸거나 customEndpointClosure 를 넘겨주는 방식으로 여러 경우를 테스트 할 수 있습니다.
public protocol ProviderProtocol: class {
    associatedtype T: TargetType

    var provider
    init(isStub: Bool, sampleStatusCode: Int, customEndpointClosure: ((T) -> Endpoint)?)
}

public extension ProviderProtocol {

    static func consProvider(
        _ isStub: Bool = false, 
        _ sampleStatusCode: Int = 200, 
        _ customEndpointClosure: ((T) -> Endpoint)? = nil) -> MoyaProvider<T> {

        if isStub == false {
            return MoyaProvider<T>(
                endpointClosure: {
                    MoyaProvider<T>.defaultEndpointMapping(for: $0).adding(newHTTPHeaderFields: Self.headers) 
                },
                session: DefaultAlamofireSession.shared,
                plugins: Self.defaultPlugins // ex - logging, network activity indicator
            )
        } else {
            // 테스트 시에 호출되는 stub 클로져
            let endPointClosure = { (target: T) -> Endpoint in
                let sampleResponseClosure: () -> EndpointSampleResponse = {
                    EndpointSampleResponse.networkResponse(sampleStatusCode, target.sampleData)
                }

                return Endpoint(
                    url: URL(target: target).absoluteString,
                    sampleResponseClosure: sampleResponseClosure,
                    method: target.method,
                    task: target.task,
                    httpHeaderFields: target.headers
                )
            }
            return MoyaProvider<T>(
                endpointClosure: customEndpointClosure ?? endPointClosure,
                stubClosure: MoyaProvider.immediatelyStub
            )
        }
    }
}
  • 이 프로토콜의 확장으로 Rx, Async, Combine 등 request 방식을 다양하게 추가 할 수 있습니다.
extension ProviderProtocol {

    func request<D: Decodable>(type: D.Type, atKeyPath keyPath: String? = nil, target: T) -> Single<D> {
        provider.rx.request(target)
            .map(type, atKeyPath: keyPath)
            // some operators
    }
}
  • 이제 JokesProvider 에서 ProviderProtocol을 구현하도록 합니다. 간편해지죠? 아니라구요? 맞아요. 테스트도 쉬워집니다.
  • provider 가 프로퍼티로 선언되어 있기 때문에 한번은 성공하고 두번째 실패하는 케이스도 테스트가 가능하게 됩니다.
class JokesAPIProvider: ProviderProtocol {

    typealias T = JokesAPI
    var provider: MoyaProvider<JokesAPI>

    required init(isStub: Bool = false, sampleStatusCode: Int = 200, customEndpointClosure: ((T) -> Endpoint)? = nil) {
        provider = Self.consProvider(isStub, sampleStatusCode, customEndpointClosure)
    }

    func fetchRandomJoke(firstName: String? = nil, lastName: String? = nil, categories: [String] = []) -> Single<Joke> {
        return request(type: Joke.self, atKeyPath: "value", target: .randomJokes(firstName, lastName, categories))
    }
}

이제 진짜로 테스트를 작성해 봅시다.

  • 성공하는 request 에대한 테스트를 아래와 같이 작성할 수 있습니다. Provider 생성시 isStubtrue 설정 하면 됩니다.
  • 실패하는 API에 대한 테스트는 JokesAPIProvider(isStub: true, sampleStatusCode: 410) 과 같은 방식으로 테스트 할 수 있겠네요.
  • Quick 이나 Nimble 같은 Test framework도 존재합니다.
  • 저는 비동기 테스트를 위해 XCTestExpectation 을 사용했는데 Quick/Nimble 을 사용하면 보기에 더 편합니다. 기타 여러 장점이 존재하죠.
  • Rx테스트에는 RxBlockingRxTest를 사용할 수도 있겠네요.
import XCTest
@testable import Joking

class JokesAPIProviderTests: XCTestCase {

    var sut: JokesAPIProvider!

    override func setUpWithError() throws {
        sut = JokesAPIProvider(isStub: true)
    }

    func test_fetchRandomJokes_success() {
        let expectation = XCTestExpectation()

        let expectedJoke = JokesAPI
            .randomJokes("Gro", "Hong", ["nerdy"])
            .sampleDecodable(JokeReponse.self)?.value

        sut.fetchRandomJoke(firstName: "Gro", lastName: "Hong", categories: ["nerdy"])
            .subscribe(onSuccess: { joke in
                XCTAssertEqual(expectedJoke?.joke, joke.joke)
                expectation.fulfill()
            })
            .dispose()
        wait(for: [expectation], timeout: 2.0)
    }
}

너무 길었죠? 급하게 마무리 짓겠습니다.

마무리

  • 개발하다보면 Low level의 코드를 작성해야 할 때도 생기자나요. 그때는 URLSession을 사용하면 될것 같습니다.
  • Moya를 사용하면 중복되는 코드를 추상화해 좀더 심플한 방식으로 API를 추가할 수 있습니다.
  • Mock을 직접 구현하지 않고 API를 테스트 할 수 있고요.
  • XCTest가 불편하다면 Quick/Nimble framework를 사용하면 가독성 좋은 테스트 코드를 작성할 수 있죠.
  • 지금까지 작성한 ProviderProtocol 은 물론 그 자체로도 테스트가 용이하지만 비즈니스 로직을 테스트 할 때 빛을 발합니다.
  • ViewModel과 함께 테스트 하는 내용은 다음 기회에 공유해보도록 하죠.
  • 가독성을 위해 실제코드의 일부를 축약하였습니다.

척 노리스는 테스트코드를 작성하지 않는다. 코드만 작성하고 IDE를 째려보면 IDE가 스스로 테스트코드를 작성한다.