네트워크 통신이 되지 않는 환경에서도 나머지 데이터를 처리하는 로직이 잘 작동하는지 테스트할 수 있는 코드를 만들기 위해서
URLSession 과 동일한 역할을 하는 객체를 주입(의존성 주입)하여 사용하는 방법에 대해 알아보자.
URLSession은 dataTask(with:) 메서드로 네트워크 통신한 결과 나오는 data, response, error를 completion handler로 전달한다.
<dataTask(with:) 메서드 정의부>
func dataTask(with request: URLRequest,
completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
사실 이 주제를 공부하기 전에는 dataTask(with:) 메서드 자체를 실행시,, 우리가 구현해주고 있다고 생각했었는데 (지금 생각해보면 말도 안되는 생각이네)
그게 아니라 dataTask(with:) 는 별개고 그 메서드를 실행하는 곳에서 클로저를 구현함으로써 결과에 대한 후처리를 해주고 있다는 사실을 깨달았다.
그래서 우리가 보통 네트워크 통신할때 쓰는 이 코드에서 우리는 dataTask(with:) 메서드가 넘겨준 data, response, error를 어떻게 처리할지에 대한 부분만 구현해주면 되는 것이었다.
<dataTask(with:) 메서드 실행부>
URLSession.shared.dataTask(with: request) { data, response, error in
// dataTask로부터 전달받은 data, response, error를
// 우리가 어떻게 처리할지 구현하는 부분
}
근데 이제 진짜 dataTask(with:)를 사용해서 네트워크 통신을 하는게 아니라 가짜 dataTask(with:)를 구현해서네트워크 통신이 성공여부를 조작해서 data, response, error를 결과로 넘겨주는 로직을 직접 구현해볼것이다.
네트워크 통신이 안되더라도 (data, response, error의 처리에 대한)나머지 로직이 잘 굴러가는지 확인해보기위해서이다!!
그니까 저 클로저 내부에 구현된 내용이 잘 굴러가는지 네트워크 통신의 성공유무와 상관없이 알고싶은 것이다.
준비사항으로 일단 기본적인 네트워크통신 코드가 필요하다.
설명: checkProductDetail 과 checkProductList에 공통으로 쓰이는 URLSession.shared.dataTask 메서드 부분을 따로 createDataTask 메서드로 분리한 코드이다.
class APIManager {
func checkProductDetail(id: Int, completion: @escaping (Result<ProductDetail, Error>) -> Void) {
guard let url = URLManager.checkProductDetail(id: id).url else { return }
let request = URLRequest(url: url, method: .get)
creatDataTask(with: request, completion: completion)
}
func checkProductList(pageNumber: Int, itemsPerPage: Int, completion: @escaping (Result<ProductList, Error>) -> Void) {
guard let url = URLManager.checkProductList(pageNumber: pageNumber, itemsPerPage: itemsPerPage).url else { return }
let request = URLRequest(url: url, method: .get)
creatDataTask(with: request, completion: completion)
}
func creatDataTask<T: Decodable>(with request: URLRequest, completion: @escaping (Result<T, Error>) -> Void) {
let task = URLSession.shared.dataTask(with: request) { data, response, error in
guard error == nil else {
completion(.failure(URLSessionError.requestFailed))
return
}
if let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode >= 300 {
completion(.failure(URLSessionError.responseFailed(code: httpResponse.statusCode)))
return
}
guard let data = data else {
completion(.failure(URLSessionError.invaildData))
return
}
guard let decodedData = JSONParser.decodeData(of: data, type: T.self) else {
completion(.failure(JSONError.dataDecodeFailed))
return
}
completion(.success(decodedData))
}
task.resume()
}
}
protocol URLSessionProtocol {
func dataTask(with request: URLRequest,
completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
}
extension URLSession: URLSessionProtocol {
}
왜 되어있냐.. URLSession 타입에 원래있는 dataTask(with:) 메서드를 똑같이 저 프로토콜에 정의를 해줬기 때문임...
아니 대체왜? 아까 네트워크 통신을 하게하지말고 우리가 구현해서 결과인 data, response, error만 넘겨주면 된다고 했잖아요.. 그거 구현해주려고 dataTask(with:) 메서드와 이름, 파라미터, 리턴값까지 똑같이 정의해서 네트워크 통신대신 작동하게 할것이기 때문이다
class MockURLSession: **URLSessionProtocol** {
//1
var makeRequestFail = false
init(makeRequestFail: Bool = false) {
self.makeRequestFail = makeRequestFail
}
//2
let sessionDataTask = MockURLSessionDataTask()
//3
func **dataTask**(with request: URLRequest,
completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
let successResponse = HTTPURLResponse(url: request.url!,
statusCode: 200,
httpVersion: "2",
headerFields: nil)
let failureResponse = HTTPURLResponse(url: request.url!,
statusCode: 410,
httpVersion: "2",
headerFields: nil)
//4
sessionDataTask.resumeDidCall = {
if self.makeRequestFail {
completionHandler(nil, failureResponse, nil)
} else {
completionHandler(MockData().data, successResponse, nil)
}
}
return sessionDataTask
}
}
//3
아.. 여기가 좀 복잡한데 일단 URLSessionProtocol 채택한 부분이랑 dataTask(with:) 구현부만 먼저 보자면...
URLSessionProtocol 채택했으니 dataTask(with:) 메서드 구현해야겠죠?
response랑 data 대충 만들어서 completionHandler로 던져준다.
//1
여기서 처음에 헷갈렸던게 파라미터로 받고있는 저 request의 url이 잘못되어도 전혀 상관이 없다는 부분이었는데... request가 뭐든간에 성공과 실패여부는 다 저 makeRequestFail flag 로 우리가 지정해주는 것이기때문이다.. 조작이지 그냥
애초에 우리가 구현했기때문에 성공하는 경우나 실패하는 경우 둘중하나 골라서 completionHandler로 보낼 수 있는것이다.
//1
var makeRequestFail = false
init(makeRequestFail: Bool = false) {
self.makeRequestFail = makeRequestFail
}
//4
일단 request가 실패하는 경우의 기본값을 false로 줘서 request 성공이 기본값이다.
아무것도 지정안하면 걍 이 밑의 completionHandler(MockData().data, successResponse, nil)
이 전달되는 것이다.
//4
sessionDataTask.resumeDidCall = {
if self.makeRequestFail {
completionHandler(nil, failureResponse, nil)
} else {
completionHandler(MockData().data, successResponse, nil)
}
}
//2
뜬금없이 MockURLSessionDataTask가 뭔가하는 생각이 들 것이다.
//2
let sessionDataTask = MockURLSessionDataTask()
class MockURLSessionDataTask: URLSessionDataTask {
var resumeDidCall: () -> Void = { }
override func resume() {
resumeDidCall()
}
}
아까 처음에 URLSessionDataTask 클래스를 상속받은 타입을 만들것이라고 했는데 이거다
resumeDidCall에는 클로저가 들어올건데... 일단 여기서는 타입만 지정해서 빈 껍데기만 만들어놓는다
그리고 URLSessionDataTask에 원래 있는 resume() 메서드가 불리면 resumeDidCall 클로저도 따라서 실행되도록 해놓는다
*resume() : URLSessionDataTask의 메서드. URLSession.shared.dataTask { }.resume() 으로 보통 많이 쓰는데 정지 상태의 task를 실행시키는 역할을 한다.
//4
sessionDataTask.resumeDidCall = {
if self.makeRequestFail {
completionHandler(nil, failureResponse, nil)
} else {
completionHandler(MockData().data, successResponse, nil)
}
}
return sessionDataTask
makeRequestFail 프로퍼티의 값이 false 냐 true 냐에 따라 completionHandler에 data나 nil을 보낸다.
여기서 사용한 MockData는 Asset에다가 JSON 파일을 넣고 불러와서 사용한 것이다.
struct MockData {
var data: Data {
return NSDataAsset(name: "products")!.data
}
}
class APIManager {
**let session: URLSessionProtocol
init(session: URLSessionProtocol = URLSession.shared) {
self.session = session
}**
func checkProductDetail(id: Int, completion: @escaping (Result<ProductDetail, Error>) -> Void) {
guard let url = URLManager.checkProductDetail(id: id).url else { return }
let request = URLRequest(url: url, method: .get)
creatDataTask(with: request, completion: completion)
}
func creatDataTask<T: Decodable>(with request: URLRequest, completion: @escaping (Result<T, Error>) -> Void) {
let task = **session**.dataTask(with: request) { data, response, error in
guard error == nil else {
completion(.failure(URLSessionError.requestFailed))
return
}
if let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode >= 300 {
completion(.failure(URLSessionError.responseFailed(code: httpResponse.statusCode)))
return
}
guard let data = data else {
completion(.failure(URLSessionError.invaildData))
return
}
guard let decodedData = JSONParser.decodeData(of: data, type: T.self) else {
completion(.failure(JSONError.dataDecodeFailed))
return
}
completion(.success(decodedData))
}
task.resume()
}
}
네트워크 통신을 관리하는 APIManager에 원래는 URLSession.shared.dataTask를 사용했으나..
URLSession 대신 우리가 만든 MockURLSession을 사용하기 위해 의존성 주입을 해준다.
*의존성 주입이라는게.. 거창해보이지만
그냥 APIManager의 프로퍼티로 어떤 타입이 들어올 자리를 마련해놓는데, 그 자리에는 특정 프로토콜을 채택한 타입들만 올 수 있게 해놓은거고, 이니셜라이저를 통해 APIManager의 인스턴스가 생성될때 프로퍼티자리에 타입을 주입시켜줘서 들어온 타입과 APIManager 사이의 의존성을 없애주는 것이다.
session 프로퍼티에 어떤 타입을 기본값으로 가지고 있는 경우에는 그타입과 APIManager간의 의존성이 높은데, 의존성 주입시 APIManager은 그 타입을 평소에는 모르고있는거니깐 의존성이 없어진다.
여기서 이니셜라이저에 기본값을 URLSession.shared 라고 준 이유는 APIManager을 초기화할때 만약 그냥 APIManager()
이렇게하면 진짜 네트워크통신을 하는것이고,
APIManager(session: MockURLSession())
이라고 하면 가짜객체를 주입할 수 있는 두가지로 사용할 수 있어서 인것같다는 생각이든다.
class APIManagerTests: XCTestCase {
var sutAPIManager: APIManager!
var mockSession = MockURLSession()
override func setUp() {
sutAPIManager = APIManager(session: mockSession)
}
func test_상품목록의_상품갯수가_20개인지_확인() {
let expectation = XCTestExpectation()
let response = JSONParser.decodeData(of: MockData().data, type: ProductList.self)
sutAPIManager.checkProductList(pageNumber: 1, itemsPerPage: 20) { result in
switch result {
case .success(let data):
XCTAssertEqual(data.itemsPerPage, response?.itemsPerPage)
case .failure:
XCTFail()
}
expectation.fulfill()
}
wait(for: [expectation], timeout: 2.0)
}
}
서버와 네트워크통신하지 않아도 나머지로직(dataTask의 completionHandler)이 잘 작동하는지 확인해볼수있게되었다 ㅎㅎ
class APIManagerTests: XCTestCase {
var sutAPIManager: APIManager!
var realAPIManager: APIManager!
var mockSession = MockURLSession()
override func setUp() {
sutAPIManager = APIManager(session: mockSession)
realAPIManager = APIManager()
}
func test_APIHealth가_정상적으로_받아지는지() {
realAPIManager.checkAPIHealth { result in
switch result {
case .success(let data):
let apiHealth = String(data: data, encoding: .utf8)!
XCTAssertEqual(apiHealth, "\"OK\"")
case .failure(let error):
XCTAssertThrowsError(error)
}
}
}
}
추가로 위에서 언급했던 것처럼 MockURLSession()을 사용하고싶지않다면 APIManager() 에 아무것도 전달하지않고 초기화하여 기본값인 URLSession.shared 를 사용할 수 있다.
우와 스텝 바이 스텝으로 설명해주셔서 이해가 쏙쏙입니다👍 잘 읽었습니다😊