Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
Tags
- collectionview
- combine
- Observable
- 클린 코드
- Human interface guide
- rxswift
- 스위프트
- swift documentation
- clean architecture
- SWIFT
- 리팩토링
- map
- Xcode
- Refactoring
- Clean Code
- ribs
- 리펙터링
- tableView
- UITextView
- swiftUI
- uitableview
- Protocol
- 리펙토링
- uiscrollview
- UICollectionView
- MVVM
- RxCocoa
- 애니메이션
- HIG
- ios
Archives
- Today
- Total
김종권의 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:290. 코드로 알아보는 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 만드는 방법
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
'Clean Architecture > Clean Architecture 코드' 카테고리의 다른 글
Comments