관리 메뉴

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

[Clean Architecture] 7. 코드로 알아보는 SOLID - Network, REST (URLSession, URLRequest, URLSessionDataTask) 본문

Clean Architecture/Clean Architecture 코드

[Clean Architecture] 7. 코드로 알아보는 SOLID - Network, REST (URLSession, URLRequest, URLSessionDataTask)

jake-kim 2021. 9. 23. 23:29

0. 코드로 알아보는 SOLID - 클래스 다이어그램 필수 표현

1. 코드로 알아보는 SOLID - SRP(Single Responsibility Principle) 단일 책임 원칙

2. 코드로 알아보는 SOLID - OCP(Open Close Principle) 개방 폐쇄 원칙

3. 코드로 알아보는 SOLID - LSP(Liskov Substitution Principle) 리스코프 치환 원칙

4. 코드로 알아보는 SOLID - ISP(Interface Segregation Principle) 인터페이스 분리 원칙

5. 코드로 알아보는 SOLID - DIP(Dependency Inversion Principle, testable) 의존성 역전 원칙

6. 코드로 알아보는 SOLID - Coordinator 패턴 화면전환

7. 코드로 알아보는 SOLID - Network, REST (URLSession, URLRequest, URLSessionDataTask)

8. 코드로 알아보는 SOLID - 캐싱 Disk Cache (UserDefeaults, CoreData)

 Network 통신 기본 개념 (3단계)

  • 1) URLSession 객체 생성 (session의 성격은 configuration 파라미터값으로 부여)
  • 2) URL을 가지고 URLRequest객체 생성(어떻게 캐싱할지, HTTP method 설정)
  • 3) URLSessionDataTask 생성 (response처리, session 실행, session 캔슬)
    • completion handler에서 오는 error, response 처리
    • dataTask를 실행하거나 취소하는 메소드 호출

URLSession

  • default session: 디스크 기반 캐싱 지원하는 세션
  • ephemeral session: 캐싱하지 않는 세션
  • background session: 앱이 종료된 이후에도 통신이 이루어지는 세션
let session = URLSession(configuration: .default)
let session2 = URLSession(configuration: .ephemeral)
let session3 = URLSession(configuration: .background(withIdentifier: "background"))

URLRequest

  • 서버로 요청을 보낼 때 어떻게 데이터를 캐싱할 것인지, HTTP method는 어떤것을 사용할지 세팅
var urlRequest = URLRequest(url: requestURL)
urlRequest.httpMethod = "GET" // POST, PUT, DELETE, HEAD, PATCH 가능
urlRequest.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
  • requestURL은 URLComponent를 이용하여 baseURL과 path, query정보를 합한 완성된 URI를 통해 Request객체를 만들고, 이 Request 객체 생성

URL의 구성 요소

// url 만드는 방법

var urlComponents = URLComponents(string: "https://ios-development.tistory.com/search/users?")
let userIDQuery = URLQueryItem(name: "id", value: "123")
let ageQuery = URLQueryItem(name: "age", value: "20")
urlComponents?.queryItems?.append(userIDQuery)
urlComponents?.queryItems?.append(ageQuery)
guard let requestURL = urlComponents?.url else { return }

URLSession, URLRequest, URLSessionDataTask 예제 코드

// URL 생성: https://ios-development.tistory.com/search/users?id=123&age=20
var urlComponents = URLComponents(string: "https://ios-development.tistory.com/search/users?")
let userIDQuery = URLQueryItem(name: "id", value: "123")
let ageQuery = URLQueryItem(name: "age", value: "20")
urlComponents?.queryItems?.append(userIDQuery)
urlComponents?.queryItems?.append(ageQuery)
guard let requestURL = urlComponents?.url else { return }

// 1. URLSession 객체 생성
let session = URLSession(configuration: .default)

// 2. URLRequest 객체 생성
var urlRequest = URLRequest(url: requestURL)
urlRequest.httpMethod = "GET" // POST, PUT, DELETE, HEAD, PATCH 가능
urlRequest.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData

// 3. URLSessionDataTask 객체 생성
let dataTask = session.dataTask(with: requestURL) { (data, response, error) in

    guard error == nil else { return }

    let successsRange = 200..<300
    guard let statusCode = (response as? HTTPURLResponse)?.statusCode,
          successsRange.contains(statusCode) else { return }

    guard let resultData = data else { return }
    let resultString = String(data: resultData, encoding: .utf8)
    print(resultData)
    print(resultString)
}

// URLSessionDataTask 객체를 이용하여 실행 or 취소
dataTask.resume()
//    dataTask.cancel()

Clean Architecture를 적용

  • DIP를 준수하여 network도 testable하도록 설계
  • Network 기능별로 나누어 사용하기 편리하도록 설계
    • 1) NetworkConfig: URL request 시 기본 설정 값
    • 2) Endpoint: bodyParameter, method, encoder 등 설정 값
    • 3) NeworkService: URLSession과 task를 이용하여 resume(), cancel()시키는 부분 (+ log 출력도 존재)
    • 4) DataTransferService: NetworkService에서 불리는 request를 wrapping하여, response로 받은 Data형을 decode시키는 모듈

1) NetworkConfig

  • 네트워크 호출을 할때 기본적인 설정 값 (baseURL, headers, query parameters)을 설정
    • cf) bodyParameter는 Endpoint에서 구현

  • NetworkConfigurable 코드
public protocol NetworkConfigurable {
    var baseURL: URL { get }
    var headers: [String: String] { get }
    var queryParameters: [String: String] { get }
}

public struct ApiDataNetworkConfig: NetworkConfigurable {
    public let baseURL: URL
    public let headers: [String: String]
    public let queryParameters: [String: String]
    
     public init(baseURL: URL,
                 headers: [String: String] = [:],
                 queryParameters: [String: String] = [:]) {
        self.baseURL = baseURL
        self.headers = headers
        self.queryParameters = queryParameters
    }
}

2) Endpoint

  • client와 server간에 client쪽에 맞닿아 있는 말단 개념
    • path, method, parameters 설정
    • 위에서 정의한 NetworkConfig를 받아서 urlRequest 완성

  • Endpoint에 필요한 값들 정의
    • addingPercentEncoding(withAllowedCharacters:): 문자가 나올 경우 `%20`으로 인코딩
/// Dictionary형을 queryString형태로 변형
private extension Dictionary {
    var queryString: String {
        return self.map { "\($0.key)=\($0.value)" }
            .joined(separator: "&")
            .addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) ?? ""
    }
}
  • GenerationError 정의
enum RequestGenerationError: Error {
    case components
}
  • Encodable인 struct형을 Dictionary형으로 변환
/// Encodable인 struct형을 딕셔너리 형태로 변형
private extension Encodable {
    func toDictionary() throws -> [String: Any]? {
        let data = try JSONEncoder().encode(self)
        let josnData = try JSONSerialization.jsonObject(with: data)
        return josnData as? [String : Any]
    }
}

ex) toDictionary() 

struct Person: Encodable {
    let age: Int
    let name: String
}

let person = Person(age: 20, name: "jake")
print(try! person.toDictionary()) // Optional(["age": 20, "name": jake])
  • HTTPMethod 타입 정의
public enum HTTPMethodType: String {
    case get     = "GET"
    case head    = "HEAD"
    case post    = "POST"
    case put     = "PUT"
    case patch   = "PATCH"
    case delete  = "DELETE"
}
  • Encode 타입 정의
public enum BodyEncoding {
    /// Dictionary를 Json 객체로 만든 후에 json을 그대로 인코딩 (Data형으로 변환)
    case jsonSerializationData
    /// Dictionary를 String로 변경 후에 해당 문자열을 인코딩 (Data형으로 변환)
    case stringEncodingAscii
}
// 사용 예) Endpoint.swift의 extension Requestable { 

/// 딕셔너리 형태를 받아서 API호출을 하기위해 아카이빙형태로 변환 (Data형)
private func encodeBody(bodyParamaters: [String: Any], bodyEncoding: BodyEncoding) -> Data? {
    switch bodyEncoding {
    case .jsonSerializationData:
        return try? JSONSerialization.data(withJSONObject: bodyParamaters)
    case .stringEncodingAscii:
        // 아래와 같은 문자열 형식에서 직렬화
        // "UserName=\(txtemailaddress.text!)&Password=\(txtpassword.text!)&Grant_type=password&DeviceID=\(DEVICE_TOKEN as! String)&DeviceType=IOS"
        return bodyParamaters.queryString.data(using: String.Encoding.ascii, allowLossyConversion: true)
    }
}
  • Encodable: Data형을 Dictionary형으로 언아카이빙
private extension Encodable {
    /// queryParameter나 bodyParameter를 딕셔너리 형태로 변경하여 URLComponent에 추가하기 위해 사용
    func toDictionary() throws -> [String: Any]? {
        let data = try JSONEncoder().encode(self)
        let josnData = try JSONSerialization.jsonObject(with: data)
        return josnData as? [String : Any]
    }
}
  • Requestable, PresponseRequestable, Endpoint 정의
public protocol Requestable {
    var path: String { get }
    var isFullPath: Bool { get }
    var method: HTTPMethodType { get }
    var headerParamaters: [String: String] { get }
    var queryParametersEncodable: Encodable? { get }
    var queryParameters: [String: Any] { get }
    var bodyParamatersEncodable: Encodable? { get }
    var bodyParamaters: [String: Any] { get }
    var bodyEncoding: BodyEncoding { get }

    func urlRequest(with networkConfig: NetworkConfigurable) throws -> URLRequest
}

public protocol ResponseRequestable: Requestable {
    associatedtype Response

    var responseDecoder: ResponseDecoder { get }
}

public class Endpoint<R>: ResponseRequestable {

    public typealias Response = R

    public let path: String
    public let isFullPath: Bool
    public let method: HTTPMethodType
    public let headerParamaters: [String: String]
    public let queryParametersEncodable: Encodable?
    public let queryParameters: [String: Any]
    public let bodyParamatersEncodable: Encodable?
    public let bodyParamaters: [String: Any]
    public let bodyEncoding: BodyEncoding
    public let responseDecoder: ResponseDecoder

    init(path: String,
         isFullPath: Bool = false,
         method: HTTPMethodType,
         headerParamaters: [String: String] = [:],
         queryParametersEncodable: Encodable? = nil,
         queryParameters: [String: Any] = [:],
         bodyParamatersEncodable: Encodable? = nil,
         bodyParamaters: [String: Any] = [:],
         bodyEncoding: BodyEncoding = .jsonSerializationData,
         responseDecoder: ResponseDecoder = JSONResponseDecoder()) {
        self.path = path
        self.isFullPath = isFullPath
        self.method = method
        self.headerParamaters = headerParamaters
        self.queryParametersEncodable = queryParametersEncodable
        self.queryParameters = queryParameters
        self.bodyParamatersEncodable = bodyParamatersEncodable
        self.bodyParamaters = bodyParamaters
        self.bodyEncoding = bodyEncoding
        self.responseDecoder = responseDecoder
    }
}

3) NetworkService

  • URLSession과 task를 이용하여 resume(), cancel()시키는 부분 (+ log 출력도 존재)
    • NetworkService: request, logger 사용
    • NetworkSessionManager: URLSession으로 URLSessionDataTask를 만든 후 resume()하는 부분
    • NetworkErrorLogger: request, response, error에 관한 log를 정의하는 부분

  • 필요한 기본 데이터 형 정의
public enum NetworkError: Error {
    case error(statusCode: Int, data: Data?)
    case notConnected
    case cancelled
    case generic(Error)
    case urlGeneration
}

// MARK: - NetworkError extension

extension NetworkError {
    public var isNotFoundError: Bool { return hasStatusCode(404) }

    public func hasStatusCode(_ codeError: Int) -> Bool {
        switch self {
        case let .error(code, _):
            return code == codeError
        default: return false
        }
    }
}

extension Dictionary where Key == String {
    func prettyPrint() -> String {
        var string: String = ""
        if let data = try? JSONSerialization.data(withJSONObject: self, options: .prettyPrinted) {
            if let nstr = NSString(data: data, encoding: String.Encoding.utf8.rawValue) {
                string = nstr as String
            }
        }
        return string
    }
}

func printIfDebug(_ string: String) {
    #if DEBUG
    print(string)
    #endif
}


public protocol NetworkCancellable {
    func cancel()
}
  • NetworkService: request, logger 사용
public protocol NetworkService {
    typealias CompletionHandler = (Result<Data?, NetworkError>) -> Void

    func request(endpoint: Requestable, completion: @escaping CompletionHandler) -> NetworkCancellable?
}

public final class DefaultNetworkService {

    private let config: NetworkConfigurable
    private let sessionManager: NetworkSessionManager
    private let logger: NetworkErrorLogger

    public init(config: NetworkConfigurable,
                sessionManager: NetworkSessionManager = DefaultNetworkSessionManager(),
                logger: NetworkErrorLogger = DefaultNetworkErrorLogger()) {
        self.sessionManager = sessionManager
        self.config = config
        self.logger = logger
    }

    private func request(request: URLRequest, completion: @escaping CompletionHandler) -> NetworkCancellable {

        let sessionDataTask = sessionManager.request(request) { data, response, requestError in

            if let requestError = requestError {
                var error: NetworkError
                if let response = response as? HTTPURLResponse {
                    error = .error(statusCode: response.statusCode, data: data)
                } else {
                    error = self.resolve(error: requestError)
                }

                self.logger.log(error: error)
                completion(.failure(error))
            } else {
                self.logger.log(responseData: data, response: response)
                completion(.success(data))
            }
        }

        logger.log(request: request)

        return sessionDataTask
    }

    private func resolve(error: Error) -> NetworkError {
        let code = URLError.Code(rawValue: (error as NSError).code)
        switch code {
        case .notConnectedToInternet: return .notConnected
        case .cancelled: return .cancelled
        default: return .generic(error)
        }
    }
}

extension DefaultNetworkService: NetworkService {

    public func request(endpoint: Requestable, completion: @escaping CompletionHandler) -> NetworkCancellable? {
        do {
            let urlRequest = try endpoint.urlRequest(with: config)
            return request(request: urlRequest, completion: completion)
        } catch {
            completion(.failure(.urlGeneration))
            return nil
        }
    }
}
  • NetworkSessionManager: URLSession을 가지고 URLSessionDataTask로 resume()하는 모듈
public protocol NetworkSessionManager {
    typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void

    func request(_ request: URLRequest,
                 completion: @escaping CompletionHandler) -> NetworkCancellable
}

public class DefaultNetworkSessionManager: NetworkSessionManager {
    public init() {}
    public func request(_ request: URLRequest,
                        completion: @escaping CompletionHandler) -> NetworkCancellable {
        let task = URLSession.shared.dataTask(with: request, completionHandler: completion)
        task.resume()
        return task
    }
}
  • NetworkErrorLogger
public protocol NetworkErrorLogger {
    func log(request: URLRequest)
    func log(responseData data: Data?, response: URLResponse?)
    func log(error: Error)
}

public final class DefaultNetworkErrorLogger: NetworkErrorLogger {
    public init() { }

    public func log(request: URLRequest) {
        print("-------------")
        print("request: \(request.url!)")
        print("headers: \(request.allHTTPHeaderFields!)")
        print("method: \(request.httpMethod!)")
        if let httpBody = request.httpBody, let result = ((try? JSONSerialization.jsonObject(with: httpBody, options: []) as? [String: AnyObject]) as [String: AnyObject]??) {
            printIfDebug("body: \(String(describing: result))")
        } else if let httpBody = request.httpBody, let resultString = String(data: httpBody, encoding: .utf8) {
            printIfDebug("body: \(String(describing: resultString))")
        }
    }

    public func log(responseData data: Data?, response: URLResponse?) {
        guard let data = data else { return }
        if let dataDict = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
            printIfDebug("responseData: \(String(describing: dataDict))")
        }
    }

    public func log(error: Error) {
        printIfDebug("\(error)")
    }
}

4) DataTransferService

  • NetworkService에서 불리는 request를 wrapping하여, response로 받은 Data형을 decode시키는 모듈

  • 필요한 기본 데이터형 정의
// MARK: - Base

public enum DataTransferError: Error {
    case noResponse
    case parsing(Error)
    case networkFailure(NetworkError)
    case resolvedNetworkFailure(Error)
}

/// Request에서 error응답을 받은 경우의 처리하는 protocol
public protocol DataTransferErrorResolver {
    func resolve(error: NetworkError) -> Error
}

public class DefaultDataTransferErrorResolver: DataTransferErrorResolver {
    public init() { }
    public func resolve(error: NetworkError) -> Error {
        return error
    }
}

/// 언아카이빙: Response로 온 Data형을 struct형으로 변경하는 protocol
public protocol ResponseDecoder {
    func decode<T: Decodable>(_ data: Data) throws -> T
}

public class JSONResponseDecoder: ResponseDecoder {
    private let jsonDecoder = JSONDecoder()
    public init() { }
    public func decode<T: Decodable>(_ data: Data) throws -> T {
        return try jsonDecoder.decode(T.self, from: data)
    }
}

public class RawDataResponseDecoder: ResponseDecoder {
    public init() { }

    enum CodingKeys: String, CodingKey {
        case `default` = ""
    }
    public func decode<T: Decodable>(_ data: Data) throws -> T {
        if T.self is Data.Type, let data = data as? T {
            return data
        } else {
            let context = DecodingError.Context(codingPath: [CodingKeys.default], debugDescription: "Expected Data type")
            throw Swift.DecodingError.typeMismatch(T.self, context)
        }
    }
}

public protocol DataTransferErrorLogger {
    func log(error: Error)
}

public final class DefaultDataTransferErrorLogger: DataTransferErrorLogger {
    public init() { }

    public func log(error: Error) {
        printIfDebug("-------------")
        printIfDebug("\(error)")
    }
}
  • DataTasnsferService 정의
public protocol DataTransferService {
    typealias CompletionHandler<T> = (Result<T, DataTransferError>) -> Void

    @discardableResult
    func request<T: Decodable, E: ResponseRequestable>(with endpoint: E,
                                                       completion: @escaping CompletionHandler<T>) -> NetworkCancellable? where E.Response == T
    @discardableResult
    func request<E: ResponseRequestable>(with endpoint: E,
                                         completion: @escaping CompletionHandler<Void>) -> NetworkCancellable? where E.Response == Void
}

public final class DefaultDataTransferService {

    private let networkService: NetworkService
    private let errorResolver: DataTransferErrorResolver
    private let errorLogger: DataTransferErrorLogger

    public init(with networkService: NetworkService,
                errorResolver: DataTransferErrorResolver = DefaultDataTransferErrorResolver(),
                errorLogger: DataTransferErrorLogger = DefaultDataTransferErrorLogger()) {
        self.networkService = networkService
        self.errorResolver = errorResolver
        self.errorLogger = errorLogger
    }
}

extension DefaultDataTransferService: DataTransferService {

    public func request<T: Decodable, E: ResponseRequestable>(with endpoint: E,
                                                              completion: @escaping CompletionHandler<T>) -> NetworkCancellable? where E.Response == T {

        return self.networkService.request(endpoint: endpoint) { result in
            switch result {
            case .success(let data):
                let result: Result<T, DataTransferError> = self.decode(data: data, decoder: endpoint.responseDecoder)
                DispatchQueue.main.async { return completion(result) }
            case .failure(let error):
                self.errorLogger.log(error: error)
                let error = self.resolve(networkError: error)
                DispatchQueue.main.async { return completion(.failure(error)) }
            }
        }
    }

    public func request<E>(with endpoint: E, completion: @escaping CompletionHandler<Void>) -> NetworkCancellable? where E : ResponseRequestable, E.Response == Void {
        return self.networkService.request(endpoint: endpoint) { result in
            switch result {
            case .success:
                DispatchQueue.main.async { return completion(.success(())) }
            case .failure(let error):
                self.errorLogger.log(error: error)
                let error = self.resolve(networkError: error)
                DispatchQueue.main.async { return completion(.failure(error)) }
            }
        }
    }

    // MARK: - Private
    private func decode<T: Decodable>(data: Data?, decoder: ResponseDecoder) -> Result<T, DataTransferError> {
        do {
            guard let data = data else { return .failure(.noResponse) }
            let result: T = try decoder.decode(data)
            return .success(result)
        } catch {
            self.errorLogger.log(error: error)
            return .failure(.parsing(error))
        }
    }

    private func resolve(networkError error: NetworkError) -> DataTransferError {
        let resolvedError = self.errorResolver.resolve(error: error)
        return resolvedError is NetworkError ? .networkFailure(error) : .resolvedNetworkFailure(resolvedError)
    }
}

Network 사용 방법

  • Request, Response 모델 정의
    • DTO 개념 사용: json response를 domain에 맞는 struct모델로 변경하기 위한 모델

  • APIEndpoint와 Repository 정의
    • APIEndpoint: 사용하고 싶은 API의 Endpoint를 미리 정의한 factory 모듈
    • Repository: Endpoint객체와 DataTasnferService 객체를 이용하여 request하고 CoreData와 같은 repository로 캐시하는 모듈

  • APIEndpoints
struct APIEndpoints {
    
    static func getMovies(with moviesRequestDTO: MoviesRequestDTO) -> Endpoint<MoviesResponseDTO> {

        return Endpoint(path: "3/search/movie/",
                        method: .get,
                        queryParametersEncodable: moviesRequestDTO)
    }

    static func getMoviePoster(path: String, width: Int) -> Endpoint<Data> {

        let sizes = [92, 154, 185, 342, 500, 780]
        let closestWidth = sizes.enumerated().min { abs($0.1 - width) < abs($1.1 - width) }?.element ?? sizes.first!
        
        return Endpoint(path: "t/p/w\(closestWidth)\(path)",
                        method: .get,
                        responseDecoder: RawDataResponseDecoder())
    }
}
  • Repository
    • 주의: protocol과 구현체는 각기 다른 그룹에 위치 - 구현체가 network와 disk저장하는 로직을 의존하는 부분이 있기때문에 그룹&파일로 분리
// Domain/Interfaces/Repositories 그룹하위에 존재

protocol MoviesRepository {
    @discardableResult
    func fetchMoviesList(query: MovieQuery, page: Int,
                         cached: @escaping (MoviesPage) -> Void,
                         completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable?
}
  • RespositoryImpl
// Data/Network 그룹 하위에 존재

final class DefaultMoviesRepository {

    private let dataTransferService: DataTransferService
    private let cache: MoviesResponseStorage

    init(dataTransferService: DataTransferService, cache: MoviesResponseStorage) {
        self.dataTransferService = dataTransferService
        self.cache = cache
    }
}

extension DefaultMoviesRepository: MoviesRepository {

    public func fetchMoviesList(query: MovieQuery, page: Int,
                                cached: @escaping (MoviesPage) -> Void,
                                completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {

        let requestDTO = MoviesRequestDTO(query: query.query, page: page)
        let task = RepositoryTask()

        cache.getResponse(for: requestDTO) { result in

            if case let .success(responseDTO?) = result {
                cached(responseDTO.toDomain())
            }
            guard !task.isCancelled else { return }

            let endpoint = APIEndpoints.getMovies(with: requestDTO)
            task.networkTask = self.dataTransferService.request(with: endpoint) { result in
                switch result {
                case .success(let responseDTO):
                    self.cache.save(response: responseDTO, for: requestDTO)
                    completion(.success(responseDTO.toDomain()))
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        }
        return task
    }
}

네트워크 테스트 코드 작성

  • DIP를 준수하여 네트워크도 testable하므로 테스트코드 작성에 용이
  • 네트워크 구조가 정상적으로 동작하는지 테스트코드 작성 (다른 API호출 테스트도 용이)

1) Mock 데이터 생성

  • NetworkSessionManagerMock: URLSession을 가지고 생성된 URLSessionDataTask를 리턴하는 코드
@testable import SOLID

struct NetworkSessionManagerMock: NetworkSessionManager {
    let response: HTTPURLResponse?
    let data: Data?
    let error: Error?

    func request(_ request: URLRequest,
                 completion: @escaping CompletionHandler) -> NetworkCancellable {
        completion(data, response, error)
        return URLSessionDataTask()
    }
}
  • NetworkConfigurableMock: baseURL, header, queryParameter를 가지고 있는 모듈
@testable import SOLID

class NetworkConfigurableMock: NetworkConfigurable {
    var baseURL: URL = URL(string: "https://mock.test.com")!
    var headers: [String: String] = [:]
    var queryParameters: [String: String] = [:]
}

2) 테스트 코드 - NetworkService 

  • 목적: NetworkService 모듈의 기능인 request에 관한 response, error 처리 테스트
  • 기본 test 클래스 생성
@testable import SOLID

class NetworkServiceTests: XCTestCase {

}
  • NetworkErrorLoggerMock 클래스 생성
class NetworkErrorLoggerMock: NetworkErrorLogger {
    var loggedErrors: [Error] = []
    func log(request: URLRequest) { }
    func log(responseData data: Data?, response: URLResponse?) { }
    func log(error: Error) { loggedErrors.append(error) }
}

private enum NetworkErrorMock: Error {
    case someError
}
  • mock 데이터를 이용한 request 시 response가 잘 오는지 테스트
func test_whenMockDataPassed_shouldReturnProperResponse() {
    //given
    let config = NetworkConfigurableMock()
    let expectation = self.expectation(description: "Should return correct data")

    let expectedResponseData = "Response data".data(using: .utf8)!
    let sut = DefaultNetworkService(config: config,
                                    sessionManager: NetworkSessionManagerMock(response: nil,
                                                                              data: expectedResponseData,
                                                                              error: nil))
    //when
    _ = sut.request(endpoint: Endpoint<Void>(path: "http://mock.test.com", method: .get), completion: { result in
        guard let responseData = try? result.get() else {
            XCTFail("Should return proper response")
            return
        }
        XCTAssertEqual(responseData, expectedResponseData)
        expectation.fulfill()
    })

    //then
    wait(for: [expectation], timeout: 0.1)
}
  • error를 request에 담아서 response에서 error가 잘 떨어지는지 확인하는 코드
func test_whenErrorWithNSURLErrorCancelledReturned_shouldReturnCancelledError() {
    //given
    let config = NetworkConfigurableMock()
    let expectation = self.expectation(description: "Should return hasStatusCode error")

    let cancelledError = NSError(domain: "network", code: NSURLErrorCancelled, userInfo: nil)
    let sut = DefaultNetworkService(config: config, sessionManager: NetworkSessionManagerMock(response: nil,
                                                                                              data: nil,
                                                                                              error: cancelledError as Error))
    //when
    _ = sut.request(endpoint: Endpoint<Void>(path: "http://mock.test.com", method: .get)) { result in
        do {
            _ = try result.get()
            XCTFail("Should not happen")
        } catch let error {
            guard case NetworkError.cancelled = error else {
                XCTFail("NetworkError.cancelled not found")
                return
            }

            expectation.fulfill()
        }
    }
    //then
    wait(for: [expectation], timeout: 0.1)
}
  • url의 path에 잘못된 값이 들어간 경우 error로 정상적으로 응답받는지 테스트
func test_whenMalformedUrlPassed_shouldReturnUrlGenerationError() {
    //given
    let config = NetworkConfigurableMock()
    let expectation = self.expectation(description: "Should return correct data")

    let expectedResponseData = "Response data".data(using: .utf8)!
    let sut = DefaultNetworkService(config: config, sessionManager: NetworkSessionManagerMock(response: nil,
                                                                                              data: expectedResponseData,
                                                                                              error: nil))
    //when
    _ = sut.request(endpoint: Endpoint<Void>(path: "-;@,?:ą", method: .get)) { result in
        do {
            _ = try result.get()
            XCTFail("Should throw url generation error")
        } catch let error {
            guard case NetworkError.urlGeneration = error else {
                XCTFail("Should throw url generation error")
                return
            }

            expectation.fulfill()
        }
    }
    //then
    wait(for: [expectation], timeout: 0.1)
}
  • status code가 400이상인 경우 error로 정상적으로 응답받는지 테스트
func test_whenStatusCodeEqualOrAbove400_shouldReturnhasStatusCodeError() {
    //given
    let config = NetworkConfigurableMock()
    let expectation = self.expectation(description: "Should return hasStatusCode error")

    let response = HTTPURLResponse(url: URL(string: "test_url")!,
                                   statusCode: 500,
                                   httpVersion: "1.1",
                                   headerFields: [:])
    let sut = DefaultNetworkService(config: config, sessionManager: NetworkSessionManagerMock(response: response,
                                                                                              data: nil,
                                                                                              error: NetworkErrorMock.someError))
    //when
    _ = sut.request(endpoint: Endpoint<Void>(path: "http://mock.test.com", method: .get)) { result in
        do {
            _ = try result.get()
            XCTFail("Should not happen")
        } catch let error {
            if case NetworkError.error(let statusCode, _) = error {
                XCTAssertEqual(statusCode, 500)
                expectation.fulfill()
            }
        }
    }
    //then
    wait(for: [expectation], timeout: 0.1)
}
  • internet connection error 발생
func test_whenErrorWithNSURLErrorNotConnectedToInternetReturned_shouldReturnNotConnectedError() {
    //given
    let config = NetworkConfigurableMock()
    let expectation = self.expectation(description: "Should return hasStatusCode error")

    let error = NSError(domain: "network", code: NSURLErrorNotConnectedToInternet, userInfo: nil)
    let sut = DefaultNetworkService(config: config, sessionManager: NetworkSessionManagerMock(response: nil,
                                                                                              data: nil,
                                                                                              error: error as Error))

    //when
    _ = sut.request(endpoint: Endpoint<Void>(path: "http://mock.test.com", method: .get)) { result in
        do {
            _ = try result.get()
            XCTFail("Should not happen")
        } catch let error {
            guard case NetworkError.notConnected = error else {
                XCTFail("NetworkError.notConnected not found")
                return
            }

            expectation.fulfill()
        }
    }
    //then
    wait(for: [expectation], timeout: 0.1)
}
  • status code 관련 테스트
func test_whenhasStatusCodeUsedWithWrongError_shouldReturnFalse() {
    //when
    let sut = NetworkError.notConnected
    //then
    XCTAssertFalse(sut.hasStatusCode(200))
}

func test_whenhasStatusCodeUsed_shouldReturnCorrectStatusCode_() {
    //when
    let sut = NetworkError.error(statusCode: 400, data: nil)
    //then
    XCTAssertTrue(sut.hasStatusCode(400))
    XCTAssertFalse(sut.hasStatusCode(399))
    XCTAssertFalse(sut.hasStatusCode(401))
}

3) 테스트 코드 - DataTransferService

  • 목적: NetworkService에서 불리는 request를 wrapping하여 동작하고, response로 받은 Data형을 decode시키는 모듈을 테스트
  • 테스트 방법: Decodable프로토콜을 준수하는 mock 모델 struct형을 정의해놓고, response들에 대해서 decode를 잘 수행하는지 테스트
  • 기본 test 클래스 생성
// decode가 잘 동작하는지에 대해 필요한 모델
private struct MockModel: Decodable {
    let name: String
}

class DataTransferServiceTests: XCTestCase {

    private enum DataTransferErrorMock: Error {
        case someError
    }
    
}
  • decode테스트 - response data 형식이 맞는 경우 디코딩이 정상 동작하는지 테스트
func test_whenReceivedValidJsonInResponse_shouldDecodeResponseToDecodableObject() {
    //given
    let config = NetworkConfigurableMock()
    let expectation = self.expectation(description: "Should decode mock object")

    let responseData = #"{"name": "Hello"}"#.data(using: .utf8)
    let networkService = DefaultNetworkService(config: config, sessionManager: NetworkSessionManagerMock(response: nil,
                                                                                                         data: responseData,
                                                                                                         error: nil))

    let sut = DefaultDataTransferService(with: networkService)
    //when
    _ = sut.request(with: Endpoint<MockModel>(path: "http://mock.endpoint.com", method: .get)) { result in
        do {
            let object = try result.get()
            XCTAssertEqual(object.name, "Hello")
            expectation.fulfill()
        } catch {
            XCTFail("Failed decoding MockObject")
        }
    }
    //then
    wait(for: [expectation], timeout: 0.1)
}
  • 정의한 데이터 형식과 다르게 response가 내려오는 경우 decode에러가 발생하는지 테스트
func test_whenInvalidResponse_shouldNotDecodeObject() {
    //given
    let config = NetworkConfigurableMock()
    let expectation = self.expectation(description: "Should not decode mock object")

    let responseData = #"{"age": 20}"#.data(using: .utf8)
    let networkService = DefaultNetworkService(config: config, sessionManager: NetworkSessionManagerMock(response: nil,
                                                                                                         data: responseData,
                                                                                                         error: nil))

    let sut = DefaultDataTransferService(with: networkService)
    //when
    _ = sut.request(with: Endpoint<MockModel>(path: "http://mock.endpoint.com", method: .get)) { result in
        do {
            _ = try result.get()
            XCTFail("Should not happen")
        } catch {
            expectation.fulfill()
        }
    }
    //then
    wait(for: [expectation], timeout: 0.1)
}
  • response에 데이터가 비어있는 경우 noResponse에러로 잘 처리하는지 테스트
func test_whenNoDataReceived_shouldThrowNoDataError() {
    //given
    let config = NetworkConfigurableMock()
    let expectation = self.expectation(description: "Should throw no data error")

    let response = HTTPURLResponse(url: URL(string: "test_url")!,
                                   statusCode: 200,
                                   httpVersion: "1.1",
                                   headerFields: [:])
    let networkService = DefaultNetworkService(config: config, sessionManager: NetworkSessionManagerMock(response: response,
                                                                                                         data: nil,
                                                                                                         error: nil))

    let sut = DefaultDataTransferService(with: networkService)
    //when
    _ = sut.request(with: Endpoint<MockModel>(path: "http://mock.endpoint.com", method: .get)) { result in
        do {
            _ = try result.get()
            XCTFail("Should not happen")
        } catch let error {
            if case DataTransferError.noResponse = error {
                expectation.fulfill()
            } else {
                XCTFail("Wrong error")
            }
        }
    }
    //then
    wait(for: [expectation], timeout: 0.1)
}

Network를 사용하는 예제 코드

  • API호출을 통하여 얻은 name과 subname을 tableView에 표출하는 앱
    • network부분: CityRepository, CityRepositoryImpl

  • DTO 사용: CityRepository와 같은 Interface에서 DTO에 의존하지 않고(존재 이유를 모르고) 내부적으로 구현체에서만 의존하여 사용하게끔 설계
    • 이유: 데이터를 변환하는 로직을 변경해도 핵심 모듈이 의존하고 있는 cityRepository 인터페이스는 변경되지 않게 함으로서 안정된 코드를 위함

* 전체 소스 코드 (SOLID/NetworkExample그룹 하위에 구현): https://github.com/JK0369/SOLID

 

* 참고:  https://github.com/kudoleh/iOS-Clean-Architecture-MVVM

 

Comments