220909 TIL [네트워크 없이 URLSession 유닛 테스트 하기]

Doogie·2022년 9월 9일
0

네트워크 없이 URLSession을 테스트 하려면 조금 귀찮은 과정들을 거쳐야 한다

그럼에도 해당 테스트를 하는 이유(개인적인 생각)

  1. 네트워크 통신을 했을 때 데이터를 잘 받아오는지는 결과값이나 혹은 어플을 직접 실행해 봐도 확인 할 수 있지만 그 과정에서 던져지는 에러들은 확인하기 어렵다

  2. 방대한 양의 테스트를 진행해야 할 경우도 있을텐데 이럴 때 마다 서버를 이용하면 서버에 무리가 갈 수도 있다

와 같은 이유가 있지 않을까?(하고 개인적으로 생각해 본다...)

방법

📌 가장 중요!!

URLSession의 dataTask는 위와 같이 서버와 통신해서 얻는 결과를 세가지의 값으로 전달 해 준다
즉, 이 과정만 테스트 할 때 바꿔치기 해서 우리가 테스트 하길 원하는 가짜 데이터를 넣어주면 된다는 말!

- dataTask 리턴과정을 분리하지 않은 네트워크 통신

- dataTask 리턴과정을 분리한 네트워크 통신

약간 이런 느낌이지 않을까 싶다

  • 일단 위 내용을 토대로 내 프로젝트에 지금 실제로 적용하면서 내용을 작성해 보려고 한다

0. dataTask가 주는 결과 값 객체 만들기

위에서 말했든 dataTask는 Data, Response, Error를 옵셔널 값으로 주는데 이걸 담아 줄 객체를 만들어준다(사실 이건 만들어도 그만 안만들어도 그만이지만 편의를 위해)

struct ResponseResult {
    let data: Data?
    let response: URLResponse?
    let error: Error?
}

1-1. URLSessionProtocol 정의

protocol URLSessionProtocol {
    func customDataTask(request: URLRequest, completion: @escaping (responseResult) -> Void) -> URLSessionDataTask
}

0번에서 정의한 responseResult를 비동기값으로 전해주는 URLSessionDataTask를 반환하는 메서드 생성
이름을 뭐로 해야할지 모르겠어서 일단 customDataTask라고 만들었다

1-2 위에서 정의한 URLSessionProtocol을 URLSession 확장을 통해 채택 한 뒤 내부 구현

extension URLSession: URLSessionProtocol {
    func customDataTask(request: URLRequest, completion: @escaping (ResponseResult) -> Void) -> URLSessionDataTask {
        let dataTask = self.dataTask(with: request) { data, response, error in
            let responseResult = ResponseResult(data: data, response: response, error: error)
            completion(responseResult)
        }
        
        return dataTask
    }
}

위와 같이 구현을 했는데 사실 이 부분만 보면 엥? 이거 이렇게 귀찮게 중간다리 하나 더 거칠바에는 그냥 구현하는게 낫지 않겠음? 할 수 도 있다

다시 말하지만 나중에 test할 때는 실질적으로 네트워크와 통신하는 이 부분을 가짜 내용으로 구성할 것이다

2 네트워크 통신 부분에 적용

  • 원래 작성했던 코드의 주요 부분들만 보자면...
struct NetworkManger: NetworkMangerable {
    private let session = URLSession.shared
    
    //아래 매개변수로 받는 request는 작성 생략
    let dataTask = session.dataTask(with: request) { data, response, error in
            DispatchQueue.main.async {
                guard error == nil else {
                    completion(.failure(APIError.transportError))
                    return
                }
                
                guard let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode) else {
                    completion(.failure(APIError.responseError))
                    return
                }

                guard let data = data else {
                    completion(.failure(APIError.dataError))
                    return
                }
                
                completion(.success(data))
            }
        }
        dataTask.resume()
}

session을 깡으로 생성하고 기본 구현된 dataTask로 서버와의 통신을 하고 있다

  • URLSessionProtocol을 적용한 코드
struct NetworkManger: NetworkMangerable {
    private let session: URLSessionProtocol
    
    init(urlSession: URLSessionProtocol = URLSession.shared) {
        self.session = urlSession
    }
    
     let dataTask = session.customDataTask(request: request) { result in
            DispatchQueue.main.async {
                guard result.error == nil else {
                    completion(.failure(APIError.transportError))
                    return
                }
                
                guard let response = result.response as? HTTPURLResponse, (200...299).contains(response.statusCode) else {
                    completion(.failure(APIError.responseError))
                    return
                }

                guard let data = result.data else {
                    completion(.failure(APIError.dataError))
                    return
                }
                
                completion(.success(data))
            }
        }
        dataTask.resume()
}
  1. URLSessionProtocol을 초기화 변수로 받아준다 (물론 기본값으로 지정하기도 했고 타입만 지정해서 생성해도 되긴 하지만 테스트 코드 작성시에는 가짜 URLSession을 넣어야 하기에 init에 구현)
  2. dataTask 정의 부분을 보면 기존 dataTask 처럼 각각 단일단위의 타입으로 주는게 아니라 result라는(속을 보면 0번에서 정의한 responseResult타입임) 걸 통해 결과를 따지는데...

사실 여기까지는 별거 없다 서버 통신도 별다를 것 없이 잘 된다

🔗 이제 가장 중요한 테스트... 이걸 위해 이렇게 귀찮은 과정을 거쳤다..

일단 테스트 관련 파일들은 테스트 폴더에 TestDouble이란 폴더를 만들어 생성했다

1. 가짜 dataTask 정의

final class StubURLSessionDataTask: URLSessionDataTask {
    var fakeResume: () -> Void = {}
    
    override func resume() {
        fakeResume()
    }
}

URLSessionDataTask 클래스를 상속받은 StubURLSessionDataTask를 생성해 준다
여기서 중요한 점은 실제 dataTask의 resume은 서버 통신을 재개하는 역할을 하는데 우리는 통신을 하지 않을 것이기 때문에 URLSessionDataTask에 관한 가짜 객체도 만들어 준다

2. 가짜 URLSession 정의

이 부분도 중요하다(뭐이리 중요한 부분이 많은지 모르겠는데 내가 이 내용들을 이해하는데 너무 오랜시간이 걸려서 그런건지 모르겠지만 다 중요하다....ㅠㅠ)

struct StubURLSession: URLSessionProtocol {
    private let dummyData: ResponseResult
    
    init(dummyData: ResponseResult) {
        self.dummyData = dummyData
    }
    /// STEP1
    private func stubDataTask(requset: URLRequest, completion: @escaping (ResponseResult) -> Void) -> URLSessionDataTask {
        let stubDataTask = StubURLSessionDataTask()
        
        stubDataTask.fakeResume = {
            completion(dummyData)
        }
        
        return stubDataTask
    }
    /// STEP2
    func customDataTask(request: URLRequest, completion: @escaping (ResponseResult) -> Void) -> URLSessionDataTask {
        
        let dataTask = stubDataTask(requset: request) { result in
            completion(result)
        }
        
        return dataTask
    }
    /// STEP3
}

순서대로 살펴보자

STEP1

우리는 dataTask가 completion으로 전달해 주는 결과 값들이 서버에서 전달 해 주는 값이 아닌 테스트용 데이터를 넣을 것 이기 때문에 StubURLSession을 초기화 할 때 dummyData를 받아 준다
(URLSession의 customDataTask의 responseResult가 아니라 customDataTask 내부의 dataTask의 responseResult의 값이다)

STEP2

STEP2 구간은 위에 1번단계에서 정의 했던 가짜 dataTask를 만들어주는 메서드 있다
STEP1 에서 초기화시 받은 dummyData를 가짜 dataTask 리줌이 호출되면 completion으로 전달할 수 있도록
즉, 진짜 dataTask가 아니라 가짜 dataTask를 사용하는 테스트코드에서 resume이 호출 되면 여기 작성한 코드들로 인해 dummyData가 completion으로 전달될 것이다

STEP3

실제 통신할 때 URLSession을 extension 했을 때와 마찬가지로 StubURLSession의 customDataTask메서드를 구현해 준다
실제 통신 때와 다른 점은 실제 통신 때는 URLSession에 있는 진짜 dataTask를 사용했지만
여기서는 STEP2에서 만든 stubDataTask를 사용한다
(다시 말하지만 진짜 dataTask는 resume시 서버 통신을 재개하는 역할을 하지만 stubDataTask는 서버 통신 대신 미리 정의 해둔 dummyData를 completion으로 넘겨준다)

다만...

문제점이 하나가 있다면


URLSession을 초기화 하는게 13.0부터 deprecated됐다고 하는데 이거 해결법 아시는분...?
일단 test코드이기 때문에 넘어가긴 하지만 해결하고 싶다...😭😭

아무튼 끝이다~!~!~!~! 준비 과정은 끝났고 이제 아래는 테스트 코드를 작성 예정!

    func test_netWorkHandler의호출시_statusCode가_불안정할경우_responseError를_잘던지는지() {
        //given
        let promise = expectation(description: "responseError와 일치하는지")
        let dummyData = makeDummyData(data: Data(), statusCode: 404, error: nil)
        let netWorkHandler = NetworkManger(urlSession: StubURLSession(dummyData: dummyData))
        let testAPIModel = TestAPIModel(bookTitle: "", host: "", path: "", method: .get)
        //when
        netWorkHandler.request(api: testAPIModel) { data in
            switch data {
            case .success(_):
                XCTFail()
            case .failure(let error):
                //then
                XCTAssertEqual(error, APIError.responseError)
            }
            promise.fulfill()
        }
        wait(for: [promise], timeout: 5)
    }

요런식으로 작성했다

참고 자료

https://sujinnaljin.medium.com/swift-mock-%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-network-unit-test-%ED%95%98%EA%B8%B0-a69570defb41
https://wody.tistory.com/10

profile
끊임없이 문을 여는 개발자

0개의 댓글