Completion Handler Skipped

I see this happening in other threads but none of those solutions have worked for me. I'm new to Swift (my apologies) and attempting a simple test to get a string response from a service I have running on my machine. (This service show results from a browser and Postman so I'm sure it's working.)

This is my code:

        let url = URL(string: "http://127.0.0.1:8080/stuff/ping")!
        
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        
        print("creating ping task...")
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            print("processing ping result...")
            if let data = data {
                let str = String(decoding: data, as: UTF8.self)
                print(str)
           } else if let error = error {
                print("HTTP Request Failed \(error)")
            }
        }
        
        print("resuming ping task...")
        task.resume()

When I run that in a playground, it works! The console shows:

creating ping task....
resuming ping task...
processing ping result...
One ping only, Vasily

But from within a XCTest, it does not:

Test Suite 'NetworkTests' started at 2023-06-02 08:24:46.007
Test Case '-[StuffTests.NetworkTests testPing]' started.
creating ping task...
resuming ping task...
Test Case '-[StuffTests.NetworkTests testPing]' passed (0.002 seconds).
Test Suite 'NetworkTests' passed at 2023-06-02 08:24:46.010.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.002 (0.002) seconds

I've set the info of the project according to what I've seen in other posts so that it can access a local host (?):

I appeal to you for help in understanding and fixing this. thank you!

Replies

When you use the URLSession convenience APIs the completion handler is called asynchronously. This works in normal apps because the app keeps running. In XCTest, however, the process running the tests terminates before the task completes.

There are two ways you can tackle this:

  • Using expectations

  • Using Swift concurrency


With expectations, you set up an expectation at the start of your task and then wait for it be fulfilled at the end. Then, in your completion handler, you fulfil the expectation. For example:

func testWithExpectation() throws {
    print("did start test")
    let expectation = self.expectation(description: "task completion")

    print("will start task")
    let url = URL(string: "https://postman-echo.com/delay/3")!
    let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 60.0)
    URLSession.shared.dataTask(with: request) { (data, response, error) in
        if let error = error as NSError? {
            print("task did fail, error \(error.domain) / \(error.code)")
        } else {
            let response = response as! HTTPURLResponse
            let data = data!
            print("task finished with status \(response.statusCode), bytes \(data.count)")
        }
        expectation.fulfill()
    }.resume()
    print("did start task")
    
    self.wait(for: [expectation])
    print("did finish test")
}

This prints:

did start test
will start task
did start task
task finished with status 200, bytes 18
did finish test

With Swift concurrency you declare your test method to be async and then call the async version of the URLSession API:

func testWithSwiftConcurrency() async throws {
    do {
        let url = URL(string: "https://postman-echo.com/delay/3")!
        let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 60.0)
        let (data, r) = try await URLSession.shared.data(for: request)
        let response = r as! HTTPURLResponse
        print("task finished with status \(response.statusCode), bytes \(data.count)")
    } catch let error as NSError {
        print("task did fail, error \(error.domain) / \(error.code)")
        throw error
    }
}

Neat-o!

Note that I’m catching the error here as a parallel to my testWithExpectation() example. In most cases, however, it’s fine to not have a docatch statement and just throw the error right out of the test method, which will do the obvious thing (cause the test to fail).

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Quinn! Thank you!

I wasn't aware that XCTest had a different lifecycle so thank you for opening my eyes. As a follow up question, if expectations or concurrency are not needed in the 'real' app, are they still considered best practice?

Thank you for getting me out of this jam.

if expectations or concurrency are not needed in the 'real' app, are they still considered best practice?

When it comes to networking, concurrency is unavoidable. You are, after all, sending a request to a different machine. The question is how you model that concurrency.

URLSession supports two concurrency models:

  • The original completion handler model

  • Swift concurrency

Both of them work just fine, so it’s largely a question of which one you prefer. Speaking personally, for network requests like this, I find Swift concurrency much nicer to use.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"