swiftxcodeunit-testingasynchronouspromisekit

How to Unit Test asynchronous functions that uses Promise Kit


Might I be so inclined to ask for a hand and or different perspectives on how to Unit Test a function on my Viewcontroller that calls an HTTP request to a Back End server using promise kit which returns JSON that is then decoded into the data types needed and then mapped.

This is one of the promise kit functions (called in viewWillAppear) to get stock values etc...

func getVantage(stockId: String) {
        firstly {
            self.view.showLoading()
        }.then { _ in
            APIService.Chart.getVantage(stockId: stockId)
        }.compactMap {
            return $0.dataModel()
        }.done { [weak self] data in
            guard let self = self else { return }
            self.stockValue = Float(data.price ?? "") ?? 0.00
            self.valueIncrease = Float(data.delta ?? "") ?? 0.00
            self.percentageIncrease = Float(data.deltaPercentage ?? "") ?? 0.00
            let roundedPercentageIncrease = String(format: "%.2f", self.percentageIncrease)
            self.stockValueLabel.text = "\(self.stockValue)"
            self.stockValueIncreaseLabel.text = "+\(self.valueIncrease)"
            self.valueIncreasePercentLabel.text = "(+\(roundedPercentageIncrease)%)"
        }.ensure {
            self.view.hideLoading()
        }.catch { [weak self] error in
            guard let self = self else { return }
            self.handleError(error: error)
        }
    }

I've thought of using expectations to wait until the promise kit function is called in the unit test like so :

func testChartsMain_When_ShouldReturnTrue() {
        
        //Arange
        let sut = ChartsMainViewController()
        let exp = expectation(description: "")
        let testValue = sut.stockValue
        
        //Act
        
 -> Note : this code down here doesn't work
 -> normally a completion block then kicks in and asserts a value then checks if it fulfills the expectation, i'm not mistaken xD
-> But this doesn't work using promisekit

        //Assert
        sut.getVantage(stockId: "kj3i19") {
        XCTAssert((testValue as Any) is Float && !(testValue == 0.0))
        exp.fulfill()
        }
        self.wait(for: [exp], timeout: 5)
    }

but the problem is promisekit is done in its own custom chain blocks with .done being the block that returns a value from the request, thus i can't form the completion block on the unit test like in conventional Http requests like :

sut.executeAsynchronousOperation(completion: { (error, data) in
    XCTAssertTrue(error == nil)
    XCTAssertTrue(data != nil)

    testExpectation.fulfill()
})

 

Solution

  • You seem to have an awful amount of business logic in your view controller, and this is something that makes it harder (not impossible, but harder) to properly test your code.

    Recommending to extract all networking and data processing code into the (View)Model of that controller, and expose it via a simple interface. This way your controller becomes as dummy as possible, and doesn't need much unit testing, and you'll be focusing the unit tests on the (view)model.

    But that's another, long, story, and I deviate from the topic of this question.

    The first thing that prevents you from properly unit testing your function is the APIService.Chart.getVantage(stockId: stockId), since you don't have control over the behaviour of that call. So the first thing that you need to do is to inject that api service, either in the form of a protocol, or in the form of a closure.

    Here's the closure approach exemplified:

    class MyController {
        let getVantageService: (String) -> Promise<MyData>
    
        func getVantage(stockId: String) {
            firstly {
                self.view.showLoading()
            }.then { _ in
                getVantageService(stockId)
            }.compactMap {
                return $0.dataModel()
            }.done { [weak self] data in
                // same processing code, removed here for clarity 
            }.ensure {
                self.view.hideLoading()
            }.catch { [weak self] error in
                guard let self = self else { return }
                self.handleError(error: error)
            }
        }
    }
    

    Secondly, since the async call is not exposed outside of the function, it's harder to set a test expectation so the unit tests can assert the data once it knows. The only indicator of this function's async calls still running is the fact that the view shows the loading state, so you might be able to make use of that:

    let loadingPredicate = NSPredicate(block: { _, _ controller.view.isLoading })
    let vantageExpectation = XCTNSPredicateExpectation(predicate: loadingPredicate, object: nil)
    
    

    With the above setup in place, you can use expectations to assert the behaviour you expect from getVantage:

    func test_getVantage() {
        let controller = MyController(getVantageService: { _ in .value(mockedValue) })
        let loadingPredicate = NSPredicate(block: { _, _ !controller.view.isLoading })
        let loadingExpectation = XCTNSPredicateExpectation(predicate: loadingPredicate, object: nil)
    
        controller.getVantage(stockId: "abc")
        wait(for: [loadingExpectation], timeout: 1.0)
    
        // assert the data you want to check
    }
    

    It's messy, and it's fragile, compare this to extracting the data and networking code to a (view)model:

    struct VantageDetails {
        let stockValue: Float
        let valueIncrease: Float
        let percentageIncrease: Float
        let roundedPercentageIncrease: String
    }
    
    class MyModel {
      let getVantageService: (String) -> Promise<VantageDetails>
    
      func getVantage(stockId: String) {
            firstly {
                getVantageService(stockId)
            }.compactMap {
                return $0.dataModel()
            }.map { [weak self] data in
                guard let self = self else { return }
                return VantageDetails(
                    stockValue: Float(data.price ?? "") ?? 0.00,
                    valueIncrease: Float(data.delta ?? "") ?? 0.00,
                    percentageIncrease: Float(data.deltaPercentage ?? "") ?? 0.00,
                    roundedPercentageIncrease: String(format: "%.2f", self.percentageIncrease))
            }
        }
    }
    
    func test_getVantage() {
        let model = MyModel(getVantageService: { _ in .value(mockedValue) })
        let vantageExpectation = expectation(name: "getVantage")
    
        model.getVantage(stockId: "abc").done { vantageData in
            // assert on the data
    
            // fulfill the expectation
            vantageExpectation.fulfill() 
        }
       
        wait(for: [loadingExpectation], timeout: 1.0)
    }