为 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 编写单元测试并发布结果的主要内容,如果未能解决你的问题,请参考以下文章
SwiftUI 线程 1:致命错误:未找到类型为 SessionStore 的 ObservableObject
为 ObservableObject ViewModels 编写单元测试并发布结果