3
\$\begingroup\$

I am working on an iOS project and I'm in charge of testing most parts of it. As I write some tests, I often have the need to wait for an asynchronous method to finish, and then test that something did not happen.

For example, try to login with an invalid username and password, wait until the network communications are finished, then test that the user was not able to login

Swift has some nifty methods for testing these sort of things; the ones I use the most are expectationForPredicate and waitForExpectationsWithTimeout. These methods are good for testing that something happened after the asynchronous method finishes. However, to test that something did not happen (which is what I want), there doesn't seem to be anything designed specifically for this.

  • I can't use predicates because that will cause the test to timeout early
  • I cannot modify the code being tested because it is an app lifecycle callback and we testers generally do not modify any code, we just test

Which is why I designed this method

extension XCTestCase {
    func waitFalseExpectationsUntilTimeout(timeout: NSTimeInterval, expectations: [XCTestExpectation], handler: XCWaitCompletionHandler?) {

        class CompleteSelector {
            @objc func fullFillExpectations(timer: NSTimer) {
                let expectations = timer.userInfo as! [XCTestExpectation]
                for expc in expectations {
                    expc.fulfill()
                }
            }
        }

        let smaller = timeout < 0 ? 0 : timeout.advancedBy(-0.5)

        NSTimer.scheduledTimerWithTimeInterval(smaller, target: CompleteSelector(), selector:
            Selector("fullFillExpectations:"), userInfo: expectations, repeats: false)

        waitForExpectationsWithTimeout(timeout, handler: handler)
    }
}

The main thing the function does is to schedule a timer and fulfill all expectations just before the waitForExpectationsWithTimeout method times out. Is this the best way to deal with this situation? What can I improve with this method (apart from commenting)? Any concurrency issues I should be worried about?


To add some context, waitForExpectationsWithTimeout will cause the test to fail if non of the expectations are fulfilled before it timesout, which is why I had to fulfill all expectations just before this timeout occurs

\$\endgroup\$
4
  • \$\begingroup\$ I have rolled back the last edit. Please see What to do when someone answers. \$\endgroup\$
    – Mast
    Mar 27, 2016 at 0:16
  • \$\begingroup\$ @Mast I did not improve any "code" I wrote, neither did I incorporate any advice I got from the answer or change what I wrote...I simply edited my answer and even clearly marked it as so. This question has been sitting idle for 2 days, so I don't understand how adding more clarification violates any rules \$\endgroup\$
    – smac89
    Mar 27, 2016 at 0:27
  • \$\begingroup\$ The appropriate action is to mark the current answer as accepted after which you could ask a new question with the new code. It's all explained in the link. \$\endgroup\$
    – Mast
    Mar 27, 2016 at 0:49
  • \$\begingroup\$ @Smac89 as I have enough rep, I was able to see the stuff you tried adding.... I recommend you try finding me some time in The 2nd Monitor because I have some advice in regards to the specific thing you're trying to do here... but basically, you should be testing it with functional tests rather than unit tests (since it hasn't been implemented in a unit testable way). \$\endgroup\$
    – nhgrif
    Mar 27, 2016 at 2:10

1 Answer 1

3
\$\begingroup\$

This is an extremely overcomplicated solution to a relatively straightforward problem.

The waitForExpectationsWithTimeout method should effectively only be used assure that a particular asynchronous expectation was fulfilled.

So, in the case where we are testing whether or not the user was successfully logged in, it'd be tempting to write something along the lines of:

func testLogin() {
    let loginExpectation = expectationWithDescription("User successfully logged in")

    LoginManager.doLogin { success in
        if success {
            loginExpectation.fulfill()
        }
    }

    waitForExpectationsWithTimeout(10, handler: nil)
}

But problematically, this test can fail for two reasons:

  1. The user wasn't successfully logged in.
  2. The expectation wasn't fulfilled in the time limit.

If the test fails, we can't be sure whether it would have passed given a longer time limit or if it returned very quickly but wasn't successful.

So, we need to modify our test to look more like this:

func testLogin() {
    let networkExpectation = expectationWithDescription("Network request completed")

    LoginManager.doLogin { success in
        XCTAssertTrue(success)
        networkExpectation.fulfill()
    }

    waitForExpectationsWithTimeout(10, handler: nil)
}

Now, the test determines whether or not the asynchronous code executed, and if so, we verified whether or not the login was a success (or whatever the success variable represents here).

Now, you're looking for the opposite case. You want to know not whether or not the user logged in, but whether or not the user was prevented from logging in. And that's as simple as inverting an XCTAssertTrue to an XCTAssertFalse.

func testLogin() {
    let networkExpectation = expectationWithDescription("Network request completed")

    LoginManager.doLogin { success in
        XCTAssertFalse(success)
        networkExpectation.fulfill()
    }

    waitForExpectationsWithTimeout(10, handler: nil)
}

Importantly, this pattern doesn't require any complicated code (that we would have to of course write tests around ourselves). Our tests are using stuff purely out of the Xcode test suite box--exactly what any one looking at our code base would expect to see. It's very straight forward what is going on here.

And we are able to distinguish between whether the asynchronous call back ever happened or if it did happen but we got a bad result. There's a huge difference here between a test failing because the asynchronous code didn't run before our time out and it did run but it gave an incorrect result. We need to clearly distinguish between these results, and doing so doesn't require writing any complicated code whatsoever.

An important note here, make sure that fulfilling the expectation is the very last thing you do in the asynchronous callbacks, or you may set up a race condition.

And finally, your tests should certainly be far more robust than this. Ultimately, we should be testing with mocked network responses and we should know, for each test, exactly what network response we will get. We should test that whatever code is doing the login returns back the correct information to us. There's a difference between the server allowing the user to log in successfully and denying the user with invalid credentials. There's also a difference between invalid credentials and getting any number of various server errors (most commonly, errors in the 5xx range). Getting a 503 and getting an invalid credential response are far from the same thing, and your tests should validate you're getting what you expect to get.


So, I've basically just told you to completely scrap all the code you've written. And I'll stand by that. There's no reason for the code you've written to exist. But, we can still talk about some of the issues your existing code does have.

This function name has a weird name: waitFalseExpectationsUntilTimeout. It's hard for me to tell by name alone what to expect this method to do. It looks similar to the existing waitForExpectationsWithTimeout(timeout: NSTimeInterval, handler: XCWaitCompletionHandler?) method, but it has this weird array of expectations I'm supposed to pass in. The name needs to be better. Something like assertUnfulfilledExpectationsWithTimeout.

Even with the better name, we're in extraordinary trouble here. As far as I can see, there's no public means of checking whether or not an expectation has been previously fulfilled. Your code doesn't really do anything beyond fulfilling all of the expectations in the last moment before the test completes. It doesn't assure that they weren't fulfilled earlier.

let smaller = timeout < 0 ? 0 : timeout.advancedBy(-0.5)

This variable deserves a better name, and this logic either deserves a comment or it should be eliminated altogether. I'd opt for eliminating it altogether and throwing an exception if the user has passed a value less than 0.5. And finally, this logic is ultimately problematic, because you can end up with negative time (if timeout is less than 0.5).

You should probably opt for some logic that looks more like this:

if timeout < 0.5 {
    // fulfill expectation immediately
}
else {
    // set up timer with timeout - 0.5 to fulfill expectations
}

Finally, there are several issues with the nested class:

class CompleteSelector {
    @objc func fullFillExpectations(timer: NSTimer) {
        let expectations = timer.userInfo as! [XCTestExpectation]
        for expc in expectations {
            expc.fulfill()
        }
    }
}

First, we don't need to mark this as an @objc function. We can instead mark it as dynamic.

Second, we're force-casting, but we're not Jedi. This would not pass my personal set of swiftlint rules where force-casting is considered an error and refuses to compile.

Third, expc is a meaningless variable name, and there's no real value in abbreviating here.

Fourth, you've misspelled fulfill even though it's spelled correctly several other times in this context by virtue of autocompleting method names.

Fifth, why don't we just add a method to the extension to handle this instead of over complicating with a nested class with a weird name?

enum ExpectationArgumentError: ErrorType {
    case InvalidTimeInterval, NoExpectations
}

extension XCTestCase {
    func assertUnfulfilledExpectations(timeout: NSTimeInterval, expectations: [XCTestExpectation], handler: XCWaitCompletionHandler?) throws {
        if expectations.empty { 
            throw ExpectationArgumentError.NoExpectations
        }      

        let fulfillTime = timeout - 0.5
        if fulfillTime < 0 {
            throw ExpectationArgumentError.InvalidTimeInterval
        }

        NSTimer.scheduledTimerWithTimeInterval(fulfillTime, target: self, selector: "fulfillExpectations:", userInfo: expectations, repeats: false)

        waitForExpectationsWithTimeout(timeout, handler: handler)
    }

    private dynamic func fulfillExpectations(timer: NSTimer) {
        if let expectations = timer.userInfo as? [XCTestExpectation] {
            for expectation in expectations {
                expectation.fulfill
            }
        }
    }
}

So, that's a significantly cleaned up and safer version of your code... but again, it doesn't actually assert that these expectations were not fulfilled before you fulfilled them (as far as I know that's impossible). And ultimately, it's a quite a lot of code to implement, test, maintain, and verify that it works... and that is especially so when we compare that to the simply expectation+assertion combo I outlined at the top of this answer.


I wanted to add an addendum in case the issue was that there is a particular block of code that is only executed in the case of a successful login. If this is the scenario, there are only two possibilities.

The first is that there are separate success and failure blocks. Something along these lines:

LoginManager.doLogin("username", password: "password", 
    success: {
        // login was successful
    },
    failure: {
        // login failed
    }
)

In this case, the gist is roughly the same as what I've outlined at the top. We do want to fulfill the expectation in both blocks, but in the success block, we'll just add an XCTFail("User was allowed to log in") so that we still wait around for this block (or the failure block) to execute, but if it takes this path, we fail for the specific reason that the wrong branch was entered (as opposed to simply taking the right path but not taking it quickly enough).

The other case is that there is only a callback for the success path:

LoginManager.doLogin {
    // if logged in, this is called... otherwise, nothing
}

If THIS is the design, I don't have a clue how your app manages to give any feedback to the user at all that something went wrong during the login process... I know you can't modify the code, but you need to be telling the people who can that they need to fix this problem.

\$\endgroup\$
1
  • \$\begingroup\$ THANK YOU! THANK YOU! I tried added some more information up there but it was deleted. You're right, I get no callback after logging in other than checking if the user's information was stored in NSUserDefaults. Anyways, I can't get into more detail here as the comments are soo limited, but I value your advice and I will keep them in mind for my next project. The tip about dynamic is something I will have to look into. I just started learning swift this semester so I haven't been around the objc/swift world to know the best practices \$\endgroup\$
    – smac89
    Mar 27, 2016 at 0:32

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.