trashpanda.cc

Kohlenstoff-basiert Zweibeiniges Säugetier

Asynchronous testing of non-callback code in Swift

The basic testing setup in Swift is the setup - run - assert loop:

 setup
   |
  run
   |
execute
   |
 assert

That's fine for code that runs synchronously, but if you're having to test anything that's asynchronous in nature things get a bit trickier.

In this context, async is something that's either slow (say a network request) or uses callbacks (again, a common pattern in networking code). In these situations, the assertions are made before the code under test will execute:

 setup
   |
  run
   |
 assert
   |
execute

The assertions here will always fail, because the code under test hasn't had the chance to do anything before the assertions are tested.

The solution to this is to create an instance of XCTestExpectation that acts as a semaphore - basically it enables the assertions to be paused until the expectation to resolve. So the pattern becomes:

      setup
        |
 create expectation
        |
       run
        |
     execute
        |
fulfill expectation
        |
      assert

With asynchronous code that has callbacks that are executed, the callback is the obvious place to fulfill the expectation - something along the lines of

doSlowThingWith(params: params) { result in

    switch result {

    case .Success:
        // Everything worked
        expectation.fulfill()

    case .Failure (let error):
        // Something went wrong
        print(error)
    }

}

But what if the code you're testing doesn't use this kind of callback pattern?


doSlowThingWithParams(params: params) {
    // do something
}

In this situation, there's nowhere to fulfill the expectation. One option would be to write the code in the callback pattern to begin with, but that feels wrong - it's a good idea to write testable code, but testing driving architectural decision to this extent isn't such a great idea.

There is a solution, albeit one that feels a bit hacky. It involves throwing the expectation onto a dispatch queue with a long enough delay to guarantee that the slow operation will have finished.

Yes, this does sound a bit like the "I had one problem so I used threading. Now problems two I've got" situation. But it does work. Here's an example:

func test_WhenSomethingSlowHappens_ShouldWorkCorrectly() {

    // Setup code under test

    // Create expectation

    // Fire the slow code
    doSlowThing()

    // Set up the delay
    let mainQueue = DispatchQueue.main
    let deadline = DispatchTime.now() + .seconds(5)

    // Throw the assert and the fulfillment onto the queue
    mainQueue.asyncAfter(deadline: deadline) {
        XCTAssertTrue(mockInteractor.didCallLoginSuccess)
        expect.fulfill()
    }

    // Hang around waiting for the expectation to be fulfilled,
    // which should happen before this timeoue
    waitForExpectations(timeout: 10) { (error) in
        if let error = error {
            print("Error: \(error.localizedDescription)")
        }
    }

}

It's not ideal, because you're responsible for figuring out what the delay and timeout should be. Too slow, and you'll waste time waiting for your test suite to run; too fast and the expectation and assert will be out of sync. But aside from those limitations, it's an approach to testing asynchronous code that works.