lukecsmith.co.uk

Two tactics for unit testing Combine pipelines

Luke, 10 May 2022

Combine publishers require asynchronous testing, and it seems to me that the scenarios coders find themselves in usually involve one of two situations. The first is that you are testing an object which is returning a Publisher, which needs testing. Something like this:

     class ChangePasscodeRepository: ChangePasscodeRepositoryProtocol {

        // this func returns a Combine Publisher, that I want to test:
        func verifyPasscodeToken(passcodeToken: String, deviceId: String) 
            -> AnyPublisher<Bool, Error> {
            ...
        }
    }

The second scenario is trickier though, and its something you also come across at times- no function or property is returning me a Publisher that I can test, and instead I need to run a method that contains, and finishes, a pipeline, without getting access to the Publisher itself. For eg:

     func checkToken() {
        self.repository
            .verifyPasscodeToken(passcodeToken: self.passcodeToken,
                                 deviceId: deviceId)
            .sink { result in
                switch result {
                case .failure(let error):
                    self.introViewState = .somethingWentWrong
                    
                case .finished: ()
                    if self.checkTokenResult == true {
                        self.introViewState = .readyToChangePasscode
                    } else {
                        self.introViewState = .somethingWentWrong
                        self.showIntroAlert = true
                    }
                }
            } receiveValue: { resultBool in
                self.checkTokenResult = resultBool
            }
            .store(in: &self.cancellables)
    }

So here a Combine pipeline is being created, and effectively 'finished' by the .sink part (and then stored).

So to the first kind, where the function I'm testing returns me a Publisher. Because the publisher isnt 'finished' with a .sink, I can add that part myself, and make it fulfill an expectation. Like this:

     func testCheckTokenWithSuccessResponseCode() {
        
        // testing this object:
        let repo = ChangePasscodeRepository()
        
        // creating an expectation to be tested:
        let got204Result = expectation(description: "got 204")
        
        // calling the function, getting the publisher back,
        // and then adding a .sink that fulfils the expectation
        repo.verifyPasscodeToken(passcodeToken: "testToken",
                                                       deviceId: "testDeviceId")
            .sink(receiveCompletion: { result in
                switch result {
                case .failure(_):
                    print("Failure")
                case .finished: ()
                    print("Finished")
                }
            }, receiveValue: { result in
                if result == true {
                    got204Result.fulfill()
                }
            })
            .store(in: &cancellables)
        
        // then waiting for the above pipeline to do its stuff
        waitForExpectations(timeout: 2, handler: nil)
    }

Thats it: create an expectation object, add the fulfill part within the sink, when it has returned the value you need. Then lastly, wait for it all to happen. Easy really.

The second scenario can be tricker. But I found an easy way to do it, using something called XCTNSPredicateExpectation. I hadnt previously come across these, but its a very versatile way of waiting for an expected outcome to happen. Heres an example with comments:

     func testCheckTokenWithSuccessResponseCode() {
        
        // object Im going to test
        let store = ChangePasscodeViewStore()
        
        // object has an initial state (see code above), which I check here
        XCTAssertEqual(store.introViewState, .checkingPasscodeToken)
        
        // create a predicate to check that state object
        let predicate = NSPredicate { _, _ in
            return store.introViewState == .readyToChangePasscode
        }
        let expectation = XCTNSPredicateExpectation(predicate: predicate, object: .none)
        
        // calling the func that contains the pipeline, and changes the state:
        store.checkToken()
        
        // wait for that state to change to the one I want
        self.wait(for: [expectation], timeout: 2)
    }

So from the top: the initial introViewState property of the ChangePasscodeViewStore is .checkingPasscodeToken - that state is asserted at the start. We expect the state to change to .readyToChangePasscode after the store.checkToken() function is executed. To check it changes correctly (asynchronously), the predicate is set up with that expected state, and an then the XCTNSPredicateExpectation is given that predicate. Then we just execute the function, and wait for it to happen. So simple, and its a great way to test any asynchronous code.

Tagged with: