3

How do you write a unit test that checks whether an async function doesn't timeout?

I'm trying with regular XCTestExpectation, but because await suspends everything, it can't wait for the expectation.

In the code below, I'm checking that loader.perform() doesn't take more than 1 second to execute.

func testLoaderSuccess() async throws {    
    let expectation = XCTestExpectation(description: "doesn't timeout")

    let result = try await loader.perform()

    XCTAssert(result.value == 42)

    wait(for: [expectation], timeout: 1) // execution never gets here

    expectation.fulfill()
}
burnsi
  • 6,194
  • 13
  • 17
  • 27
Eric
  • 16,003
  • 15
  • 87
  • 139

4 Answers4

3

It might be prudent to cancel the task if it times out:

func testA() async throws {
    let expectation = XCTestExpectation(description: "timeout")

    let task = Task {
        let result = try await loader.perform()
        XCTAssertEqual(result, 42)
        expectation.fulfill()
    }

    await fulfillment(of: [expectation], timeout: 1)
    task.cancel()
}

If you do not, perform may continue to run even after testA finishes in the failure scenario.


The other approach would be to use a task group:

func testB() async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in
        group.addTask {
            let result = try await self.loader.perform()
            XCTAssertEqual(result, 42)
        }
        group.addTask {
            try await Task.sleep(for: .seconds(1))
            XCTFail("Timed out")
        }
        let _ = try await group.next() // wait for the first one
        group.cancelAll()              // cancel the other one
    }
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
0

You need to structure this in a different way.

You need to create a new Task. In this Task execute and await the async code. After awaiting fulfill the expectation.

Your code did not work because the Thread the Test runs on will stop at wait(for: for the expectation to fulfill, what it never does as the line comes after wait(for:.

func testLoaderSuccess() throws {
    let expectation = XCTestExpectation(description: "doesn't timeout")

    Task{
        try await Task.sleep(nanoseconds: 500_000_000)
        expectation.fulfill()
    }
    
    wait(for: [expectation], timeout: 1)

    // Assertions here because only then is assured that  
    // everything completed
}
burnsi
  • 6,194
  • 13
  • 17
  • 27
  • Thanks but the assertions after the `wait` weren't getting hit for me.. – Eric Sep 07 '22 at 14:19
  • @Eric this is a more general example to show how to structure this to be able to fulfill the expectation. I did the Assertions after wait if there are multiple task to fulfill and you need to wait for more than one. – burnsi Sep 07 '22 at 15:39
0

The sequence that worked for me both locally and on CI is the following:

func testLoaderSuccess() async throws {    
    Task {
        let result = try await loader.perform()
        XCTAssert(result.value == 42)
        expectation.fulfill()
    }
    wait(for: [expectation], timeout: 1)
}
Eric
  • 16,003
  • 15
  • 87
  • 139
0

I suggest you the following function based on Rob's answer:

func testAwait(timeout: UInt64, task: @escaping () async -> Void) async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in
        group.addTask {
            await task()
        }
        group.addTask {
            try await Task.sleep(nanoseconds: timeout * NSEC_PER_SEC)
            XCTFail("Timed out")
        }
        let _ = try await group.next()
        group.cancelAll()
    }
}

This is how you can use it:

try await testAwait(timeout: 1) {
    let result = try await loader.perform()
    XCTAssert(result.value == 42)
}
Roman Podymov
  • 4,168
  • 4
  • 30
  • 57
  • 1
    I might suggest swapping the order of the parameters. By convention (and to enjoy “trailing closure” syntax), the closure is usually the last parameter. – Rob Jul 17 '23 at 17:48