WWDC 18 - Testing Tips & Tricks - 1

2023. 3. 24. 18:58IOS/WWDC

반응형

WWDC 18 - Testing Tips & Tricks - 1

Testing network requests

  • 앱에서 네트워킹을 통해 무언가를 받아올 경우 위와 같은 형태로 진행되며
  • 몇가지의 메소드를 통해 위 과정을 처리합니다.
func loadData(near coord: CLLocationCoordinate2D) {
    let url = URL(string: "/locations?lat=\(coord.latitude)&long=\(coord.longitude)")!
    URLSession.shared.dataTask(with: url) { data, response, error in
        guard let data = data else { self.handleError(error); return }
        do {
            let values = try JSONDecoder().decode([PointOfInterest].self, from: data)
            DispatchQueue.main.async {
                self.tableValues = values
                self.tableView.reloadData()
            }
        } catch {
            self.handleError(error)
        }
    }.resume()
}
  • 위와 같이 좌표를 통해 무언가 값을 받아올 경우 URL을 생성하고 dataTask메소드를 통해 URLSessionDataTask를 만들어 resume메소드로 동작합니다.
  • 그리고 받아온 error, response, data를 통해 에러를 처리하거나 데이터를 deconding하여 뷰를 업데이트 할 수 있습니다.
  • 우리는 위에서 보았던 사진의 한 부위씩 유닛테스팅을 하여야합니다.

Prepare URLRequest, Parse Response

  • 기존 위에서 작성하였던 코드를 여러개의 메소드로 나누어서 관리하면 따로 테스트하기가 용이해집니다.
struct PointsOfInterestRequest {
    func makeRequest(from coordinate: CLLocationCoordinate2D) throws -> URLRequest {
        guard CLLocationCoordinate2DIsValid(coordinate) else {
            throw RequestError.invalidCoordinate
        }

        var components = URLComponents(string: "https://example.com/locations")!

        components.queryItems = [
            URLQueryItem(name: "lat", value: "\(coordinate.latitude)"),
            URLQueryItem(name: "long", value: "\(coordinate.longitude)")
            }
        ]

        return URLRequest(url: components.url!)
    }
}
  • 하나는 Prepare URLRequest를 테스트하기 위한 메소드로 기존 코드와 유사한 형태지만 URLRequest를 반환하고 좌표가 올바르지 않을 경우 invalidCoordinate에러를 반환
func parseResponse(data: Data) throws -> [PointOfInterest] {
    return try JSONDecoder().decode([PointOfInterest].self, from: data)
}
  • 하나는 Data를 원하는 형태로 decoding해주는 메서드로 Parse Response의 역할 중 하나입니다.
  • 위와 같이 메서드로 나누어 세부적인 테스트케이스 작성이 가능해졌습니다.
class PointOfInterestRequestTests: XCTestCase {
    let request = PointsOfInterestRequest()
    func testMakingURLRequest() throws {
        let coordinate = CLLocationCoordinate2D(latitude: 37.3293, longitude: -121.8893)
        let urlRequest = try request.makeRequest(from: coordinate)
        XCTAssertEqual(urlRequest.url?.scheme, "https")
        XCTAssertEqual(urlRequest.url?.host, "example.com")
        XCTAssertEqual(urlRequest.url?.query, "lat=37.3293&long=-121.8893")
    }
}
  • makeRequest를 통해 올바른 url값을 가지고 있는지 실제 좌표 값은 정확히 URL query에 들어가 있는지에 대한 테스트를 작성할 수 있습니다.
class PointOfInterestRequestTests: XCTestCase {
    func testParsingResponse() throws {
        let jsonData = "[{\"name\":\"My Location\"}]".data(using: .utf8)!
        let response = try request.parseResponse(data: jsonData)
        XCTAssertEqual(response, [PointOfInterest(name: "My Location")])
    }
}
  • 또 원하는 값으로 JSON Decoding이 되고 있는지에 대한 여부도 테스트가 가능합니다.

두 메서드는 Error를 반환하도록 throws가 있지만 XCTest의 기능으로 do-catch문을 사용하지 않아도 위와 같이 사용이 가능합니다

Create URLSession Task

  • 위에서 구현한 내용을 토대로 프로토콜을 만들 수 있습니다.
protocol APIRequest {
    associatedtype RequestDataType
    associatedtype ResponseDataType

    func makeRequest(from data: RequestDataType) throws -> URLRequest
    func parseResponse(data: Data) throws -> ResponseDataType
}
  • 해당 프로토콜을 통해 URLSession DataTask와 관련된 클래스를 아래와 같이 또 만들 수 있습니다.
class APIRequestLoader<T: APIRequest> {
    let apiRequest: T
    let urlSession: URLSession

    init(apiRequest: T, urlSession: URLSession = .shared) {
        self.apiRequest = apiRequest self.urlSession = urlSession
    }
}
  • 해당 T 제너릭 타입은 위에서 구현한 PointsOfInterestRequest로 생성할 수 있게 됩니다.
class APIRequestLoader<T: APIRequest> {
    func loadAPIRequest(requestData: T.RequestDataType,
                        completionHandler: @escaping (T.ResponseDataType?, Error?) -> Void) {
        do {
            let urlRequest = try apiRequest.makeRequest(from: requestData)

            urlSession.dataTask(with: urlRequest) { data, response, error in
                guard let data = data else { return completionHandler(nil, error) }
                do {
                    let parsedResponse = try self.apiRequest.parseResponse(data: data)
                    completionHandler(parsedResponse, nil)
                } catch {
                    completionHandler(nil, error)
                }
            }.resume()

        } catch {
            return completionHandler(nil, error)
        }
    }
}
  • 앱 내에서 처음 구현한 dataTask를 해당 타입에서 다시 구현을 할 수 있습니다.

  • 이를 통해 기존의 하위레벨 단계의 유닛테스트를 더욱 큰 단계의 테스트로 바꿀 수 있습니다.
  • APIRequestLoader를 이용하여 Prepare URLRequest, Create URLSession Task, Parse Response까지 테스트를 할 수 있도록 코드가 구현되었습니다.

How to Use URLProtocol

  • URLSession은 네트워크 통신을 하기 위해 URLSessionDataTask와 같은 상위 수준 API를 제공합니다.

  • 하지만 더욱 낮은 구현부에서는 네트워크 연결 열기, 네트워크 응답 작성, 응답 요청 등 하위수준의 API URLProtocol subclasses가 존재합니다.
  • URLProtocol은 URL로딩시스템에 대한 확장을 제공하기 위해 서브클래싱되어 설계되었습니다.

  • Foundation은 HTTPS와 같은 프로토콜에 대해서 하위 클래스를 제공합니다
  • 다만 우리는 테스트에 필요한 요청 자체를 모의(Mock)으로 만들어 제공할 수 있는 프로토콜을 구현하여 재정의 할 수 있습니다.

  • 위와 같이 URLProtocolURLProtocolClient를 통해 시스템에 진행 상황을 시스템에 전달하는데 이를 MockProtocol을 구현하여 해결할 수 있습니다.
class MockURLProtocol: 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() {
    }
}
  • 위와 같이 URLProtcol상속받아 서브클래스로 MockProtocol을 구현할 수 있습니다.
    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }
  • 해당 메소드는 request에 대한 관심여부라고 볼 수 있습니다. 만약 이것이 true라면 시스템을 통한 모든 requestMockURLProtocol을 통해서 진행됩니다.
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }
  • 해당 메소드는 request에 관해 표준 버전을 반환해주는 역할을 합니다.
  • 이 외에 startLoadingstopLoading은 이름 그대로의 역할을 수행합니다.
  • request에 관해 canonicalRequest를 구현을 하지만 대부분의 작업은 startLoading에서 발생합니다.
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
  • 테스트가 해당 URLProtocol에 연결할 수 있는 방법으로는 requestHandler를 활용하는것입니다.
  • URLSession의 작업이 시작(resume)되면 시스템은 URLProtocol의 하위 클래스를 인스턴스화 하여 URLRequest, URLProtocol Client인스턴스를 제공합니다.(startLoading메소드가 실행되기 이전)
  • 이후 startLoading메소드가 실행되는데 위에서 구현한 reuqestHandler를 가져와 URLRequest, 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)
        }
    }
  • 우리는 handler를 통해 받은 임의로 준 responsedataURLProtocolClient의 인스턴스인 client에 주어서 URLSessionDataTask로 전달합니다.

  • 테스트 요청 취소와 관련된 작업을 테스트하고 싶을 경우 위와 마찬가지로 stopLoading메소드를 통해 구현할 수도 있습니다.
  • Stub프로토콜을 이용하여 테스트케이스를 작성하면
class APILoaderTests: XCTestCase {
    var loader: APIRequestLoader<PointsOfInterestRequest>!

    override func setUp() {
        let request = PointsOfInterestRequest()
        let configuration = URLSessionConfiguration.ephemeral

        configuration.protocolClasses = [MockURLProtocol.self]

        let urlSession = URLSession(configuration: configuration)

        loader = APIRequestLoader(apiRequest: request, urlSession: urlSession)
    }
}
  • 이전에 구현한 APIRequestLoader의 인스턴스를 만들고 URLProtocol을 사용하도록 URLSession을 구상할 수 있습니다.
class APILoaderTests: XCTestCase {
    func testLoaderSuccess() {
        let inputCoordinate = CLLocationCoordinate2D(latitude: 37.3293, longitude: -121.8893)
        let mockJSONData = "[{\"name\":\"MyPointOfInterest\"}]".data(using: .utf8)!

        MockURLProtocol.requestHandler = { request in
            XCTAssertEqual(request.url?.query?.contains("lat=37.3293"), true)
            return (HTTPURLResponse(), mockJSONData)
        }

        let expectation = XCTestExpectation(description: "response")
        loader.loadAPIRequest(requestData: inputCoordinate) { pointsOfInterest, error in
            XCTAssertEqual(pointsOfInterest, [PointOfInterest(name: "MyPointOfInterest")])
            expectation.fulfill()
        }

        wait(for: [expectation], timeout: 1)
    }
}
  • 이전의 MockURLProtocolrequestHandler에 Stub응답과 데이터를 넣음으로 loaderAPIRequestLoader의 메소드를 테스트할 수 있습니다.
  • 위와 같이 세분화하고 Mock을 만들고 테스트코드를 작성한다면 URLSessionDataTaskresume메서드를 실수로 쓰지 않았다면 테스트에 실패하므로 좋은 테스트코드입니다.
  • 이것은 UI테스트에 관해서도 굉장히 유용한 결과를 낼 수 있습니다.

( ~ 13 : 40)

반응형