使用已发布的结果为 ObservableObject ViewModel 编写单元测试

Write unit tests for ObservableObject ViewModels with Published results

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

也许这篇文章可以帮到您

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: [{ [=11=][0].idFromBackend ==  "product-1" }])

        productListViewModel.loadProducts()

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

对我来说最简单明了的方法就是在 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)
}

希望对您有所帮助!