【问题标题】:How to mock DataTaskPublisher?如何模拟 DataTaskPublisher?
【发布时间】:2020-05-22 04:40:03
【问题描述】:

我正在尝试使用 URLSession.DataTaskPublisher 为我的 API 编写一些单元测试。我在 Stackoverflow 上发现了一个已经存在的问题,但我正在努力使用建议的解决方案来实现一个工作类。

这是现有的问题:How to mock URLSession.DataTaskPublisher

protocol APIDataTaskPublisher {
    func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher
}

class APISessionDataTaskPublisher: APIDataTaskPublisher {
    func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher {
        return session.dataTaskPublisher(for: request)
    }

    var session: URLSession

    init(session: URLSession = URLSession.shared) {
        self.session = session
    }
}

class URLSessionMock: APIDataTaskPublisher {
    func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher {
        // How can I return a mocked URLSession.DataTaskPublisher here?
    }
}

我的 API 然后像这样使用上面的:

class MyAPI {
    /// Shared URL session
    private let urlSession: APIDataTaskPublisher

    init(urlSession: APIDataTaskPublisher = APISessionDataTaskPublisher(session: URLSession.shared)) {
        self.urlSession = urlSession
    }
}

我不知道的是如何实现 URLSessionMock.dataTaskPublisher()。

【问题讨论】:

    标签: swift unit-testing combine


    【解决方案1】:

    模拟DataTaskPublisher 可能会更简单。你真的关心发布者是不是DataTaskPublisher?可能不是。您可能关心的是获得与DataTaskPublisher 相同的OutputFailure 类型。因此,将您的 API 更改为仅指定:

    protocol APIProvider {
        typealias APIResponse = URLSession.DataTaskPublisher.Output
        func apiResponse(for request: URLRequest) -> AnyPublisher<APIResponse, URLError>
    }
    

    符合URLSession 以供生产使用:

    extension URLSession: APIProvider {
        func apiResponse(for request: URLRequest) -> AnyPublisher<APIResponse, URLError> {
            return dataTaskPublisher(for: request).eraseToAnyPublisher()
        }
    }
    

    然后您的模拟可以以任何方便的方式创建发布者。例如:

    struct MockAPIProvider: APIProvider {
        func apiResponse(for request: URLRequest) -> AnyPublisher<APIResponse, URLError> {
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!
            let data = "Hello, world!".data(using: .utf8)!
            return Just((data: data, response: response))
                .setFailureType(to: URLError.self)
                .eraseToAnyPublisher()
        }
    }
    

    【讨论】:

    • 您可以使用Result.success((data: data, response: response)).publisher,而不是Just(_:).setFailureType(to:),推断故障类型。它也适用于测试错误条件:Result.failure(URLError(.timedOut)).publisher,或任何所需的 URL 错误。
    • 是的。你也可以说Result.Publisher((data: data, response: response))Result.Publisher(URLError(.timedOut)),更短。
    • 我必须使用let response: URLResponse = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! 来匹配预期的类型。
    【解决方案2】:

    如果您将要测试的每个 API 调用存储在 UT 捆绑存根 JSON(XML 或其他东西)中,那么最简单的模拟代码可能如下所示

    class URLSessionMock: APIDataTaskPublisher {
        func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher {
    
            // here might be created a map of API URLs to cached stub replies
            let stubReply = request.url?.lastPathComponent ?? "stub_error"
            return URLSession.shared.dataTaskPublisher(for: Bundle(for: type(of: self)).url(forResource: stubReply, withExtension: "json")!)
        }
    }
    

    所以改为调用网络服务器,您的发布者是使用具有已知数据的本地存储资源的 URL 创建的,因此您可以验证所有工作流程。

    【讨论】:

      【解决方案3】:

      我将开发从一个简单的 Get 请求到为组合模拟 .dataTaskPublisher 以及最后一部分测试调用的步骤。这是一个随时可用的代码,供所有人使用,以防其他人需要它。

      按照评论添加您的模型或任何取决于您的项目数据的内容。

      所以这是为我的 NetworkRequest 类提供规则的协议:

      protocol NetworkRequestProtocol {
      
        associatedtype Resource
      
        var resourceURL: NetworkEndpoint { get set }
        var resourceSession: URLSession { get set }
      
        func download() -> AnyPublisher<Resource, NetworkError>
      }
      

      有一些自定义类,NetworkEndpoint 和 NetworkError,如果需要,可以在此处添加自己的类,或者使用 URL 和 URLError 代替:

      enum NetworkEndpoint {
      
        static let baseURL = URL(string: "API_BASE_URL")! // Add your api base url here
      
        case live
      
        var url: URL {
          switch self {
          case .live:
            return NetworkEndpoint.baseURL!.appendingPathComponent("END_OR_YOUR_API_URL") // Add the end of your API url here
          }
        }
      }
      
      enum NetworkError: LocalizedError {
      
        case addressUnreachable(URL)
        case invalidResponse
      
        var errorDescription: String? {
          switch self {
          case .invalidResponse:
            return "The server response is invalid."
          case .addressUnreachable(let url):
            return "\(url.absoluteString) is unreachable."
          }
        }
      }
      

      现在,我正在创建 NetworkRequest 类来处理 API 调用。 RessourceSession 初始化器仅用于 UnitTest 部分:

      final class NetworkRequest<Resource> where Resource: Codable {
      
        var resourceURL: NetworkEndpoint
        var resourceSession: URLSession
      
        init(_ resourceURL: NetworkEndpoint,
             resourceSession: URLSession = URLSession(configuration: .default)) {
          self.resourceURL = resourceURL
          self.resourceSession = resourceSession
        }
      
        // MARK: - Dispatch Queues
        let downloadQueue = DispatchQueue(
          label: "downloadQueue", qos: .userInitiated,
          attributes: .concurrent, autoreleaseFrequency: .inherit, target: .main)
      }
      
      // MARK: - Network Requests
      extension NetworkRequest: NetworkRequestProtocol {
      
        func download() -> AnyPublisher<Resource, NetworkError> {
      
          resourceSession
            .dataTaskPublisher(for: resourceURL.url)
            .receive(on: downloadQueue)
            .map(\.data)
            .decode(type: Resource.self, decoder: JSONDecoder())
            .mapError { error -> NetworkError in
              switch error {
              case is URLError:
                return .addressUnreachable(self.resourceURL.url)
              default:
                return .invalidResponse }}
            .eraseToAnyPublisher()
        }
      } 
      

      对于生产代码,这是一个使用 NetworkRequest 类的例子,当然,你的模型必须是 Codable:

      var subscriptions = Set<AnyCancellable>()
      
      func downloadData() {
        NetworkRequest<YOUR_MODEL_NAME>(.live).download() // Add your model name inside the brackets
          .sink(
            receiveCompletion: { completion in
              switch completion {
              case .failure(let error):
                print(error)
              case .finished:
                break }},
            receiveValue: { data in
              print(data) })
          .store(in: &subscriptions)
      }
      

      现在项目中的所有代码都设置好了,我们可以传递给 UnitTest 部分并开始模拟 URLSession:

      class MockURLSession: URLSession {
      
        var data: Data?
        var response: URLResponse?
        var error: Error?
      
        init(data: Data?, response: URLResponse?, error: Error?) {
          self.data = data
          self.response = response
          self.error = error
        }
      
        override func dataTask(with request: URLRequest,
                               completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
          let data = self.data
          let response = self.response
          let error = self.error
          return MockURLSessionDataTask {
            completionHandler(data, response, error)
          }
        }
      }
      

      现在,我们模拟在 MockURLSession 中覆盖 dataTask 时返回的 URLSessionDataTask,它将适用于 .dataTaskPublisher:

      class MockURLSessionDataTask: URLSessionDataTask {
        private let closure: () -> Void
      
        init(closure: @escaping () -> Void) {
          self.closure = closure
        }
      
        override func resume() {
          closure()
        }
      }
      

      我们会创建假响应数据以传递到我们的测试中,但您必须创建一个包含您的数据的 .json 文件才能在测试中获取它们:

      class FakeResponseData {
      
        static let response200OK = HTTPURLResponse(url: URL(string: "https://test.com")!,
                                                   statusCode: 200,
                                                   httpVersion: nil,
                                                   headerFields: nil)!
      
        static let responseKO = HTTPURLResponse(url: URL(string: "https://test.com")!,
                                                statusCode: 500,
                                                httpVersion: nil,
                                                headerFields: nil)!
      
        class RessourceError: Error {}
        static let error = RessourceError()
      
        static var correctData: Data {
          let bundle = Bundle(for: FakeResponseData.self)
          let fakeJsonURL = bundle.url(forResource: "FAKE_JSON_FILE_NAME", withExtension: "json") // Add your fake json file name in here
          let fakeJsonData = try! Data(contentsOf: fakeJsonURL!)
          return fakeJsonData
        }
      
        static let incorrectData = "error".data(using: .utf8)!
      }
      

      最后,这是您测试 NetworkRequest 的部分,使用来自 .json 文件的虚假数据或错误。您使用 resourceSession 初始化程序在此处添加您的 MockURLSession 并避免进行真正的网络调用:

      class NetworkRequestTests: XCTestCase {
      
        var expectation: XCTestExpectation!
        var subscriptions: Set<AnyCancellable>!
      
        override func setUpWithError() throws {
          try super.setUpWithError()
          expectation = XCTestExpectation(description: "wait for queue change")
          subscriptions = Set<AnyCancellable>()
        }
      
        override func tearDownWithError() throws {
          subscriptions = nil
          expectation = nil
          try super.tearDownWithError()
        }
      
        func testNetworkRequest_mockURLSessionAddCorrectDataResponse_returnRatesDataModelValues() throws {
          let expectedTestValue = "test" // This value is set in your .json fake data for testing
      
          // This is where you use resourceSession to pass your fake data
          let networkRequest = NetworkRequest<RatesData>(.live, resourceSession:
            MockURLSession(data: FakeResponseData.correctData,
                           response: FakeResponseData.response200OK,
                           error: nil))
      
          networkRequest.download()
            .sink(
              receiveCompletion: { completion in
                self.expectation.fulfill() },
              receiveValue: { value in
                XCTAssertEqual(expectedTimestamp, value.InFakeJson) // Compare with your fake json file
            })
            .store(in: &subscriptions)
      
          wait(for: [expectation], timeout: 0.1)
        }
      
        func testNetworkRequest_mockURLSessionAddServerErrorAsResponse_returnNetworkErrorInvalidResponse() throws {
          let expectedNetworkError = NetworkError.invalidResponse.localizedDescription
      
          // This is where you use resourceSession to pass your fake data
          let networkRequest = NetworkRequest<RatesData>(.live, resourceSession:
            MockURLSession(data: nil,
                           response: FakeResponseData.responseKO,
                           error: nil))
      
          networkRequest.download()
            .sink(
              receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                  XCTAssertEqual(expectedNetworkError, error.localizedDescription)
                case .finished:
                  break
                }
                self.expectation.fulfill() },
              receiveValue: { value in
                XCTAssertNil(value)
            })
            .store(in: &subscriptions)
      
          wait(for: [expectation], timeout: 0.1)
        }
      }
      

      【讨论】:

        【解决方案4】:

        已回答原始问题,但将在此处重新发布:

        由于DataTaskPublisher 使用URLSession 创建它,你可以模拟它。我最终创建了一个URLSession 子类,覆盖dataTask(...) 以返回一个URLSessionDataTask 子类,我提供了我需要的数据/响应/错误...

        class URLSessionDataTaskMock: URLSessionDataTask {
          private let closure: () -> Void
        
          init(closure: @escaping () -> Void) {
            self.closure = closure
          }
        
          override func resume() {
            closure()
          }
        }
        
        class URLSessionMock: URLSession {
          var data: Data?
          var response: URLResponse?
          var error: Error?
        
          override func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask {
            let data = self.data
            let response = self.response
            let error = self.error
            return URLSessionDataTaskMock {
              completionHandler(data, response, error)
            }
          }
        }
        

        那么显然你只希望你的网络层使用这个URLSession,我去工厂做这个:

        protocol DataTaskPublisherFactory {
          func make(for request: URLRequest) -> URLSession.DataTaskPublisher
        }
        

        然后在你的网络层:

          func performRequest<ResponseType>(_ request: URLRequest) -> AnyPublisher<ResponseType, APIError> where ResponseType : Decodable {
            Just(request)
              .flatMap { 
                self.dataTaskPublisherFactory.make(for: $0)
                  .mapError { APIError.urlError($0)} } }
              .eraseToAnyPublisher()
          }
        

        现在您可以使用URLSession 子类在测试中传递一个模拟工厂(这个断言URLErrors 被映射到一个自定义错误,但您也可以在给定数据/响应的情况下断言其他一些条件):

          func test_performRequest_URLSessionDataTaskThrowsError_throwsAPIError() {
            let session = URLSessionMock()
            session.error = TestError.test
            let dataTaskPublisherFactory = mock(DataTaskPublisherFactory.self)
            given(dataTaskPublisherFactory.make(for: any())) ~> {
              session.dataTaskPublisher(for: $0)
            }
            let api = API(dataTaskPublisherFactory: dataTaskPublisherFactory)
            let publisher: AnyPublisher<TestCodable, APIError> = 
            api.performRequest(URLRequest(url: URL(string: "www.someURL.com")!))
            let _ = publisher.sink(receiveCompletion: {
              switch $0 {
              case .failure(let error):
                XCTAssertEqual(error, APIError.urlError(URLError(_nsError: NSError(domain: "NSURLErrorDomain", code: -1, userInfo: nil))))
              case .finished:
                XCTFail()
              }
            }) { _ in }
          }
        

        其中一个问题是 URLSession init() 已从 iOS 13 中弃用,因此您必须在测试中接受警告。如果有人能找到解决办法,我将不胜感激。

        (注意:我使用 Mockingbird 进行模拟)。

        【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2021-11-05
        • 2020-12-25
        • 2014-11-26
        • 1970-01-01
        • 1970-01-01
        • 2012-08-03
        • 2014-07-01
        相关资源
        最近更新 更多