为 ObservableObject ViewModels 编写单元测试并发布结果

Posted

技术标签:

【中文标题】为 ObservableObject ViewModels 编写单元测试并发布结果【英文标题】:Write unit tests for ObservableObject ViewModels with Published results 【发布时间】:2020-02-29 16:23:57 【问题描述】:

今天又遇到了一个我目前遇到的组合问题,希望你们中的某个人能提供帮助。如何为包含 @Published 属性的 ObservableObjects 类编写正常的单元测试?如何在我的测试中订阅它们以获得我可以断言的结果对象?

Web 服务的注入模拟工作正常,loadProducts() 函数在 fetchedProducts 数组中设置了与模拟完全相同的元素。

但我目前不知道如何在我的测试中访问这个数组,因为它被函数填充后,因为我似乎无法在这里工作,loadProducts() 没有完成块。

代码如下所示:

class ProductsListViewModel: ObservableObject 
    let getRequests: GetRequests
    let urlService: ApiUrls

    private let networkUtils: NetworkRequestUtils

    let productsWillChange = ObservableObjectPublisher()

    @Published var fetchedProducts = [ProductDTO]()
    @Published var errorCodeLoadProducts: Int?

    init(getRequestsHelper: GetRequests, urlServiceClass: ApiUrls = ApiUrls(), utilsNetwork: NetworkRequestUtils = NetworkRequestUtils()) 
        getRequests = getRequestsHelper
        urlService = urlServiceClass
        networkUtils = utilsNetwork
    


    // nor completion block in the function used
    func loadProducts() 
        let urlForRequest = urlService.loadProductsUrl()

        getRequests.getJsonData(url: urlForRequest)  [weak self] (result: Result<[ProductDTO], Error>) in
            self?.isLoading = false
            switch result 
            case .success(let productsArray):
                // the products filled async here
                self?.fetchedProducts = productsArray
                self?.errorCodeLoadProducts = nil
            case .failure(let error):
                let errorCode = self?.networkUtils.errorCodeFrom(error: error)
                self?.errorCodeLoadProducts = errorCode
                print("error: \(error)")
            
        
    

目前我尝试编写的测试如下所示:

import XCTest
@testable import MyProject

class ProductsListViewModelTest: XCTestCase 
    var getRequestMock: GetRequests!
    let requestManagerMock = RequestManagerMockLoadProducts()

    var productListViewModel: ProductsListViewModel!

    override func setUp() 
        super.setUp()

        getRequestMock = GetRequests(networkHelper: requestManagerMock)
        productListViewModel = ProductsListViewModel(getRequestsHelper: getRequestMock)
    

    func test_successLoadProducts() 
        let loginDto = LoginResponseDTO(token: "token-token")
        UserDefaults.standard.save(loginDto, forKey: CommonConstants.persistedLoginObject)

        productListViewModel.loadProducts()

        // TODO access the fetchedProducts here somehow and assert them
    


Mock 看起来像这样:

class RequestManagerMockLoadProducts: NetworkRequestManagerProtocol 
    var isSuccess = true

    func makeNetworkRequest<T>(urlRequestObject: URLRequest, completion: @escaping (Result<T, Error>) -> Void) where T : Decodable 
        if isSuccess 
            let successResultDto = returnedProductedArray() as! T
            completion(.success(successResultDto))
         else 
            let errorString = "Cannot create request object here"
            let error = NSError(domain: ErrorDomainDescription.networkRequestDomain.rawValue, code: ErrorDomainCode.unexpectedResponseFromAPI.rawValue, userInfo: [NSLocalizedDescriptionKey: errorString])

            completion(.failure(error))
        
    

    func returnedProductedArray() -> [ProductDTO] 
        let product1 = ProductDTO(idFromBackend: "product-1", name: "product-1", description: "product-description", price: 3.55, photo: nil)
        let product2 = ProductDTO(idFromBackend: "product-2", name: "product-2", description: "product-description-2", price: 5.55, photo: nil)
        let product3 = ProductDTO(idFromBackend: "product-3", name: "product-3", description: "product-description-3", price: 8.55, photo: nil)
        return [product1, product2, product3]
    

【问题讨论】:

【参考方案1】:

也许这篇文章可以帮到你

Testing your Combine Publishers

为了解决您的问题,我将使用我文章中的代码

    typealias CompetionResult = (expectation: XCTestExpectation,
                                 cancellable: AnyCancellable)
    func expectValue<T: Publisher>(of publisher: T,
                                   timeout: TimeInterval = 2,
                                   file: StaticString = #file,
                                   line: UInt = #line,
                                   equals: [(T.Output) -> Bool])
        -> CompetionResult 
        let exp = expectation(description: "Correct values of " + String(describing: publisher))
        var mutableEquals = equals
        let cancellable = publisher
            .sink(receiveCompletion:  _ in ,
                  receiveValue:  value in
                      if mutableEquals.first?(value) ?? false 
                          _ = mutableEquals.remove(at: 0)
                          if mutableEquals.isEmpty 
                              exp.fulfill()
                          
                      
            )
        return (exp, cancellable)
    

你的测试需要用到这个函数

func test_successLoadProducts() 
        let loginDto = LoginResponseDTO(token: "token-token")
        UserDefaults.standard.save(loginDto, forKey: CommonConstants.persistedLoginObject)

/// The expectation here can be extended as needed

        let exp = expectValue(of: productListViewModel .$fetchedProducts.eraseToAnyPublisher(), equals: [ $0[0].idFromBackend ==  "product-1" ])

        productListViewModel.loadProducts()

        wait(for: [exp.expectation], timeout: 1)
    

【讨论】:

感谢您的回答,但我的问题中提到的用例有点不同。我想测试不返回任何内容的函数'loadProducts()'。如果成功加载数据,结果将写入@Published 数组 'fetchedProducts'。 我更新了答案以匹配您的代码。我无法编译您的代码,因此可能需要进行一些小改动。 非常棒的 +1,非常感谢。问题是,它在“if mutableEquals.first?(value) ?? false ”这一行中崩溃。崩溃的错误是“线程 1:致命错误:索引超出范围”。似乎预期值不包含任何内容(“...equals: [ $0[0].idFromBackend =...”行中的 $0 为空)。函数“loadProducts”中的“self?.fetchedProducts = productsArray”行,类“ProductsListViewModel”也没有被调用。这是我在进入测试中比较期望的行之前所期望的。 是的,我错了,尝试替换 $0[0]。 idFromBackend 与 $0.first?。 idFromBackend 很好,它可以按预期正常工作。非常感谢阿波斯托洛斯。使用您的代码,我还能够定义 2 个期望:让期望1 = expectValue(of: productListViewModel.$fetchedProducts.eraseToAnyPublisher(), equals: [ $0.first?.idFromBackend == "product-1" ]) 并让期望2 = expectValue(来自:productListViewModel.$fetchedProducts.eraseToAnyPublisher(),等于:[ $0.count == 3])。在此之后,我可以在等待语句中添加两个期望,如下所示:wait(for: [expectation1.expectation,expectation2.expectation], timeout: 1.0)【参考方案2】:

对我来说最简单、最清晰的方法就是在 X 秒后测试 @published var。下面是一个例子:

func test_successLoadProducts() 
    let loginDto = LoginResponseDTO(token: "token-token")
    UserDefaults.standard.save(loginDto, forKey: CommonConstants.persistedLoginObject)

    productListViewModel.loadProducts()

    // TODO access the fetchedProducts here somehow and assert them

    let expectation = XCTestExpectation()
    DispatchQueue.main.asyncAfter(deadline: .now() + 5) 
        XCTAssertEqual(self.productListViewModel.fetchedProducts, ["Awaited values"])

        expectation.fulfill()
    
    wait(for: [expectation], timeout: 5.0)

希望对你有帮助!

【讨论】:

不错,只要你模拟了响应,效果就很好

以上是关于为 ObservableObject ViewModels 编写单元测试并发布结果的主要内容,如果未能解决你的问题,请参考以下文章

在 ObservableObject 中调用多个函数

SwiftUI 线程 1:致命错误:未找到类型为 SessionStore 的 ObservableObject

来自 ObservableObject 的绑定值

为 ObservableObject ViewModels 编写单元测试并发布结果

在 SwiftUI 中将 ObservableObject 链接到 DatePicker

SwiftUI 致命错误:未找到“”类型的 ObservableObject