1

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

 
Cristik
  • 30,989
  • 25
  • 91
  • 127
  • You need to mock your entire promise kit function. – matt Jun 20 '21 at 09:52
  • @matt Hi there, thanks for the tip but so is it then entirely impossible to just have the unit test call the async func, wait then see the result of the changed value in the vc? and regarding mocking, i know the term and have seen them in conventional http requests, but as to how to implement it in a promisekit func, do have any sources? – patrick sebastian Jun 20 '21 at 09:59
  • What exactly do you want to test here? If it's the code from the `done` callback, then, you can extract that code into a dedicated method, and test that method instead. – Cristik Jun 21 '21 at 06:04
  • @Cristik hey there, what i'd like to test is the changed values after i've assigned to response to a variable in said VC. for example : stockValue was initially 0.0, after the promisekit func is called, within the .done block i assign one of the response (via a view model/model) to the variable and that's what i'd like to test. The changed value of variables after the func is called and the variables are assigned values in the .done block. – patrick sebastian Jun 21 '21 at 10:32
  • Then convert the `done` callback to a regular function that receives one argument, and call that method from the unit test. This will cover the data post-processing aspect, and you are left with having to test the callback aspect, however since PromiseKit guarantees that the `done` callback will be called if the promise succeeds, you don't need to necessarily test that. – Cristik Jun 21 '21 at 11:28
  • Think of it this way. Only test your code. Not the network. Not PromiseKit. None of that should even be called in a test. Just test your code. – matt Jun 22 '21 at 05:46

1 Answers1

0

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)
}
    
Cristik
  • 30,989
  • 25
  • 91
  • 127
  • Thank you so much for your detailed explanation, turns out my lead didn't explain to me that we didn't have to test the promisekit function itself since we usually check them manually anyways it's easier. And usually bugs happen when theres a problem with the BE database. But this seems uncannily feasible, thank you ;D i'll give it a try – patrick sebastian Jun 22 '21 at 02:38
  • @patricksebastian note that you'd still be testing some promise related code in my last code snippet also, but you're not explicitly testing the promise, but rather the async contract the model is making. As a general rule, try to separate the data processing code and the UI processing code in separate classes, you'll find out that unit testing is easier this way (and not only the unit testing, but your overall architecture will improve). – Cristik Jun 22 '21 at 05:31