iOS) 네트워크 상태와 무관한 테스트를 작성하는 방법

CODA·2021년 6월 8일
0
post-thumbnail

0. 레퍼런스 블로그

iOS Networking and Testing - 우아한형제들 기술 블로그

  • Flow을 따라오기 정말 어려울 수 있다.
  • 내가 작성한 이하의 코드는, 위 블로그의 과정중에서도 어려울 법한 내용을 조금 더 재구성한 코드이다. 하지만 그럼에도 어렵다. 마음 단단히 먹고 따라오시길..

읽기 전 알아둘 사항.

  • 위에 제시한 블로그는 야곰캠프 1기 @태태 덕분에 알게된 블로그이고, 네트워크에 종속되지 않는 테스트를 원했던 나에게 단비같은 레퍼런스였다. 비록 처음에는 하나도 너무 어려웠지만, 역시나 무한반복하면서 코드 분석을 하니 이틀차인 오늘에 이르러 대략적인 이해를 할 수 있었다.
  • 이하의 코드들 역시 원 블로그를 각색한 것으로, 혹여 조금 더 난이도가 있는 코드를 바탕으로 공부해보고 싶다면 링크를 참고하자. 내 코드에서 이용하고 있는 서버 역시 위 블로그에서 제시한 서버이다.
  • 원래는 Test 파일에서 진행하는 것이 맞지만, 연습의 편의성을 위해 CommandLine Tool 에서 진행했다.
  • 네트워크 없는 테스트 방법을 간략하게 정리해보면 다음과 같다.
    1. URLSession 보다 한 단계 위의 Protocol(여기선 URLSessionProtocol)을 만든다.
    2. URLSession 뿐만 아니라, MockURLSessionURLSessionProtocol을 채택하도록 한다.
    3. MockURLSession 내부에서 내가 원하는 형태의 URLSessionDataTask 타입의 객체가 나오게 한다. (그래야 dataTask.resume()을 통해 원하는 작업을 진행할 수 있다.)
    4. 어떤 객체(URLSession, MockURLSession)를 테스트 할 것인지 정해준 다음, 테스트를 진행한다.

1. 우리가 네트워킹 과정을 테스트하는 방법에는 무엇이 있을까?

1. 좌측과 같은 JSON파일을 네트워크 를 통해 서버로부터 받아오고, 이를 테스트하는 것
- 장점: 실제적인 상황에서 테스트 코드의 작동여부를 확인해볼 수 있다.
- 단점
- 테스트로 인해서 서버에 영향을 줄 수 있다(비멱등성). 즉, 같은 테스트를 했음에도 다른 결과가 도출될 수 있다.
- 테스트가 실패했을 때, 서버의 문제인지, 내가 response를 받아오는 로직의 문제인지 명확하게 알기 어렵다. (물론 이 문제는 dataTask의 completionHandler를 통해 어느정도 커버가 가능하다.)

2. 좌측과 같은 JSON파일을 네트워크 없이 테스트하는 방식
- 우리가 오늘 알아봐야 하는 친구이다.

struct JokeReponse: Decodable {
    let type: String
    let value: Joke
}

struct Joke: Decodable {
    let id: Int
    let joke: String
    let categories: [String]
}

먼저 평소에 우리가 하던대로 서버와 통신해보자.

  • Model 타입을 만든다.
struct JokesAPI {
    static var url = URL(string: "https://api.icndb.com/jokes/random")!
		
		var sampleData: Data {
	        Data(
	            """
	            {
	                "type": "success",
	                    "value": {
	                    "id": 459,
	                    "joke": "Chuck Norris can solve the Towers of Hanoi in one move.",
	                    "categories": []
	                }
	            }
	            """.utf8
	        )
	    }
}

enum APIError: Error {
    case unknownError
}

class JokesAPIProvider {
    let session: URLSession
		
		init(session: URLSession){
				self.session = session
		}

    func fetchRandomJoke(completion: @escaping(Result<Joke, APIError>) -> Void) {
        let request = URLRequest(url: JokesAPI.url)
        
        let task: URLSessionDataTask = session.dataTask(with: request) { data, response, error in
            guard let response = response as? HTTPURLResponse,
                  (200...299).contains(response.statusCode) else {
                completion(.failure(.unknownError))
                return
            }
            
            if let data = data,
               let jokeResponse = try? JSONDecoder().decode(JokeReponse.self, from: data) {
                completion(.success(jokeResponse.value))
                return
            }
            
            completion(.failure(.unknownError))
        }
        task.resume()
    }
}
  • API와 연결할 url을 가지는 타입(sampleData는 일단 무시),
  • 나만의 에러 타입,
    • 실제로 서버 API와 통신하기위한 APIProvider 타입을 준비
```swift
let jokesAPIProvider = JokesAPIProvider(session: URLSession.shared)let jokesAPIProvider = JokesAPIProvider(session: URLSession.shared)
jokesAPIProvider.fetchRandomJoke { result in
    switch result {
    case .failure: print("엄마나!! 실패했자나")
    case .success(let joke):
        print("서버에서 받아온 조크입니다.")
        print(joke.joke)
    }
}

sleep(3)
jokesAPIProvider.fetchRandomJoke { result in
    switch result {
    case .failure: print("엄마나!! 실패했자나")
    case .success(let joke):
        print("서버에서 받아온 조크입니다.")
        print(joke.joke)
    }
}

sleep(3) // command line tool 에서는 비동기처리를 기다려주지 않으므로 의도적 배치
```
  • 실제로 이렇게 사용을 해보면 서버로부터 데이터가 전달됨을 알 수 있다. (서버로부터 오는 Joke는 랜덤이다.)

2. 그럼 이제 어떻게 네트워크와 무관하게 테스트할 수 있는지 알아보자

1) 기존의 URLSession과 MOCKURLSession을 포괄하는 상위의 Protocol을 구현하자!


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

extension URLSession: URLSessionProtocol { }
  • URLSessionProtocol 이라는 프로토콜을 하나 만들어주고
  • URLSessionURLSessionProtocol을 채택하도록 했다.
  • 이제 우리는 MockURLSession을 만들어 URLSession 객체를 바꿔칠 수 있게 되었다!

2) MockURLSessionDataTaskMockURLSession을 만들어주자!


class MockURLSessionDataTask: URLSessionDataTask {
    override init() {}
    var resumeDidCall: () -> Void = {}
    
    override func resume() {
        resumeDidCall()
    }
}

class MockURLSession: URLSessionProtocol {
    var isRequestSuccess: Bool
    let sessionDataTask = MockURLSessionDataTask()
    
    init(isRequestSuccess: Bool){
        self.isRequestSuccess = isRequestSuccess
    }

    func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
        let successResponse = HTTPURLResponse(url: JokesAPI.url,
                                              statusCode: 200,
                                              httpVersion: "2",
                                              headerFields: nil)
        let failureResponse = HTTPURLResponse(url: JokesAPI.url,
                                              statusCode: 402,
                                              httpVersion: "2",
                                              headerFields: nil)
        if isRequestSuccess {
            sessionDataTask.resumeDidCall = { completionHandler(JokesAPI().sampleData, successResponse, nil) }
        } else {
            sessionDataTask.resumeDidCall = { completionHandler(nil, failureResponse, nil) }
        }
        
        return sessionDataTask
    }
}
  • 여기가 제일 어렵다.

  • 우리는 MockURLSession을 통해 session의 타입을 바꿔줌으로써, task.resume() 메서드가 실행될 때 우리가 조작한 responsefetchRandomJokecompletionHandler 의 전달인자로 넘겨주어야 한다.

  • 그러려면, MockURLSessiondataTask 메서드를 통해 우리가 원하는 response 를 전달하기 위한 준비를 해야한다.

    • 이를테면, response를 에러가 있고 없고로 나누어 준비하거나, statusCode를 바꾸어 준비한다던가, 전달하는 데이터의 종류를 바꾸어줄 수도 있겠다.
    • 좌측 코드에서는 statusCode가 200일때와 402일때를 각각 success인 경우와 failure인 경우로 나누고, 이에 대한 response를 (Data?, URLResponse?, Error?) 꼴로 만들어 준비시켰다.
  • 그런 다음,

    dataTask 메서드의 반환값인 sessionDataTask (타입: MockURLSessionDataTask) 를 return한다.

  • return한 sessionDataTaskfetchRandomJoke() 을 통해 resume() 메서드를 호출하게 될 것이다.

  • 따라서 우리는 MockURLSessionDataTask 를 만들어줄 때, resume() 메서드를 재정의함으로써,

    sessionDataTask.resumeDidCall = { completionHandler(JokesAPI().sampleData, successResponse, nil) }

    다음과 같이 responsefetchRandomjok() 의 클로저로 들어갈 수 있도록 해야한다.

3) 이제 테스트 준비를 위해 아까 작성했던 JokesAPIProvider 속에서 URLSessionMockURLSession으로 바꿔주자.

class JokesAPIProvider {
    let session: URLSessionProtocol
    init(session: URLSessionProtocol){
        self.session = session
    }
...
}

3. 우리가 원하는 결과가 나오는지 확인해보자.

1) 기존처럼 서버에서 데이터를 불러오는 경우

let jokesAPIProvider = JokesAPIProvider(session: URLSession.shared)
jokesAPIProvider.fetchRandomJoke { result in
    switch result {
    case .failure: print("엄마나!! 실패했자나")
    case .success(let joke):
        print("서버에서 받아온 조크입니다.")
        print(joke.joke)
    }
}
sleep(3)
  • sessionURLSession.shared인 경우(기존처럼 네트워크에서 불러오는 경우) 원래의 결과와 비슷한 결과가 나온다.

2) 조작한 성공 케이스의 Response를 클로저로 전달한 경우

let jokesAPIProvider = JokesAPIProvider(session: MockURLSession(isRequestSuccess: true))
jokesAPIProvider.fetchRandomJoke { result in
    switch result {
    case .failure: print("엄마나!! 실패했자나")
    case .success(let joke):
        print("서버에서 받아온 조크입니다.")
        print(joke.joke)
    }
}
sleep(3)
  • session에 들어갈 객체가 MockURLSession(isRequestSuccess: true) 인 경우, 우리가 제대로된 요청을 보냈을 때의 기대했던 결과값이 나올 것이다.

    이 때 우리는 서버에서 데이터를 받아오는 것이 아니라, JokesAPI 에서 만들어준 sampleData의 내용을 불러오는 것이다.

    struct JokesAPI {
    	...
    		var sampleData: Data {
    	        Data(
    	            """
    	            {
    	                "type": "success",
    	                    "value": {
    	                    "id": 459,
    	                    "joke": "Chuck Norris can solve the Towers of Hanoi in one move.",
    	                    "categories": []
    	                }
    	            }
    	            """.utf8
    	        )
    	    }
    }

    비로소 네트워크 상황과 무관한 테스팅이 가능해졌다!!


3) 조작한 실패 케이스의 Response를 클로저로 전달한 경우

let jokesAPIProvider = JokesAPIProvider(session: MockURLSession(isRequestSuccess: false))
jokesAPIProvider.fetchRandomJoke { result in
    switch result {
    case .failure: print("엄마나!! 실패했자나")
    case .success(let joke):
        print("서버에서 받아온 조크입니다.")
        print(joke.joke)
    }
}

sleep(3)

예상한대로, 실패했을 경우의 결과가 print된다.

profile
(아직) 코딩조무사.

0개의 댓글