Testing Asynchronously

We’ve learned how to test that parts of our user interface show what they’re supposed to, and that’s really useful. However, the whole point of this scene is to play an audio file, and we would also like to get some test coverage of that functionality. In other words, when we call handlePlayPauseTapped, what proves that anything is playing?

Back in the ViewController class where we have all the controller logic for the scene, we had an AVPlayer that’s responsible for playing the audio. It has a currentTime method that tells us how far into the current media item it has gotten. So this seems straightforward: we write a test to tap the Play button, then check that the current time isn’t 0.

First things first…back in ViewController.swift, we declared the player to be private. Good for encapsulation, but bad for testing, since the test class won’t be able to access it, even with our existing @testable import PragmaticPodcasts declaration. So go back to that class and remove the private keyword, so the declaration looks like this:

 var​ player : ​AVPlayer​?

Back in PragmaticPodcastsTests.swift, we’re now ready to write our test. When we ask for the player’s current time, we’ll get back a struct of type CMTime. That comes from the Core Media framework, so we need to add an import statement to our test class:

 import​ ​CoreMedia

Our test is almost identical to checking the Play button’s title, except now we’re going to check the player’s current time and make sure it’s not zero. The CMTime is picky about timekeeping, and that makes its docs hard to read at first, but it has a convenience property called seconds that is just a Double, and that’s good enough for us. All we care about is that it’s not 0. So here’s our test:

 func​ testPlayerCurrentTime_WhenPlaying_IsNonZero() {
 guard​ ​let​ playerVC = playerVC ​else​ {
 XCTFail​(​"Couldn't set up test"​)
 return
  }
  playerVC.handlePlayPauseTapped(​self​)
 XCTAssertNotEqual​(0, playerVC.player?.currentTime().seconds)
 }

Click the diamond next to the testPlayerCurrentTime_WhenPlaying_IsNonZero method declaration, and like usual Xcode builds our test code, we see some activity in the status window…and the test fails. Wait, what?

images/testing/xcode-synchronous-test-fail.png

Making Tests Wait

What happened? Well, try running the app again and not just the tests. After you tap the Play/Pause button, the title changes immediately, but it takes a second or two for the audio to load and start playing. But from the test’s point of view, as soon as it calls handlePlayPauseTapped, the player object is ready to be tested. What’s happening is that we are testing too soon. We need a way to wait before we run our test.

What we need is asynchronous testing, the ability to test things that happen at unpredictable times. If we wanted to test that 2 + 2 == 4, or that a string has a certain value, we could do that right away, because the value would be there right when we asked for it. That’s what we’ve been doing all along. But with the AVPlayer, we don’t know when (or if) its contents will start playing. Asynchronous testing lets us test these kinds of unpredictable events.

The way to deal with these situations is a testing class called XCTestExpectation. An XCTestExpectation is an object that describes an event that we expect to happen at some point in the near future. We tell the test how long it can wait, and then perform test assertions elsewhere—in parts of the code that run asynchronously—finally notifying the expectation when we’re done. And if we don’t do so in time, that’s considered a failure.

Joe asks:
Joe asks:
What the Heck Is an “Expectation Object”?

There is a wonderful quote by the late John Pinette that goes, “Salad isn’t food. Salad comes with the food. Salad is a promissory note that food will soon arrive.”

Expectation objects are like salad. They are not the test; they are the promise to your program that something is going to happen a little later.

If you went to a restaurant and got a salad, and then waited for an hour for food that never arrives, you would realize something is terribly wrong. You were set up to expect that another part of your meal was coming, and if it never arrived, your meal would be a failure.

That, in a nutshell, is how asynchronous testing with expectation objects works.

Back in the PragmaticPodcastsTests class, the first thing we will do is create an XCTestExpectation property:

 var​ startedPlayingExpectation : ​XCTestExpectation​?

This expectation object will start as nil (which is why it has to be an optional), and we will populate it when we start the test. When we know the player’s current time is greater than zero, we can tell it that we’re done by calling its fulfill method.

So our new approach is going to be to create the expectation, start playing, periodically check on the player, and when we find playback has started, fulfill the expectation. We’ll also have a timeout to fail the test if we don’t call fulfill in a certain amount of time.

Using a Timer

To periodically check on the player, we have a few options. Probably the simplest is to create a Timer, which is an object that can repeatedly call one of our methods on a schedule we set. We’ll want to hold on to the timer as a property, so that we can turn it off when we don’t need it anymore:

 var​ startedPlayingTimer : ​Timer​?

So using a timer to periodically check on the player, here’s what our new testPlayerCurrentTime_WhenPlaying_IsNonZero is going to look like (this will pop up one error as you type; we’ll fix it in a minute):

1: func​ testPlayerCurrentTime_WhenPlaying_IsNonZero() {
guard​ ​let​ playerVC = playerVC ​else​ {
XCTFail​(​"Couldn't set up test"​)
return
5:  }
startedPlayingExpectation = expectation(description:
"player starts playing when tapped"​)
startedPlayingTimer =
Timer​.scheduledTimer(timeInterval: 1.0,
10:  target: ​self​,
selector: ​#selector(​timedPlaybackChecker​)​,
userInfo: ​nil​,
repeats: ​true​)
playerVC.handlePlayPauseTapped(​self​)
15:  waitForExpectations(timeout: 10.0, handler: ​nil​)
}

There are a bunch of new things to unpack in here, so let’s take it slowly. On lines 6-7, we call the method expectation from our superclass, XCTest, to create an expectation with the given description. We assign this to the property we created earlier, startedPlayingExpectation. This is what we have to fulfill before a timeout in order to pass the asynchronous test.

Lines 9-13 are where we create the Timer. By using the scheduledTimer method, we create a timer that can fire on a regular interval. We have to provide five parameters to create a timer like this:

  • timeInterval: How often, in seconds, the timer should call back to us. Getting called every 1.0 seconds should be frequent enough for a unit test.

  • target: An object to call back to. We want the timer to call back to this test itself, so we use self.

  • selector: A method on the target to call. We describe the method as a selector, which is the method’s name and any named arguments written in a specific format. This line will currently produce an error, as Xcode realizes we haven’t written this timedPlaybackChecker yet.

  • userInfo: An object we want the Timer to deliver to the selector on each callback. We don’t need one, so this can be nil.

  • repeats: A Bool indicating whether we want the Timer to keep firing until we stop it, or once and never again. true means we want it to keep firing.

Next, we virtually tap the button handlePlayPauseTapped as before. But we don’t immediately test anything. Instead, on line 15, we call the XCTest method waitForExpectations. This puts the test on hold for the given timeout period, and allows the system under test to achieve the expected state and call fulfill on all expectations created in the test. If the expectations aren’t fulfilled before the timeout—10.0 seconds in our case—the test fails.

Our final step is to create the timedPlaybackChecker that we told the Timer it could call back to. This method will be called every 1.0 seconds, and needs to check to see if the player has actually started playing. If it has, it should fulfill the expectation created back in the test.

 func​ timedPlaybackChecker(timer: ​Timer​) {
 if​ ​let​ player = playerVC?.player,
  player.currentTime().seconds > 0 {
  startedPlayingExpectation?.fulfill()
  startedPlayingTimer?.invalidate()
  }
 }

A method called as a Timer callback, like our timedPlaybackChecker here, always takes a single argument: the Timer that’s calling into it. Our timed update is pretty simple: we use an if let to get the player, then we check if its currentTime (in seconds) is greater than 0. If that’s true, then we can fulfill the expectation, which will cause the testPlayerCurrentTime_WhenPlaying_IsNonZero test (where waitForExpectations is still pending) to finally pass. As a bit of cleanup, we also call invalidate on the Timer, which causes it to stop running.

So try out our new and improved testPlayerCurrentTime_WhenPlaying_IsNonZero test. You’ll notice that the test run takes a little longer compared to the earlier version that failed quickly, and you’ll probably actually hear a little bit of the podcast audio before the timer realizes that playback has begun and ends the test.

Now we are passing our test and proving that even tricky asynchronous behaviors like waiting for media to download and start playing can be exposed to unit tests!

images/testing/xcode-three-tests-pass.png

The Best Test Is a Reproducible Test

images/aside-icons/warning.png

To keep things simple, we haven’t changed the code of the ViewController class, which currently depends on downloading an MP3 from the Internet in viewDidLoad. That means that tests performed when the computer is offline will fail.

Later on, we’ll be removing that code, and callers will set ViewController’s URL themselves. At that point, it would be better if the tests provided a known-good MP3 in the Xcode project itself. That way, the test could get a URL of this embedded file (see the Bundle documentation if you want to know how this works) and set that on playerVC prior to doing asserts or checking expectations. That way, we would remove a big external dependency and have a greater confidence in our tests.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset