Networking Test
요즘에는 Network를 사용하지 않는 앱을 찾아보기 힘들 정도로 Network를 빼놓고서 앱을 얘기하기 힘들다.
따라서, Network 작업을 처리하는 로직을 Test하는 것도 매우 중요한 일 중 하나가 되었다.
하지만, Server의 응답을 받아서 처리를 해야하는 Network작업은 Test시에 Server의 상태에 상당히 의존적일 수 밖에 없다.이는 Test가 빠르지 못하고, 독립적이지 못해서 같은 요청을 보내더라도 똑같은 결과를 받지 못한다는 의미가 된다.(F.I.R.S.T 원칙)
어떻게 iOS에서 위 문제를 해결 할 수 있을지 Network작업을 처리하는 가장 기본적인 방법중 하나인 URLSession을 이용해서 알아보자.
Network 작업이 진행되는 과정
모두 같지는 않겠지만, 보통 Network작업이 처리되는 과정은 이렇다.
1. URLRequest를 준비 및 생성 한다.
2. 준비된 URLRequest와 URLSession을 이용해서 dataTask를 만든다.
3. Server에게 URLRequest에 맞는 작업을 요청하고, Server의 응답을 받는다.
4. 성공적이었다면 응답을 Parsing하고 그렇지 않다면, Error를 낸다.
5. Parsing된 Data를 가지고 View를 업데이트 한다.
결국, 우리가 처리를 해야하는 과정은 3번 과정이 될 것이다.
URLProtocol
들어가기 앞서.. URLProtocol은 Class입니다...
어떻게 Server없이 response를 받을 수 있을까??
URLProtocol을 잘 활용하면 된다는데...
WWDC의 How to Use URLProtocol 내용을 참고해보자.
보통 우리가 URLSession을 사용해서 networkRequest를 처리하는 DataTask Flow는 이렇게 된다.
1. URLSessionConfiguration -> URLSession -> URLSessionDataTask 순으로 만든다.
2. Request가 들어온다.
3. Request에 맞는 response를 받는다.
WWDC에 따르면, 사실, 이 과정 뒤에서 좀더 Low-level한 API가 하나 더 있다고 한다.
그게 바로 URLProtocol.
Openning Network Connection, Writing the request, Reading Back Response 등의 역할을 한다고 한다.
그리고 우리가 만든 URLProtocol의 subclass를 URLSessionConfiguration에 넣을 수 있다.
configuration.protocolClasses = [TestURLProtocol.self]
그래서요??
이를 이용하면 우리는 Network 작업을 Hook(가로채기?)할 수 있다.
우리가 request 요청을 시작하면, 시스템 내부적으로 해당 request를 처리하도록 등록되어 있는
URLProtocol subclass가 있는지 검사하는데, 만약, 등록된 subclass가 있다면 네트워크 작업을 완료할 책임을 해당 subclass에 넘길수 있다.
따라서, 우리는 URLProtocol을 이용해 request를 처리할 subclass를 만들 수 있고 처리 할 수 있다.
그림으로 한번 더 살펴보자.
1. URLSessionConfiuguration을 만든다
2. Configuration에 내가 만든 Custom URLProtocol을 넣는다.
3. Configuaration으로 URLSession을 만든다.
4. 만든 URLSession에 Request를 보낸다.
5.Protocols 안에 해당 request 처리를 담당할 URLProtocol의 Subclass들이 있는지 확인하고 처리한다.
6. 그에 맞는 Response를 내보낸다.
이로써... 우리는 우리만의 규칙(URLProtocol subclass)을 이용해 Request를 처리할 수 있게됬다.
심지어 서버가 없어도!
URLProtocol & URL Loading System 좀더 알아보기.
URLProtocol은 URL Loading System의 확장성을 위해 만들어진 추상적인 클래스로 직접 Class를 만들지 않고
subclass해서 사용한다.
URL Loading System?
URL과 상호 작용하고 표준 인터넷 프로토콜을 사용하여 서버와 통신하는 Sytem Foundation에 기본적으로 내장되어있다.
Stanard Internet Protocol(Ex Https..)이나 우리가 만든 custom Protocol를 이용해서,
URL Loading System은 URL로 identified된 resource에 접근 하게 해준다.
유의!!
Protocol의 의미에 주의하자.
우리가 사는 세상에서 Protocol이란 규격, 약속 등을 의미한다. 따라서 Standard Internt Protocol은 인터넷 통신 규격, 약속이다.
Swift에서 Protocol은 특정 메소드, 프로퍼티등을 가질 수 있는 추상 타입이다.
URL Loading System이 Stanard Internet Protocol 이나 Custom Protocol을 사용한다는 말은
어떠한 Reqeust가 들어왔을때 어떠한 약속이나 규격에 따라 response를 주면 된다는 의미.
따라서, URLProtocol은 Protocol이 아니라 Class인데 어떻게 대체가 가능해요? 라는 말은 맞지 않다.
Protocol이든 Class이든 Reqeust를 처리할 규격(Protocol)이 필요한 것이고 Swift는 이를 추상 Class로 만들어 놨을 뿐이다.
요약
- URL Loading System은 여러 Protocol들을 이용해서 URL로 검증된 resource에 접근을 가능하게 해준다.
- Foundation의 URL Loading Sytem에는 여러 기본 Protocol(Built in protocol)들이 있다.
- URL Loading System의 기능을 확장하기 위해서 URLProtocol이라는 추상클래스를 사용한다.
- Server없이 Network Testing을 하려면 특정 reqeust를 처리하기 위한 URLProtocol subclass를 생성, 조작해야한다.
마지막으로. URLProtocol은 URLProtocolClient에게 다시 Progress를 다시 전달함.
URLProtocolClient?
URLProtocol의 SubClass들이 URL Loading Sytem과 소통하기 위해 사용하는 interface이다.
How to Use it?
이제 개념은 알겠는데(아마도...) 어떻게 사용하나??
마찬가지로 WWDC의 예제를 살펴보자.
URLProtocol을 Subclassing한 MockURLProtocol을 하나 만든다.
그리고 네개의 함수를 override해야한다.
1. caninit(with request: URLRequest) -> Bool)
URLProtocol의 subClass가 특정한 작업을 처리할 수 있는지 여부
왠만하면 return true로 두면 된다.
2. canonicalRequest(for request: URLRequest) -> URLRequest
request를 canonical(표준,기준?)하게 바꾸어 준다.
보통 request 그대로 반환하면 된다고 한다.
3. StartLoading
Request가 시작되는 Method
이 Method안에서 URLProtocolClient 프로토콜을 통해 URL로드 시스템에 피드백을 제공함.
4. StopLoading
Request가 끝났을때 혹은 중지 되었을때 처리할 행동을 정의하는 Method
실제 구현 모습
배운 내용을 토대로 진행하던 Toy 프로젝트에 적용을 시켜보았다. (틀린내용이 있을수 있습니다!)
TestURLProtocol
1. request를 받아서 (HTTPPURLResponse, Data?, Error?)를 리턴하는 클로저를 정의함. - loadingHandler
2. startLoading
- request를 이용해 loadingHandler를 실행하고 response, data, error를 변수에 저장함.
- 받은 response, data, error를 client에게 알려줌.
final class TestURLProtocol:URLProtocol {
static var loadingHandler: ((URLRequest) -> (HTTPURLResponse, Data?, Error?))?
override class func canInit(with request: URLRequest) -> Bool {
return true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
override func startLoading() {
guard let handler = TestURLProtocol.loadingHandler else { return }
let (response, data, error) = handler(request)
if let data = data {
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
}
else {
client?.urlProtocol(self, didFailWithError: error!)
}
}
override func stopLoading() {}
}
NetworkManager
Network처리를 하는 Class이다.
여기서 중요한 점은 init시 Session을 매개변수로 받는다는 것이다.
Test시 의존성 주입함으로써 Test시 용이하게 코드를 짤 수 있다.
final class NetworkManager {
private var session:URLSession
init(session:URLSession) {
self.session = session
}
func request<T:Decodable>(endpoint:Endpointable, completionHandler: @escaping((Result<T,NetworkError>) -> Void)) {
//handling urlError
let endpointURL = endpoint.getURL()
guard let url = URL(string:endpointURL) else {
completionHandler(.failure(.invalidURL))
return
}
var urlRequest = URLRequest(url: url)
//HTTP Method
let httpMethod = endpoint.getHttpMethod().rawValue
urlRequest.httpMethod = httpMethod
//HTTP header
let headers = endpoint.getHeaders()
headers?.forEach { urlRequest.setValue($1 as? String, forHTTPHeaderField: $0) }
//handling encodingError if endpoint has body
if let postBody = endpoint.getBody() {
do {
let body = try JSONSerialization.data(withJSONObject: postBody, options: [])
urlRequest.httpBody = body
}
catch {
completionHandler(.failure(.encodingError))
}
}
dataTask(urlRequest: urlRequest, completionHandler: completionHandler)
}
func dataTask<T:Decodable>(urlRequest: URLRequest, completionHandler: @escaping((Result<T,NetworkError>) -> Void)) {
let dataTask = session.dataTask(with: urlRequest) { [weak self] data, response, error in
guard let self = self else { return }
//handling transportError
if let error = error {
completionHandler(.failure(.transportError(error)))
return
}
//handling NoDataError
guard let data = data else {
completionHandler(.failure(.noData))
return
}
//handling ServerError
guard let statusCode = self.getStatusCode(response: response) else { return }
guard 200..<300 ~= statusCode else {
completionHandler(.failure(.serverError(statusCode: statusCode)))
return
}
//handling DecodingError
do {
let deleteCase:Any = "DELETE"
if urlRequest.httpMethod == HTTPMethod.delete.rawValue {
completionHandler(.success(deleteCase as! T))
return
}
let fetchedData = try JSONDecoder().decode(T.self, from: data)
completionHandler(.success(fetchedData))
}
catch {
completionHandler(.failure(.decodingError))
}
}
dataTask.resume()
}
private func getStatusCode(response:URLResponse?) -> Int? {
guard let httpResponse = response as? HTTPURLResponse else { return nil }
return httpResponse.statusCode
}
}
struct NoDecode {
let noDecode:String = "noDecode"
}
Test
1. MockData를 만든다. 이 Data는 Test성공시 data로 오기로 기대되는 값이다.
2. TestURLProtocol의 LoadingHandler를 만든다.
3. TestURLProtocol을 가진 configuration을 만든다.
4. NetworkManager에 3번으로 만든 URLSession을 주입한다.
5. Test를 실행한다.
func testRequest() {
//MockData
guard let path = Bundle.main.path(forResource: "MockJsonData", ofType: "json") else {
XCTFail("Mock Data Path Error")
return
}
guard let jsonString = try? String(contentsOfFile: path) else {
XCTFail("Mock Data String Path Error")
return
}
guard let mockdata = jsonString.data(using: .utf8) else {
XCTFail("encode Mock Data Error")
return
}
//Decoded MockData with specific type
guard let expected = try? JSONDecoder().decode(NetworkResult.self, from: mockdata) else {
XCTFail("Decode Mock Data Error")
return
}
//mockEndpoint
let mockEndPoint = Endpoint(httpMethod: .get,
baseURL: .main,
path: .get,
headers: ["Content-Type": "application/json"],
body: nil
)
//loadingHandler 만들기
TestURLProtocol.loadingHandler = { request in
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response,mockdata,nil)
}
//Make networkManger with session for test
let expectation = XCTestExpectation(description: "Loading")
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [TestURLProtocol.self]
//Dependency injection
let networkmanager = NetworkManager(session: URLSession(configuration: configuration))
networkmanager.request(endpoint: mockEndPoint) { (result:Result<NetworkResult,NetworkError>) in
switch result {
case .failure(let error):
XCTFail("Request was not successful: \(error.localizedDescription)")
case .success(let result):
XCTAssertEqual(result, expected)
}
expectation.fulfill()
}
wait(for: [expectation], timeout: 1)
}
내 생각
- 네트워크 Test는 쉽지않다.
- URLSession을 정말 자주 사용했는데 그 밑단에서 어떠한 일이 일어나는지에 대한 이해가 조금이나마 더 된것 같다.
- 위 코드는 성공사례만 Test했는데, 만약 실패사례까지 모두 Test하려하면 정말 쉽지 않을 것 같다.
- 어려운 네트워크 작업을 Moya나 OHHTTPStubs 를 활용하면 더 직관적이고 편하게 작업을 할수 있다고 한다. 도구를 잘 활용해보자.
참고:
피드백은 언제나 환영입니다 😃
'iOS' 카테고리의 다른 글
Method Swizzling in iOS (0) | 2022.07.10 |
---|---|
iOS의 뷰가 그려지는 과정 (0) | 2022.06.19 |
iOS Cache (2) | 2022.04.24 |
Responder Chain (3) | 2022.03.29 |
iOS에서 일어난 touch가 처리되는 과정. (0) | 2022.03.29 |
댓글