# 네트워크 목업 테스트

테스트 코드 작성은 외부 환경에 의존하면 안된다는 전제가 있다. 이러한 전제에 따라 네트워크 환경도 목킹해야 한다. 이 글에서는 네트워크 환경 목업에 대한 내용들을 정리하려고 한다.

# Mocks vs Stubs vs Fake

본격적인 내용 정리에 앞서 용어를 정리한다.

  1. Faking: 프로덕션에서 사용되지 않는 클래스의 구현
  2. Mocking: 객체의 메서드 호출에 대한 가짜 응답을 제공하는 것. 이를 통해 메서드 호출 및 응답으로 온 객체 속성값을 체크할 수 있다.
  3. Stubbing: 메서드 호출에 따른 가짜 응답 자체를 가리키는 말.

# Unit Testing & Test Doubles

  1. Unit Testing: SUT의 상태값을 Assert등을 통해 테스팅하는 것을 의미한다. SUTSystem Under Test의 준말로 테스트하고자 하는 주요 대상을 말한다. (상태 기반 테스트)
  2. Test Doubles: 유닛 테스트를 위해 실 구현을 대체하는 가짜 컴포넌트 및 객체를 가리킨다. (행위 기반 테스트)
    1. Stub: 메서드 호출에 대한 가짜 응답. 백엔드 구축이 아직 덜 이루어진 경우 스텁을 통해 가짜 백엔드 모델을 정의해볼 수 있다. 스텁 객체에 대한 네이밍은 메서드명 + toBeReturned로 한다.
    2. Spies: 메서드 호출 형태를 정보로 저장하는 스텁이다. 메서드 호출이 정상적으로 이루어졌는지, 몇번이나 호출되었는지를 기록한다.
    3. Mock: 스텁과 유사하지만 메서드 호출이 실행되었는지, 객체 속성값이 세팅되었는지를 체크한다. 스텁은 상태 검증에 사용되고 목은 행위 검증에 사용된다.

아래는 다음 문서에 작성되어 있는 스텁 예제 코드이다. (opens new window)

final class PostsServiceStub: PostsServiceProtocol {
    var fetchAllResultToBeReturned: Result<[Post], Error> = .success([])
    func fetchAll(then: (Result<[Post], Error>) -> Void) {
        then(fetchAllResultToBeReturned)
    }
}

// Usage
final class UserFeedViewModelTests: XCTestCase {

    func test_fetchAll_shouldReturnTheCorrectAmountOfPosts() {
        // Given
        let postsServiceStub = PostsServiceStub()
        let stubbedPosts: [Post] = [
            .init(title: "Post 1", text: "Post Text 1"),
            .init(title: "Post 2", text: "Post Text 2")
        ]
        postsServiceStub.fetchAllResultToBeReturned = .success(stubbedPosts)
        let sut = UserFeedViewModel(postsService: postsServiceStub)
        let initialNumberOfPosts = sut.numberOfPosts

        // When
        let loadDataExpectation = expectation(description: "loadDataExpectation")
        sut.loadData {
            loadDataExpectation.fulfill()
        }
        wait(for: [loadDataExpectation], timeout: 1.0)

        // Then
        XCTAssertNotEqual(initialNumberOfPosts, sut.numberOfPosts)
    }
}
  1. 스텁 클래스를 정의한 뒤 fetchAllResultToBeReturned 속성을 정의한다. 이 속성값이 가짜 응답에 해당한다.
  2. 테스트 코드를 작성할때 스텁 클래스로 객체를 생성하는데 이때 가짜 응답 값은 이니셜라이저 파라미터로 사용하지 않아야 다양한 케이스를 직접 테스트하기 용이하다.(stubbedPosts 배열)
  3. 스텁 객체의 fetchAllResultToBeReturned 속성에 success 케이스로 값을 할당하여 응답이 정상적으로 이루어졌음을 나타낸다.
  4. 뷰모델에 서비스 객체를 주입하고 가짜 네트워킹을 진행한다.

# 목업 프로토콜 작성

38-1

iOS에서 네트워크 통신은 URLSessionConfiguration에서 시작하여 URLSession 객체를 정의하고, 진행중인 요청 상태를 나타내는 URLSessionDataTask객체로 이어진다.

이때 해당 작업과 관련되어 내부 동작들이(네트워크 연결 열기, 요청 작성 및 응답 읽기 등) 추상화되어 있는 URLProtocol들이 존재한다.

위의 그림처럼 목업 프로토콜들을 재정의하여 URLProtocolClient에 진행 상황을 전달하고 이를 URLSessionDataTask에 전달함으로써 목업 네트워킹을 작성할 수 있다.

쉽게 말해, 네트워크 통신은 Alamofire의 Session객체를 통해 이루어지게 되는데 이때 세션 객체를 기본 configuration 기반으로 사용하는 것이 아니라 세션 객체의 protocolClasses를 커스텀하여 네트워크 통신이 가짜로 이루어짐을 선언하는 것이다.

프로토콜 클래스 커스텀시 구현해야할 네트워크 라이프사이클 필수 함수들이 있다. 커스텀 프로토콜 객체는 URLProtocol를 상속하는데, 필수 구현대상 함수들이 존재한다.

  1. canInit: true값을 리턴하면 네트워크 요청 처리가 가능함을 알리게 됨 (It uses the protocol whose canInit(with:) class method returns true, indicating that the class is capable of handling the specified request.)
  2. canonicalRequest: 기존 요청에 대한 수정이 필요할때 사용 가능하다. 헤더 및 url 스킴의 변경이 이루어질 때 사용 가능하다. 테스트 시에는 굳이 요청에 대한 변경이 불필요하기 때문에 파라미터로 전달된 요청 객체를 그대로 리턴하면 된다.
  3. startLoading: 네트워크 요청에 따라 응답을 정의하는 구간이다. URLProtocol 객체는 client 속성값을 가진다. client 속성값의 urlProtocol()메서드를 호출하여 가짜 응답에 대한 정의와 가짜 응답 데이터에 대한 정의를 진행한다. startLoading함수 내에서는 아래 직접 정의된 두 함수가 호출된다.
    • setUpMockResponse(): 정상 응답 혹은 에러 모두 검증하고자 할 때가 있으니, 응답 자체에 대한 타입을 지정하는 것이라 보면 된다.
    • setUpMockData(): 번들 파일에 json파일을 정의한 뒤 정상 응답으로 리턴되는 데이터를 디코딩하여 내보내기 위해 사용된다.
  4. stopLoading: 작업을 마치고 네트워크 통신을 끝낸다. 이 함수 내부는 비운 채로 구현하는 것이 안전하다.

위의 네 코드만 구현하면 URLProtocol 클래스를 상속받는 커스텀 프로토콜은 사용 가능하다.

// 코드 출처: https://leeari95.tistory.com/71
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)
}

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

    guard let file = Bundle.main.url(forResource: fileName, withExtension: nil) else {
        return Data()
    }
    return try? Data(contentsOf: file)
}

목업 응답 및 데이터 셋업 코드는 이아리님의 iOS 탐구생활 블로그 (opens new window)를 참조하였다. 나머지 목업 프로토콜 구현 코드의 경우 이아리님께서 URLConfiguration을 비공개 세션을 기반으로 하여 구현하여 추가적인 코드들이 있었는데, 본인의 경우 URLSessionConfiguration값을 default로 설정하여 실제 구현 필수 대상만 추가하였다.

목업 프로토콜 코드 전문
//
//  MockURLProtocol.swift
//  posepicker
//
//  Created by 박경준 on 12/2/23.
//

import Foundation

final class MockURLProtocol: URLProtocol {

    private lazy var session: URLSession = {
        let configuration: URLSessionConfiguration = URLSessionConfiguration.default
        return URLSession(configuration: configuration)
    }()

    enum ResponseType {
        case error(APIError)
        case success(HTTPURLResponse)
    }

    static var responseType: ResponseType!
    static var dtoType: MockDTOType!
}

extension MockURLProtocol {

    static func responseWithFailure() {
        MockURLProtocol.responseType = MockURLProtocol.ResponseType.error(APIError.unknown)
    }

    static func responseWithStatusCode(code: Int) {
        MockURLProtocol.responseType = MockURLProtocol.ResponseType.success(HTTPURLResponse(url: URL(string: K.baseUrl)!, statusCode: code, httpVersion: nil, headerFields: nil)!)
    }

    static func responseWithDTO(type: MockDTOType) {
        MockURLProtocol.dtoType = type
    }
}


extension MockURLProtocol {

    enum MockDTOType {
        case posepick
        case starred
        case user
        case empty
        case oauth
        case oauthBadRequest
        case oauthRedirectURLMismatch
        case oauthIncorrectClientCredentials

        var fileName: String {
            switch self {
            case .posepick: return "PosePick.json"
            case .empty: return ""
            }
        }
    }

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

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

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

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

# 테스트코드 작성

목업 URL 프로토콜 작성 이후 실제 테스트 코드를 작성한다. 네트워크 통신은 비동기 처리가 필요하다. 동기적으로 동작하는 코드의 테스트는 RxTest를 통해 테스트 스케줄러에서 마련해둔 내부 타이머에 따라 스트림 값에 대한 비교로 작성되는데, 비동기 코드의 경우 정확한 시점을 특정할 수 없다.

따라서 expectation이라는 개념이 등장하는데 비동기통신을 마치는 시점을 예상한다는 의미로 사용되는 비동기 통신 테스트 전용 객체이다.

테스트 해보려는 예시 시나리오는 다음과 같다.

  1. 애플로그인 처리를 앱에서 마친 뒤 ASAuthorizationAppleIDCredential 객체로부터 idToken값을 추출하여 뷰모델로 인풋으로 전달한다.
  2. 인풋 전달과 동시에 APISession 객체를 통해 로그인 API에 요청을 보낸다.
  3. 요청 성공 후 발급받은 액세스 토큰을 확인한다.

이때 위의 1번 과정은 특정 시점에 아이디 토큰이 반드시 발급된다는 것을 가정하고 스케줄러의 createColdObservable로 아이디 토큰을 갖는 옵저버블을 인풋 옵저버블에 할당한다. 자세한 내용은 RxTest 기초에 대해 다루는 다음 글을 (opens new window) 참고하자.

스케줄러 start()메서드 호출 이후 인풋이 동작하고 테스트 코드 목업 네트워크 객체를 거쳐 요청이 시작된다.

구독해두었다가 데이터 요청이 끝나는 시점에 토큰값을 점검하고 테스트를 마치는데, 비동기 통신 테스트의 경우 반드시 expectations 객체에 대해 fulfill 메서드를 호출해줘야 한다.

func test_애플로그인__이후_로그인처리() {
    MockURLProtocol.responseWithDTO(type: .user) // 1. User.json 파일에 접근
    MockURLProtocol.responseWithStatusCode(code: 200) // 2. 요청에 성공한 경우 테스트

    let expectation = XCTestExpectation(description: "/api/users/login/apple/ 테스트") // 3. expectation 객체 추가
    var input = retrieveDefaultInputObservable() // 4. 인풋 옵저버블 추가

    input.appleIdToken = scheduler.createColdObservable([ // 5. 애플 아이디 토큰 셋업 이벤트 인풋 전달
        .next(10, "test_idToken")
    ]).asObservable()

    let output = viewModel.transform(input: input) // 6. transform으로 네트워크 요청 시작

    output.user.asObservable() // 7. 유저 데이터에서 토큰 추출
        .compactMap { $0 }
        .map { $0.token.accessToken }
        .asDriver(onErrorJustReturn: "")
        .drive(onNext: {
            XCTAssertEqual($0, "string") // 8. 토큰값이 string이라고 가정
            expectation.fulfill()
        })
        .disposed(by: disposeBag)

    scheduler.start() // 9. 스케줄러 시작을 통해 테스트 본격적인 시작

    wait(for: [expectation], timeout: 5) // 비동기통신 타임아웃 추가
}

# Reference

  1. Stubbing, Mocking or Faking (opens new window)
  2. Mocking Network Calls in Swift (opens new window)
  3. Velog - Test Doubles (opens new window)
  4. Medium - Unit Testing and Test Doubles in Swift (opens new window)
  5. Medium - Test Doubles in swift (opens new window)
  6. WWDC2018 - Testing Tips & Tricks (opens new window)
  7. Medium - How to Test Your Network Connection Requests in Swift Using URLProtocol (opens new window)