【问题标题】:Write unit tests for ObservableObject ViewModels with Published results为 ObservableObject ViewModels 编写单元测试并发布结果
【发布时间】:2023-09-17 10:44:01
【问题描述】:

今天又遇到了一个我目前遇到的组合问题,希望你们中的某个人能提供帮助。如何为包含 @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]
    }
}

【问题讨论】:

    标签: swift xcode unit-testing swiftui combine


    【解决方案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)
    }
    

    希望对你有帮助!

    【讨论】:

    • 不错,只要你模拟了响应,效果就很好
    最近更新 更多