2

Today again one combine problem I currently run in and I hope that someone of you can help. How can normal unit tests be written for ObservableObjects classes which contain @Published attributes? How can I subscribe in my test to them to get the result object which I can assert?

The injected mock for the web service works correctly, loadProducts() function set exactly the same elements from the mock in the fetchedProducts array.

But I don't know currently how to access this array in my test after it is filled by the function because it seems that I cannot work with expectations here, loadProducts() has no completion block.

The code looks like this:

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)")
            }
        }
    }
}

The test I try to write looks like this at the moment:

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
    }
}

The Mock looks like this:

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]
    }
}
rb90
  • 289
  • 1
  • 6
  • 19

2 Answers2

2

Maybe this article can help you

Testing your Combine Publishers

To solve your issue I will use code from my article

    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)
    }

your test needs to use this function

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)
    }
Apostolos
  • 409
  • 3
  • 13
  • Thank you for the answer, but the use case mentioned in my question is a little bit different. I want to test the function 'loadProducts()' which does not return anything. If loaded data successfully, the result is written in the @Published array 'fetchedProducts'. – rb90 Mar 05 '20 at 19:53
  • 1
    I updated my answer to match your code. I could not compile your code so it may need some minor changes. – Apostolos Mar 05 '20 at 22:16
  • Great +1, thanks a lot. The problem is, that it crashes in this line "if mutableEquals.first?(value) ?? false {". The error of the crash is "Thread 1: Fatal error: Index out of range". It seems that the expected value does not contain anything ($0 from the line "...equals: [{ $0[0].idFromBackend =..." is empty). Also the line "self?.fetchedProducts = productsArray" from the function "loadProducts", class "ProductsListViewModel" is not called. This is what I would expect until entering the line in the test where the expectation gets compared. – rb90 Mar 06 '20 at 21:11
  • 1
    Yes, my mistake, try replacing the $0[0]. idFromBackend with $0.first?. idFromBackend – Apostolos Mar 07 '20 at 10:30
  • 1
    Great with this it is working correctly as expected. Thanks a lot Apostolos. With your code I was also able to define 2 expectations: let expectation1 = expectValue(of: productListViewModel.$fetchedProducts.eraseToAnyPublisher(), equals: [{ $0.first?.idFromBackend == "product-1" }]) and let expectation2 = expectValue(of: productListViewModel.$fetchedProducts.eraseToAnyPublisher(), equals: [{ $0.count == 3}]). And after this I could add both expectations in the wait statement which looks then like this: wait(for: [expectation1.expectation, expectation2.expectation], timeout: 1.0) – rb90 Mar 07 '20 at 11:15
  • So again, very cool and thanks a lot, this issue gets definitely solved now by your answer and the code you provide here +1!!! :) It works great also when using @Published results in ObservableObject classes. – rb90 Mar 07 '20 at 11:19
1

The easy and clearest way for me is simply to test @published var after X seconds. An example bellow :

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)
}

I hope that helps !