Search code examples
iosswifttestingbackground-thread

How can I properly test a background URLSessionDownloadTask?


I'm following this Apple guide on Downloading Files in the Background and wondering how to properly test that all the steps are working properly. (via unit testing also, if you have resources please share but not the primary focus of this question).

According to the documentation, there are multiple steps for this to work (not listing the standard URLSessionDownloadTask delegates, only background requirements):

  1. Create a background session with URLSessionConfiguration.background(withIdentifier: "MyBackgroundDownload")
  2. Implement handleEventsForBackgroundURLSession
  3. Implement urlSessionDidFinishEvents
  4. (optional error catching) Implement didCompleteWithError

However, when testing the app getting terminated by the system I am still getting a successful call to my urlSession:downloadTask:didFinishDownloadingTo implementation even without implementing steps 2 and 3.

If it's still working without these steps, how do I know they're doing anything at all? Do I even need those steps? This is not giving me confidence.

To get the app killed by the system, I'm using the Fast App Termination developer tool on device, so I am NOT debugging directly in Xcode (since I was concerned that could be keeping the app alive in some way).


Solution

  • You asked:

    How can I properly test a background URLSessionDownloadTask?

    As you noted, you cannot test what happens to a suspended/terminated app from the Xcode debugger (because the debugger will keep the app running). So, just install the app on the device and then launch it directly on the device, not from Xcode.

    So that begs the question as to how one confirms what is going on during background execution. Specifically, the question is how one monitors what is going without the benefit of either the Xcode console or the app running in the foreground.

    There are a variety of techniques that I use to monitor what is going on during background execution:

    1. I will have the app present a “user notification” when the downloads were done in the background (while testing it, at the very least). This way I get a visual confirmation even though the app is running in the background.

      So, when the app is in foreground, I request user notifications permissions:

      import UserNotifications
      

      And

      override func viewDidAppear(_ animated: Bool) {
          super.viewDidAppear(animated)
      
          UNUserNotificationCenter.current().requestAuthorization(options: .alert) { granted, error in
              print(granted, String(describing: error))
          }
      }
      

      And then, when the download is done, I post a user notification:

      extension BackgroundSession: URLSessionDelegate {
          func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
      
              UNUserNotificationCenter.current().add(title: "Downloads finished", body: "Hurray")
      
              // then continue doing other stuff; e.g., in this method, I 
              // would often call the saved completion handler, if any
              //
              // DispatchQueue.main.async {
              //     self.savedCompletionHandler?()
              //     self.savedCompletionHandler = nil
              // }
          }
      }
      
      extension UNUserNotificationCenter {
          func add(title: String, body: String) {
              let content = UNMutableNotificationContent()
              content.title = title
              content.body = body
              let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
              let request = UNNotificationRequest(identifier: Bundle.main.bundleIdentifier!, content: content, trigger: trigger)
      
              add(request)
          }
      }
      
    2. Another approach is to watch iOS logging messages from the macOS Console. To do this, create a Logger:

      import os.log
      
      private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "BackgroundSession")
      

      And then I will have key events log messages.

      extension BackgroundSession: URLSessionDelegate {
          func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
              logger.debug(#function)
      
              …
          }
      }
      

      I can then watch these iOS events on my macOS Console app. See Logger discussion in Swift: print() vs println() vs NSLog().

    3. If these downloads take so long that staring at the macOS Console is impractical, I sometimes log these events to some persistence store (e.g., whether SwiftData/CoreData or just write them to a text or JSON file). Then I can then download this from the Xcode “Devices” window at a later point in time. (For background URLSession, the aforementioned Console approach is sufficient, but this technique can be very useful for background events whose timing is less prompt/predictable, such as BGProcessingTask, BGAppRefreshTask, etc.)


    You mention the “Fast App Termination” developer setting on the device. That is not necessary to test this “relaunch in background” functionality. Yes, it is useful to test the scenario of “what if the app was terminated in the course of its normal lifecycle” workflow (to make sure the background URLSession is properly re-instantiated). But it is not necessary simply to verify the “relaunch app in background” functionality.


    You said that it appeared to work despite not implementing handleEventsForBackgroundURLSession and urlSessionDidFinishEvents.

    You do not need to implement these if you do not want your app awakened when the background requests finished (i.e., you explicitly set sessionSendsLaunchEvents to false).

    But if you do want your app to be launched in the background when the network request finish, Apple is quite specific that we should implement these two methods. The idea behind this pattern is that if the app is launched in the background, we want to tell the OS when we are done and the app can be suspended again. The documentation is not always explicit regarding the repercussions of failing to implement all of the necessary delegate methods, but, commonly, apps that fail to fulfill their background execution obligations may become ineligible for background execution in the future. Apple has a history of becoming evermore strict about apps abusing background execution, so I would advise implementing API as advised in the documentation.


    You shared a quote from the documentation that says:

    Once your resumed app calls the completion handler, the download task finishes its work and calls the delegate’s urlSession(_:downloadTask:didFinishDownloadingTo:) method.

    This would seem to imply that the didFinishDownloadingTo is called after you call the closure. That is not the case. As outlined below, urlSessionDidFinishEvents (which is where we would often call the saved closure) is only called after all the task delegate methods (including didFinishDownloadingTo) have been called, not before.

    Furthermore, the correct sequence of events is outlined earlier in the very same document:

    When all events have been delivered, the system calls the urlSessionDidFinishEvents(forBackgroundURLSession:) method of URLSessionDelegate. At this point, fetch the [saved completion handler] stored by the app delegate.


    I just did an empirical test to verify the sequence of events. That confirms that if your app is be awakened when the uploads/downloads are done, the sequence of events is as follows:

    1. While the app is running, create background URLSession and start downloads. I just leave the app while they are underway to suspend the app. A few observations:

      • Note, you cannot be attached to the Xcode debugger when you do this, as this artificially keeps the app running in the background.
      • Needless to say, the app will not be relaunched if you overrode the sessionSendsLaunchEvents configuration setting to false.
    2. The app delegate’s handleEventsForBackgroundURLSession is called if the app was not running when the downloads finished and the app was therefore relaunched in the background to let you process the tasks. If and when this method is called, we

      • Save the closure for future reference and will use it at step 4, below.

      • If the app had previously been terminated, we obviously recreate the the background URLSession session using the same identifier. If the app had merely been suspended (which is more likely), then obviously we still have the existing background URLSession running.

      • Obviously, this is only called if the app is awakened and launched in the background.

      Needless to say, if the app happened to be running in the foreground when the downloads finished, this event is not called. But you must still implement this method to support the scenario of the app having been suspended/terminated. (If are not concerned about the app having been suspended/terminated while downloads were in progress, you probably would not be using background URLSession, at all.)

    3. All the task delegate methods are called (e.g., didFinishDownloadingTo, didCompleteWithError, etc.) for the completed background URLSession tasks.

    4. Only when all of those events are done, will the urlSessionDidFinishEvents get called. This is often where we would generally call the saved completion handler closure that we received at step 2. (Some may note that if you’ve launched additional asynchronous events as a result of step 3, then you would defer the calling of the closure until all those are done, too, but that is a bit of an edge-case.)

    Please note: I have swapped 3 & 4 from your original list, as the task delegate methods are called before urlSessionDidFinishEvents is.