네트워크 통신을 하지 않는 mock URLSession 구현하기

jane·2022년 1월 7일
2

iOS

목록 보기
12/32

네트워크 통신이 되지 않는 환경에서도 나머지 데이터를 처리하는 로직이 잘 작동하는지 테스트할 수 있는 코드를 만들기 위해서
URLSession 과 동일한 역할을 하는 객체를 주입(의존성 주입)하여 사용하는 방법에 대해 알아보자.

dataTask(with:)

URLSessiondataTask(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의 처리에 대한)나머지 로직이 잘 굴러가는지 확인해보기위해서이다!!

그니까 저 클로저 내부에 구현된 내용이 잘 굴러가는지 네트워크 통신의 성공유무와 상관없이 알고싶은 것이다.

그렇게 하기 위해서 크게 순서를 보면 이 5가지다

  1. 하나의 프로토콜을 만들고 : URLSessionProtocol
  2. 그 프로토콜을 구체적 타입(MockURLSession)에 채택한다
  3. URLSessionDataTask 클래스를 상속받은 타입(MockURLSessionDataTask)을 만들고
  4. 2에서 만든 MockURLSession에서 3의 MockURLSessionDataTask 타입의 프로퍼티를 갖는다
  5. APIManager가 URLSessionProtocol 타입의 프로퍼티를 가지도록 한다 : 의존성 주입 방식으로

준비사항으로 일단 기본적인 네트워크통신 코드가 필요하다.

설명: 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()
    }
    
}

먼저 URLSessionProtocol 부터 알아보자

1. 프로토콜을 정의한다.

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

2. URLSession 을 extension해서 URLSessionProtocol을 채택한다.

extension URLSession: URLSessionProtocol {
    
}

3. 그럼 채택한곳에서 구체적 구현을 해줘야겠죠.. 근데 이미 되어있음 ㅎ

왜 되어있냐.. URLSession 타입에 원래있는 dataTask(with:) 메서드를 똑같이 저 프로토콜에 정의를 해줬기 때문임...

아니 대체왜? 아까 네트워크 통신을 하게하지말고 우리가 구현해서 결과인 data, response, error만 넘겨주면 된다고 했잖아요.. 그거 구현해주려고 dataTask(with:) 메서드와 이름, 파라미터, 리턴값까지 똑같이 정의해서 네트워크 통신대신 작동하게 할것이기 때문이다

근데 그럼 구체적구현은 어디서해주냐?

4. MockURLSession 이라는 가짜 URLSession 처럼 동작하는 애를 만들어서 여기다가 채택할것

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
    }
}

5. 4에서 만들어준 MockURLSession을 이제 메인코드에 연결해줘야한다.

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 를 사용할 수 있다.

Reference

iOS Networking and Testing

profile
제가 나중에 다시 보려고 기록합니다 ✏️

1개의 댓글

comment-user-thumbnail
2022년 1월 9일

우와 스텝 바이 스텝으로 설명해주셔서 이해가 쏙쏙입니다👍 잘 읽었습니다😊

답글 달기