Testing networking logic.
Bài đăng này đã không được cập nhật trong 5 năm
- 
Khi tham khảo các topicvềunit testinghayautomated testta thường bắt gặp các thuật ngữ rất phổ biến như vềtestable codenhưsynchronous,predicablehay luôn trả raoutputgiống nhau cho cácinputkhác nhau. Tuy nhiên trong thực tế các đoạn code vềnet workingmà chúng ta hay gặp lại đi ngược với các thuật ngữ chúng ta gặp trong các topic.
- 
Nguyên nhân khiến chúng ta gặp khó khăn khi test code net workinglànet workingvốn dĩ là một công việc không đồng bộ và phụ thuộc nhiều vào các yếu tố bên ngoài như làinternet connection,server, các loại hệ thống vận hành khác nhau. Các yếu tố trên đều ảnh hưởng trực tiếp đến việcperforming,loading,decodingcủanet workrequest.
- 
Ở bài viết này chúng ta sẽ tập trung vào cách testcác đoạn codeasynchronousbằng cách sử dụngAPIFoundation:
1/ Verifying request generation logic:
- 
Khi bắt đầu triển khai một công việc testmới chúng ta nên bắt đầu ngược bằng cách kiểm tra độ chính xác của cáclogiccơ bản nhất trước khi chuyển sangtestcácAPI.
- 
Chúng ta sẽ cùng khơi tạo các URLRequesttừEndpointvới một số điều kiện kèm theo .Để việc test có tiêu chuẩn chung chúng ta cùng tạoEndpointKindmà không hề tùy chỉnh:
extension EndpointKinds {
    enum Stub: EndpointKind {
        static func prepare(_ request: inout URLRequest,
                            with data: Void) {
            // No-op
        }
    }
}
- Chúng ta đã có thể viết các suite testvới đoạncodebên trên trong đó chúng ra sẽ cần xác minh chính xác xácURLRequestcho cácendpointcơ bản không cầnHTTP header.
class EndpointTests: XCTestCase {
    func testBasicRequestGeneration() {
        let endpoint = Endpoint<EndpointKinds.Stub, String>(path: "path")
        let request = endpoint.makeRequest(with: ())
        XCTAssertEqual(request?.url, URL(string: "https://api.myapp.com/path"))
    }
}
- 
Chúng ta sẽ cần cải thiện thêm test casebên trên , chúng ta có thể nhận được kết quả sai vì cả trường hợpURLRequestchúng ta khởi tạo cũng nhưURLcho sẵn đều có thể bằngnil(đây là điều rất khó xảy ra nhưng khi viếttest casechúng ta không nên bỏ sót bất kì trường hợp nào không chắc chắn).
- 
Tiếp theo chúng ta đang giả định rằng hostsẽ luôn làapi.myapp.com, điều này đi được lại với thực tế là cácapphiện tại đều hỗ trợ chúng ta sử dụng nhiềunet working enviromentnhưstaginghayproduction, hơn thế nữa là cácappcòn có một vàiservervới cáchostcóaddresskhác nhau.
- 
Chúng ta sẽ gặp vấn đề đầu tiên khi sử dụng XCTUnwrapđể kiểm tra cácrequestđược khởi tạo không bằngnil. Chúng ta nên tạotypealiascho cácEndpointđặc biệt:
class EndpointTests: XCTestCase {
    typealias StubbedEndpoint = Endpoint<EndpointKinds.Stub, String>
    func testBasicRequestGeneration() throws {
        let endpoint = StubbedEndpoint(path: "path")
        let request = try XCTUnwrap(endpoint.makeRequest(with: ()))
        XCTAssertEqual(request.url, URL(string: "https://api.myapp.com/path"))
    }
}
- Để có thể kiểm soát chính xác hosthoạt động thế nào chúng ta cùng sử dụngURLHostchuyên dụng để có thể bao bọc một chuỗiStringđơn gian:
struct URLHost: RawRepresentable {
    var rawValue: String
}
- Lợi ích của việc sử dụng URLHostvới cáctyperiêng biệt là chúng ta có thể đóng gói các biến thể phổ biến bằng cách sử dụng cácpropertytĩnh trongenumnhư cách tạopropertychohostnhưstagingvàproductioncũng nhưdefaultđể tự động xử lý cho các trường hợpappchạy trongDEBUGmode:
extension URLHost {
    static var staging: Self {
        URLHost(rawValue: "staging.api.myapp.com")
    }
    static var production: Self {
        URLHost(rawValue: "api.myapp.com")
    }
    static var `default`: Self {
        #if DEBUG
        return staging
        #else
        return production
        #endif
    }
}
- Chúng ta cần updatethêmEndpointcácmethodkhởi tạoURLRequestbằng cáchenablingURLHostL:
extension Endpoint {
    func makeRequest(with data: Kind.RequestData,
                     host: URLHost = .default) -> URLRequest? {
        var components = URLComponents()
        components.scheme = "https"
        components.host = host.rawValue
        components.path = "/" + path
        components.queryItems = queryItems.isEmpty ? nil : queryItems
        guard let url = components.url else {
            return nil
        }
        var request = URLRequest(url: url)
        Kind.prepare(&request, with: data)
        return request
    }
}
- Để tránh việc phải xử lý thủ công cho mỗi URLchúng ta sử dụng chúng ta cần cải thiệnURLHostđể sễ dàng khởi tạo cácURLđặc biệt với cácpathnhư sau:
extension URLHost {
    func expectedURL(withPath path: String) throws -> URL {
        let url = URL(string: "https://" + rawValue + "/" + path)
        return try XCTUnwrap(url)
    }
}
- Đoạn codetrên không chỉ giúp việctestcủa chúng ta trở nên chặt chẽ, chính xác cũng như linh động hơn. Chúng ta bây giờ hoàn toàn có thể tạo cáctest casedễ đọc và dễ kiểm thử hơn.
class EndpointTests: XCTestCase {
    typealias StubbedEndpoint = Endpoint<EndpointKinds.Stub, String>
    let host = URLHost(rawValue: "test")
    func testBasicRequestGeneration() throws {
        let endpoint = StubbedEndpoint(path: "path")
        let request = endpoint.makeRequest(with: (), host: host)
        try XCTAssertEqual(
            request?.url,
            host.expectedURL(withPath: "path")
        )
    }
}
- CHúng ta đã tốn kha khá effortđể viết các tùy chỉnh cho các API để cải thiện chocode basechúng ta có thể mạnh mẽ cũng như linh hoạt hơn. Chúng ta có thể tạo thêm nhiềutest caseđể kiểm tra các loạiEndpointmột cách nhanh chóng.
    ...
    func testGeneratingRequestWithQueryItems() throws {
        let endpoint = StubbedEndpoint(path: "path", queryItems: [
            URLQueryItem(name: "a", value: "1"),
            URLQueryItem(name: "b", value: "2")
        ])
        let request = endpoint.makeRequest(with: (), host: host)
        try XCTAssertEqual(
            request?.url,
            host.expectedURL(withPath: "path?a=1&b=2")
        )
    }
}
- Chúng ta nên tạo thêm một số test casekiêm tra cácendpointthực để đảm bảocode testhoạt động đúng. Trong trường hợp chúng ta gặpendpointyêu cầu sử dụngauthenticationthì chúng ta sẽ cần thêmheaderAuthorizationkhi khởi tạorequest:
class EndpointTests: XCTestCase {
    ...
    func testAddingAccessTokenToPrivateEndpoint() throws {
        let endpoint = Endpoint.search(for: "query")
        let token = AccessToken(rawValue: "12345")
        let request = endpoint.makeRequest(with: token, host: host)
        try XCTAssertEqual(
            request?.url,
            host.expectedURL(withPath: "search?q=query")
        )
        XCTAssertEqual(request?.allHTTPHeaderFields, [
            "Authorization": "Bearer 12345"
        ])
    }
}
2/ Using integration tests:
- Chúng ta hiện đang sử dụng URLSessionAPItrongFoundationkết hợp với một vàioperatorcủaCombineđể xây dựng corenet workingcode:
extension URLSession {
    func publisher<K, R>(
        for endpoint: Endpoint<K, R>,
        using requestData: K.RequestData,
        decoder: JSONDecoder = .init()
    ) -> AnyPublisher<R, Error> {
        guard let request = endpoint.makeRequest(with: requestData) else {
            return Fail(
                error: InvalidEndpointError(endpoint: endpoint)
            ).eraseToAnyPublisher()
        }
        return dataTaskPublisher(for: request)
            .map(\.data)
            .decode(type: NetworkResponse<R>.self, decoder: decoder)
            .map(\.result)
            .eraseToAnyPublisher()
    }
}
- 
Kết quả successfullycủa đoạncodetrên không phải là kết quả chúng ta nên quá quan tâm vì chúng ta sử dụng một loạt cácAPIcủa hệ thống đã được tùy chỉnh, kiểm soát theo một số cách thức đặc biệt khiến các kết quảtestđạt được thường bị thu hẹp và dễ đoán biết.
- 
Protocollà một phương pháp cải tiện kết quảtestkhông chỉ trong trường hợp trên mà còn cho nhiều trường hợp khác mà chúng ta không phải tham gia tùy chỉnh, kiểm soát cácAPI. Đây là một phương pháp hữu dụng và phổ biến nên ở bài viết này chúng ta sẽ cùng ưu tiên cho cách thức khác:
- 
Trong Swiftta thấyURLSessionsử dụngURLProtcolđể tiến hành các tác vụnetwork, hệ thốngAppleđã cung cấp cũng như hỗ trợ hoàn chỉnh để chúng ta có thể tùy chỉnh bằng việc sử dụng cácclass. Điều đó có nghĩa là chúng ta có thể tự tùy biến một hệ thốngHTTP Net working stackmà không phải sửa đổi cácoperator Combinetùy biến trước đó.
- 
Nhược điểm của URLProtcoltừ quan điểm cá nhân tôi là nó chủ yếu dựa vào cácstatic methodđồng nghĩa chúng ta sẽ phảiimplementcácmockcủa chúng ta riêng biệt. Cách khắc phục tạm thời là sử dụng thêm protocolMockURLResponserđể cho phép chúng ta tạo cácmock servertrả vềDatahoặcErrorcần thiết:
protocol MockURLResponder {
    static func respond(to request: URLRequest) throws -> Data
}
- Điều tiếp theo chúng ta cần triển khai là tùy chỉnh thêm URLProtcolvới việcoverridecácmethodnhư sau:
class MockURLProtocol<Responder: MockURLResponder>: URLProtocol {
    override class func canInit(with request: URLRequest) -> Bool {
        true
    }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        request
    }
    override func startLoading() {
        guard let client = client else { return }
        do {
            // Here we try to get data from our responder type, and
            // we then send that data, as well as a HTTP response,
            // to our client. If any of those operations fail,
            // we send an error instead:
            let data = try Responder.respond(to: request)
            let response = try XCTUnwrap(HTTPURLResponse(
                url: XCTUnwrap(request.url),
                statusCode: 200,
                httpVersion: "HTTP/1.1",
                headerFields: nil
            ))
            client.urlProtocol(self,
                didReceive: response,
                cacheStoragePolicy: .notAllowed
            )
            client.urlProtocol(self, didLoad: data)
        } catch {
            client.urlProtocol(self, didFailWithError: error)
        }
        client.urlProtocolDidFinishLoading(self)
    }
    override func stopLoading() {
        // Required method, implement as a no-op.
    }
}
- Chúng ta cần URLSessionsử dụng cácmock protocolhơn là sử dụng hệ thốngHTTPmặc định. Để thực hiện điều đó chúng ta chỉ cần choURLSessionsử dụngMockURLProtocolnhưng đừng quên khai báo khởi tạo cho chínhURLProtcol:
extension URLSession {
    convenience init<T: MockURLResponder>(mockResponder: T.Type) {
        let config = URLSessionConfiguration.ephemeral
        config.protocolClasses = [MockURLProtocol<T>.self]
        self.init(configuration: config)
        URLProtocol.registerClass(MockURLProtocol<T>.self)
    }
- Với những đoạn codetrên chúng ta giờ đã có thể cố địnhMockURLResponderprotocol, để ví dụ chúng ta sẽ tham khảo việcencodeitemsau:
extension Item {
    enum MockDataURLResponder: MockURLResponder {
        static let item = Item(title: "Title", description: "Description")
        static func respond(to request: URLRequest) throws -> Data {
            let response = NetworkResponse(result: item)
            return try JSONEncoder().encode(response)
        }
    }
}
- Chúng ta nên cân nhắc việc sử dụng chế đố synchronousmàCombinegiới thiệu với việc sử dụngXCtTestđể xây dựng các logicsynchronoushơn là sử dụngGrand Central Dispatch semaphore:
extension XCTestCase {
    func awaitCompletion<T: Publisher>(
        of publisher: T,
        timeout: TimeInterval = 10
    ) throws -> [T.Output] {
        // An expectation lets us await the result of an asynchronous
        // operation in a synchronous manner:
        let expectation = self.expectation(
            description: "Awaiting publisher completion"
        )
        var completion: Subscribers.Completion<T.Failure>?
        var output = [T.Output]()
        let cancellable = publisher.sink {
            completion = $0
            expectation.fulfill()
        } receiveValue: {
            output.append($0)
        }
        // Our test execution will stop at this point until our
        // expectation has been fulfilled, or until the given timeout
        // interval has elapsed:
        waitForExpectations(timeout: timeout)
        switch completion {
        case .failure(let error):
            throw error
        case .finished:
            return output
        case nil:
            // If we enter this code path, then our test has
            // already been marked as failing, since our
            // expectation was never fullfilled.
            cancellable.cancel()
            return []
        }
    }
}
- Giống công việc testEndpointcủa hệ thống chúng ta đã sử dụng nhiềueffortđể xây dựng các công cụ hữu hiệu để có thể triển khai việctestvới các dòng code ngắn gọn mạch lạc hơn. Chúng ta sẽ cùng tiến hànhtestcông việcsuccessfully loadcũng nhưdecodecủarequest:
class NetworkIntegrationTests: XCTestCase {
    func testSuccessfullyPerformingRequest() throws {
        let session = URLSession(mockResponder: Item.MockDataURLResponder.self)
        let accessToken = AccessToken(rawValue: "12345")
        let publisher = session.publisher(for: .latestItem, using: accessToken)
        let result = try awaitCompletion(of: publisher)
        XCTAssertEqual(result, [Item.MockDataURLResponder.item])
    }
}
- YEAHH!!!, Điều cuối cùng chúng ta cần quan tâm là chúng ta có thể xác nhận lại các phương thức networkinghoạt động đúng mong đợi khi xảy ra các lỗi:
enum MockErrorURLResponder: MockURLResponder {
    static func respond(to request: URLRequest) throws -> Data {
        throw URLError(.badServerResponse)
    }
}
class NetworkIntegrationTests: XCTestCase {
    ...
    func testFailingWhenEncounteringError() throws {
        let session = URLSession(mockResponder: MockErrorURLResponder.self)
        let accessToken = AccessToken(rawValue: "12345")
        let publisher = session.publisher(for: .latestItem, using: accessToken)
        XCTAssertThrowsError(try awaitCompletion(of: publisher))
    }
}
All rights reserved
 
  
 