관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 네트워크(network) - testable한 URLSession 설계 (Endpoint, Provider) 본문

iOS 응용 (swift)

[iOS - swift] 네트워크(network) - testable한 URLSession 설계 (Endpoint, Provider)

jake-kim 2021. 10. 2. 16:06

* URLSession 기본 개념은 여기 참고

준비

  • 구현된 해당 소스 코드 사용 시, 아래 작업 사용 후 실행
  • https://unsplash.com/developers 가입 후 api키 획득
  • Contants에 accessKey입력
struct Constants {
    static let accessKey = "Your accessKey"
}

Network 레이어 설계

  • 네트워크의 핵심 모듈
    • Endpoint: path, queryPramameters, bodyParameter등의 데이터 객체
    • Provider: URLSession, DataTask를 이용하여 network호출이 이루어 지는 곳

  • Endpoint는 요청, 응답 protocol을 준수하는 상태
    • requestable에는 baseURL, path, method, parameters, ... 같은 정보가 존재
  • Responsable은 단순히 아래 코드
    • Request하는 곳인, Provider에서 Response의 타입을 알아야 제네릭을 적용할 수 있는데, 여기서 Endpoint객체 하나만 넘기면 따로 request할 때 Response타입을 넘기지 않아도 되게끔 설계
// Responsable.swift
protocol Responsable {
    associatedtype Response
}

// Endpoint.swift
// Endpoint 객체를 만들때 Response타입을 명시
class Endpoint<R>: RequesteResponsable {
    typealias Response = R
...

// Provider.swift
// Provider에서 Endpoint객체를 받으면 따로 Response 타입을 넘기지 않아도 되도록 설계
protocol Provider {
    func request<R: Decodable, E: RequesteResponsable>(with endpoint: E, completion: @escaping (Result<R, Error>) -> Void) where E.Response == R
    ...

Network 구현

- 핵심:

1) Request, Response가 Generic하여 하드코딩되지 않은 형태

2) URLSession의 dataTask메소드를 protocol로 선언하여 request/response를 testable하도록 구현

공통 Error 타입 정의

enum NetworkError: LocalizedError {
    case unknownError
    case invalidHttpStatusCode(Int)
    case components
    case urlRequest(Error)
    case parsing(Error)
    case emptyData
    case decodeError

    var errorDescription: String? {
        switch self {
        case .unknownError: return "알수 없는 에러입니다."
        case .invalidHttpStatusCode: return "status코드가 200~299가 아닙니다."
        case .components: return "components를 생성 에러가 발생했습니다."
        case .urlRequest: return "URL request 관련 에러가 발생했습니다."
        case .parsing: return "데이터 parsing 중에 에러가 발생했습니다."
        case .emptyData: return "data가 비어있습니다."
        case .decodeError: return "decode 에러가 발생했습니다."
        }
    }
}

Endpoint 정의

  • Requestable
protocol Requestable {
    var baseURL: String { get }
    var path: String { get }
    var method: HttpMethod { get }
    var queryParameters: Encodable? { get }
    var bodyParameters: Encodable? { get }
    var headers: [String: String]? { get }
    var sampleData: Data? { get }
}

extension Requestable {
    func getUrlRequest() throws -> URLRequest {
        let url = try url()
        var urlRequest = URLRequest(url: url)

        // httpBody
        if let bodyParameters = try bodyParameters?.toDictionary() {
            if !bodyParameters.isEmpty {
                urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: bodyParameters)
            }
        }

        // httpMethod
        urlRequest.httpMethod = method.rawValue

        // header
        headers?.forEach { urlRequest.setValue($1, forHTTPHeaderField: $0) }

        return urlRequest
    }

    func url() throws -> URL {

        // baseURL + path
        let fullPath = "\(baseURL)\(path)"
        guard var urlComponents = URLComponents(string: fullPath) else { throw NetworkError.components }

        // (baseURL + path) + queryParameters
        var urlQueryItems = [URLQueryItem]()
        if let queryParameters = try queryParameters?.toDictionary() {
            queryParameters.forEach {
                urlQueryItems.append(URLQueryItem(name: $0.key, value: "\($0.value)"))
            }
        }
        urlComponents.queryItems = !urlQueryItems.isEmpty ? urlQueryItems : nil

        guard let url = urlComponents.url else { throw NetworkError.components }
        return url
    }
}
  • Responsable: Endpoint를 생성할 때 타입을 주입하여, 뒤에 나올 provider의 request제네릭에 적용
protocol Responsable {
    associatedtype Response
}

// Endpoint.swift

class Endpoint<R>: RequesteResponsable {
	// Response타입을 미리 정의하여, Endpoint객체 하나만 request쪽에 넘기면 request함수의 Response제네릭에 적용
    typealias Response = R
    ...
}
  • Endpoint 구현: Requestable, Responsable의 성격을 가지고 있는 클래스
protocol RequesteResponsable: Requestable, Responsable {}

class Endpoint<R>: RequesteResponsable {
    typealias Response = R

    var baseURL: String
    var path: String
    var method: HttpMethod
    var queryParameters: Encodable?
    var bodyParameters: Encodable?
    var headers: [String: String]?
    var sampleData: Data?

    init(baseURL: String,
         path: String = "",
         method: HttpMethod = .get,
         queryParameters: Encodable? = nil,
         bodyParameters: Encodable? = nil,
         headers: [String: String]? = [:],
         sampleData: Data? = nil) {
        self.baseURL = baseURL
        self.path = path
        self.method = method
        self.queryParameters = queryParameters
        self.bodyParameters = bodyParameters
        self.headers = headers
        self.sampleData = sampleData
    }
}

enum HttpMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
}

Provider 정의

  • request는 2가지 종류
    • 1) Encodable한 Request모델을 통해서 Decodable한 Response를 받는 request
    • 2) 단순히 URL을 request로 주어, Data를 얻는 request (ex - 이미지 url을 넣고 이미지 Data 얻어오는 경우 사용)
protocol Provider {
    /// 특정 responsable이 존재하는 request
    func request<R: Decodable, E: RequesteResponsable>(with endpoint: E, completion: @escaping (Result<R, Error>) -> Void) where E.Response == R

    /// data를 얻는 request
    func request(_ url: URL, completion: @escaping (Result<Data, Error>) -> ())
}

class ProviderImpl: Provider {

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

    func request<R: Decodable, E: RequesteResponsable>(with endpoint: E, completion: @escaping (Result<R, Error>) -> Void) where E.Response == R {
        
        do {
            let urlRequest = try endpoint.getUrlRequest()

            session.dataTask(with: urlRequest) { [weak self] data, response, error in
                self?.checkError(with: data, response, error) { result in
                    guard let `self` = self else { return }

                    switch result {
                    case .success(let data):
                        completion(`self`.decode(data: data))
                    case .failure(let error):
                        completion(.failure(error))
                    }
                }
            }.resume()

        } catch {
            completion(.failure(NetworkError.urlRequest(error)))
        }
    }

    func request(_ url: URL, completion: @escaping (Result<Data, Error>) -> ()) {
        session.dataTask(with: url) { [weak self] data, response, error in
            self?.checkError(with: data, response, error, completion: { result in
                completion(result)
            })
        }.resume()
    }

    // Private

    private func checkError(with data: Data?, _ response: URLResponse?, _ error: Error?, completion: @escaping (Result<Data, Error>) -> ()) {
        if let error = error {
            completion(.failure(error))
            return
        }

        guard let response = response as? HTTPURLResponse else {
            completion(.failure(NetworkError.unknownError))
            return
        }

        guard (200...299).contains(response.statusCode) else {
            completion(.failure(NetworkError.invalidHttpStatusCode(response.statusCode)))
            return
        }

        guard let data = data else {
            completion(.failure(NetworkError.emptyData))
            return
        }

        completion(.success((data)))
    }

    private func decode<T: Decodable>(data: Data) -> Result<T, Error> {
        do {
            let decoded = try JSONDecoder().decode(T.self, from: data)
            return .success(decoded)
        } catch {
            return .failure(NetworkError.emptyData)
        }
    }
}

extension Encodable {
    func toDictionary() throws -> [String: Any]? {
        let data = try JSONEncoder().encode(self)
        let jsonData = try JSONSerialization.jsonObject(with: data)
        return jsonData as? [String: Any]
    }
}

사용하는 쪽

  • APIEndpoints를 정의하여 도메인에 종속된 baseURL, path등을 정의
struct APIEndpoints {
    static func getPhotosInfo(with photoListRequestDTO: PhotoListRequestDTO) -> Endpoint<[PhotoListResponseDTO]> {
        return Endpoint(baseURL: "https://api.unsplash.com/",
                        path: "photos",
                        method: .get,
                        queryParameters: photoListRequestDTO,
                        headers: ["Authorization": "Client-ID \(Constants.accessKey)"],
                        sampleData: NetworkResponseMock.photoList)
    }

    static func getImages(with url: String) -> Endpoint<Data> {
        return Endpoint(baseURL: url, sampleData: NetworkResponseMock.image)
    }
}
  • 호출하여 사용
let endpoint = APIEndpoints.getPhotosInfo(with: photoListRequestDTO)
provider.request(with: endpoint) { result in
    switch result {
    case .success(let response)
        print(response)
    case .failure(let error):
        print(error)
    }
}

네트워크 Test 코드

  • mock response로 사용될 데이터를 APIEndpoints에서 sampleData에 사용되는 데이터 미리 준비
// NetworkResponseMock.swift
struct NetworkResponseMock {
    static let photoList: Data = """
                            [
                               {
                                  "id":"z3htkdHUh5w",
...
                               },
                            ]
                            """.data(using: .utf8)!
    static let image: Data = UIImage(systemName: "square")!.pngData()!
}

// APIEndpoints.swift
struct APIEndpoints {
    static func getPhotosInfo(with photoListRequestDTO: PhotoListRequestDTO) -> Endpoint<[PhotoListResponseDTO]> {
        return Endpoint(baseURL: "https://api.unsplash.com/",
                        path: "photos",
                        method: .get,
                        queryParameters: photoListRequestDTO,
                        headers: ["Authorization": "Client-ID \(Constants.accessKey)"],
                        sampleData: NetworkResponseMock.photoList) // <- 여기서사용
    }

    static func getImages(with url: String) -> Endpoint<Data> {
        return Endpoint(baseURL: url, sampleData: NetworkResponseMock.image)  // <- 여기서사용
    }
}
  • 설계할때 URLSession을 protocol을 따르도록 구현했으므로, URLSession에 mock을 넣어서 response데이터를 조작할 수 있으므로 testable 성격 확보
// URLSessionable.swift
protocol URLSessionable {
    func dataTask(with request: URLRequest,
                  completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
    func dataTask(with url: URL,
                  completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
}

extension URLSession: URLSessionable {}

// Provider.swift
// 사용하는 곳
class ProviderImpl: Provider {

    let session: URLSessionable
    init(session: URLSessionable = URLSession.shared) {
        self.session = session
    }
...
}
  • URLSessionDataTask를 준수하는 테스트용 MockURLSessionDataTask 정의
    • resumeDidCall: resume이 불리면 실행되는 closure블록 (외부에서 정의하면, resume이 불리는 타이밍에 바로 실행)
class MockURLSessionDataTask: URLSessionDataTask {

    var resumeDidCall: (() -> ())?

    override func resume() {
        // 주의: super.resume()호출하면 실제 resume()이 호출되므로 주의
        resumeDidCall?()
    }
}
  • URLSessionable을 준수하는 MockURLSession정의
    • 미리 성공, 실패에 대한 response를 정의하여 넘길 수 있는 역할
class MockURLSession: URLSessionable {

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

    var sessionDataTask: MockURLSessionDataTask?

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

        let endpoint = APIEndpoints.getPhotosInfo(with: .init(page: 1))

        // 성공 callback
        let successResponse = HTTPURLResponse(url: try! endpoint.url(),
                                              statusCode: 200,
                                              httpVersion: "2",
                                              headerFields: nil)
        // 실패 callback
        let failureResponse = HTTPURLResponse(url: try! endpoint.url(),
                                              statusCode: 301,
                                              httpVersion: "2",
                                              headerFields: nil)

        let sessionDataTask = MockURLSessionDataTask()

        // resume() 이 호출되면 completionHandler()가 호출
        sessionDataTask.resumeDidCall = {
            if self.makeRequestFail {
                completionHandler(nil, failureResponse, nil)
            } else {
                completionHandler(endpoint.sampleData!, successResponse, nil)
            }
        }
        self.sessionDataTask = sessionDataTask
        return sessionDataTask
    }
}
  • 테스트 코드 작성
class PhotoAPITests: XCTestCase {

    var sut: Provider!

    override func setUpWithError() throws {
        // mock데이터 주입
        sut = ProviderImpl(session: MockURLSession())
    }

    func test_fetchPhotoList_whenSuccess_thenProcessRight() {
        // async테스트를 위해서 XCTestExpectation 사용
        let expectation = XCTestExpectation()

        let endpoint = APIEndpoints.getPhotosInfo(with: .init(page: 1))
        let responseMock = try? JSONDecoder().decode([PhotoListResponseDTO].self, from: endpoint.sampleData!)

        sut.request(with: endpoint) { result in
            switch result {
            case .success(let response):
                XCTAssertEqual(response.first?.id, responseMock?.first?.id)
            case .failure:
                XCTFail()
            }
            expectation.fulfill()
        }

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

    func test_fetchPhotoList_whenFailed_thenProcessRight() {
        sut = ProviderImpl(session: MockURLSession(makeRequestFail: true))
        let expectation = XCTestExpectation()

        let endpoint = APIEndpoints.getPhotosInfo(with: .init(page: 1))

        sut.request(with: endpoint) { result in
            switch result {
            case .success:
                XCTFail()
            case .failure(let error):
                XCTAssertEqual(error.localizedDescription, "status코드가 200~299가 아닙니다.")
            }
            expectation.fulfill()
        }

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

테스트 성공

전체 코드, Pagination/Infrastructure/Network 하위 파일 참고: https://github.com/JK0369/PaginationExample

 

* 참고

- https://developer.apple.com/documentation/foundation/urlsession

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

Comments