iOS Networking and Testing - 우아한형제들 기술 블로그
URLSession
보다 한 단계 위의 Protocol(여기선URLSessionProtocol
)을 만든다.URLSession
뿐만 아니라,MockURLSession
도URLSessionProtocol
을 채택하도록 한다.MockURLSession
내부에서 내가 원하는 형태의URLSessionDataTask
타입의 객체가 나오게 한다. (그래야dataTask.resume()
을 통해 원하는 작업을 진행할 수 있다.)- 어떤 객체(
URLSession
,MockURLSession
)를 테스트 할 것인지 정해준 다음, 테스트를 진행한다.
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]
}
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()
}
}
sampleData
는 일단 무시),```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 에서는 비동기처리를 기다려주지 않으므로 의도적 배치
```
protocol URLSessionProtocol {
func dataTask(with request: URLRequest,
completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask
}
extension URLSession: URLSessionProtocol { }
URLSessionProtocol
이라는 프로토콜을 하나 만들어주고URLSession
이 URLSessionProtocol
을 채택하도록 했다.MockURLSession
을 만들어 URLSession
객체를 바꿔칠 수 있게 되었다!MockURLSessionDataTask
와 MockURLSession
을 만들어주자!
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()
메서드가 실행될 때 우리가 조작한 response
를 fetchRandomJoke
내 completionHandler
의 전달인자로 넘겨주어야 한다.
그러려면, MockURLSession
의 dataTask
메서드를 통해 우리가 원하는 response
를 전달하기 위한 준비를 해야한다.
(Data?, URLResponse?, Error?)
꼴로 만들어 준비시켰다.그런 다음,
dataTask
메서드의 반환값인 sessionDataTask
(타입: MockURLSessionDataTask
) 를 return한다.
return한 sessionDataTask
는 fetchRandomJoke()
을 통해 resume()
메서드를 호출하게 될 것이다.
따라서 우리는 MockURLSessionDataTask
를 만들어줄 때, resume()
메서드를 재정의함으로써,
sessionDataTask.resumeDidCall = { completionHandler(JokesAPI().sampleData, successResponse, nil) }
다음과 같이 response
가 fetchRandomjok()
의 클로저로 들어갈 수 있도록 해야한다.
JokesAPIProvider
속에서 URLSession
을 MockURLSession
으로 바꿔주자.class JokesAPIProvider {
let session: URLSessionProtocol
init(session: URLSessionProtocol){
self.session = session
}
...
}
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)
session
이 URLSession.shared
인 경우(기존처럼 네트워크에서 불러오는 경우) 원래의 결과와 비슷한 결과가 나온다.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
)
}
}
비로소 네트워크 상황과 무관한 테스팅이 가능해졌다!!
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된다.