🌐 WWDC에서 설명하는 네트워크 구조, 테스트 방법
WWDC 2018에서는 아래와 같이 네트워킹 과정을 설명하고 있다. 간단하게 말로 풀어보면 아래와 같지 않을까?
- 원하는 요청 만들기
- 서버에게 요청 보내고 응답 받기
- 응답을 뷰에 맞는 형태로 바꾸기
- 응답을 뷰에 표시하기
만약 여기서 Server를 제외하고 중간 흐름 수준의 테스트를 하고싶다면?
URLProtocol을 사용하면 된다!
🍎 WWDC에서 설명하는 URLProtocol
가상의 데이터, 응답을 줘서 직접적인 인터넷 연결 없이 내가 요청을 잘 만들었는지, 응답을 잘 처리하는지를 확인 할 수 있다. 네트워크 요청을 가로채는 것이다.
Swift에서는 URLProtocol클래스를 상속받는 MockProtocol을 만들 수 있다. WWDC에서 소개하는 코드를 그대로 가져와봤다.
class MockProtocol: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool {
return true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
override func startLoading() {
// ...
}
override func stopLoading() {
// ...
}
}
함수 하나하나 좀 디테일하게 살펴보자
canInit
override class func canInit(with request: URLRequest) -> Bool {
return true
}
주어진 요청을 이 프로토콜이 처리할 수 있는지 여부를 결정한다. 아래처럼 특정 url이나 특정 httpMethod일 때 가로챌 수 도 있다.
override class func canInit(with request: URLRequest) -> Bool {
return request.url?.host == "hyunjun.com" && request.httpMethod == "GET"
}
canonicalRequest
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
요청을 표준화 한다고 하는데, 요청을 중간에 슥 가로채서 수정이 가능하다는 뜻이다. 보통은 그대로 보내니... request를 그대로 return해주면 될 듯 하다.
startLoading, stopLoading
override func startLoading() {
// ...
}
override func stopLoading() {
// ...
}
이 두함수는 이름부터 약간 느낌이 온다(?) startLoading은 요청을 처리하고 응답을 반환하는데 사용되고, stopLoading은 요청이 취소되거나 네트워크 작업이 중단될 때 호출된다고 한다. 보통 MockProtocol의 경우 데이터, 응답을 임의로 만들어서 던져주기 때문에 stopLoading은 쓸 일이 없을 것 같다.
🍏 WWDC에서 보여준 MockURLProtocol
너무나도 친절하게도? WWDC에서 손수 예제 코드를 보여준다. requestHandler를 활용하여 바깥에서 Response와 Data를 넣어준다.
class MockURLProtocol: URLProtocol {
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override func startLoading() {
guard let handler = MockURLProtocol.requestHandler else {
XCTFail("Received unexpected request with no handler set")
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
}
테스트에서는 MockURLProtocol이 모든 요청을 처리하도록 설정했다. 아래는 WWDC에서 다음에 보여주는 코드이다.
class APILoaderTests: XCTestCase {
var loader: APIRequestLoader<PointOfInterestRequest>!
override func setUp() {
let reqeuest = PointOfInterestRequest()
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
let urlSession = URLSession(configuration: configuration)
loader = APIRequestLoader(urlSession: urlSession, request: reqeuest)
}
}
테스트를 하기 전에 아래와 같이 requestHandler에 값을 넣어주고 시작했다.
MockURLProtocol.requestHandler = { request in
XCTAssertequal(request.url?.query?.contains("lat=37.3293"), true)
return (HTTPURLResponse(), mockJSONData)
}
🧑🏻💻 실제 적용해보기, 간단하게 테스트하기
나도 실제 MockURLProtocol을 아래와 같이 만들어서 테스트에 사용했다.
import Foundation
final class MockURLProtocol: URLProtocol {
static var requestHandler: ((URLRequest) throws -> (Data, HTTPURLResponse))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let requestHandler = MockURLProtocol.requestHandler else {
client?.urlProtocol(self, didFailWithError: URLError(.unknown))
return
}
do {
let (data, response) = try requestHandler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() { }
}
이렇게 테스트를 진행하다보니 아쉬웠던 점은 requestHandler를 한번 결정해주면 변경했을때 비동기적으로 실행되서 그런지 아래처럼 그때그때 값을 넣어주면 모두 실패가 떠버린다. 결국 하나의 MockURLProtocol이 하나의 테스트밖에 못한다는게 너무 아쉬웠다.
@Test func success() async throws { // 분명 성공했었는데 실패!
MockURLProtocol.requestHandler = { request in
let url = try #require(request.url)
let response = try #require(HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil))
return (kakaoBookData, response)
}
let query = KakaoBookSearchQuery(query: "사피엔스")
let data = try await query.request(by: urlSession)
#expect(data == kakaoBookData)
}
@Test func failure() async throws {
MockURLProtocol.requestHandler = { request in
let url = try #require(request.url)
let response = try #require(HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil))
return (kakaoBookData, response)
}
let query = KakaoBookSearchQuery(query: "사피엔스")
try await #require(throws: URLError(.unknown)) {
try await query.request(by: urlSession)
}
}
🧑🔬 입맛에 맞게 변형하기
그러니까 결국 MockURLProtocol 하나 당 requestHandler가 고정이 된 셈...! 그나마 찾은 해결책중 하나는 requestHandler를 딕셔너리로 만들어서 URL을 키값으로 해서 관리하는 것이다.
static var requestHandler: [URL: (URLRequest) throws -> (Data, HTTPURLResponse)] = [:]
하지만 나는 아래와 같이 쿼리를 생성하면 request를 바로 보내기 때문에 url을 추출할 필요가 없게 만들었다.
let query = KakaoBookSearchQuery(query: "사피엔스")
let data = try await query.request(by: urlSession)
어자피 데이터들은 고정적으로 넣어줘야 하는 값이기 때문에 함수마다 동적으로 넣어줄 일이 없었다. 따라서 그냥 아래처럼 그때그때 data, response를 줄 수 있도록 바꿨고, 확장성을 위해 단순한 딕셔너리 구조로 우선 구현했다. 이제 키워드와 데이터만 있다면 쉽게 추가하면 내가 원하는 결과를 받을 수 있다.
final class MockURLProtocol: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
do {
let (data, response) = try data(for: request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() { }
}
extension MockURLProtocol {
private func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) {
guard let url = request.url, let query = url.query() else { throw URLError(.unknown) }
let data: [String: Data] = [
"KakaoSuccess": stubKakaoBookData
]
if let data = data.first(where: { query.contains($0.key) }) {
return (data.value, response(from: url))
} else {
return (Data(), response(from: url, statusCode: 404))
}
}
private func response(from url: URL, statusCode: Int = 200) -> HTTPURLResponse {
let badResponse = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil)
return badResponse ?? HTTPURLResponse()
}
}
결국 아래와 같이 요청 성공 시나리오를 테스트 해볼 수 있었다.
@Test("Mock 서버 책 검색 요청, 응답 성공")
func request() async throws {
let query = KakaoBookSearchQuery(query: "KakaoSuccess")
let data = try await query.request(by: urlSession)
#expect(data == stubKakaoBookData)
let result = try KakaoBookSearchResult(from: data)
let meta = try #require(result.meta)
let document = try #require(result.documents?.first)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
let datetime = dateFormatter.date(from: "2023-04-01T00:00:00.000+09:00")
#expect(meta.isEnd == false)
#expect(meta.pageableCount == 10)
#expect(meta.totalCount == 10)
#expect(document.title == "사피엔스")
#expect(document.authors == ["유발 하라리"])
#expect(document.isbn == "8934972467 9788934972464")
#expect(document.translators == ["조현욱"])
#expect(document.publisher == "김영사")
#expect(document.datetime == datetime)
}