Home > Back-end >  Swift 5.5 test async Task in init
Swift 5.5 test async Task in init

Time:05-24

I would like to test if my init function works as expected. There is an async call in the init within a Task {} block. How can I make my test wait for the result of the Task block?

class ViewModel: ObservableObject {
    @Published private(set) var result: [Item]
        
    init(fetching: RemoteFetching) {
        self.result = []
        Task {
            do {
                let result = try await fetching.fetch()
                
                self.result = result // <- need to do something with @MainActor?
            } catch {
                print(error)   
            }
        }
    }  
}

Test:

func testFetching() async {
    let items = [Item(), Item()]
    let fakeFetching = FakeFetching(returnValue: items)

    let vm = ViewModel(fetching: FakeFetching())
        
    XCTAssertEqual(vm.result, [])
        
    // wait for fetching, but how?
        
    XCTAssertEqual(vm.result, items])
}

I tried this, but setting the items, only happens after the XCTWaiter. The compiler warns that XCTWaiter cannot be called with await, because it isn't async.

    func testFetching() async {
        let items = [Item(), Item()]
        let fakeFetching = FakeFetching(returnValue: items)

        let expectation = XCTestExpectation()

        let vm = ViewModel(fetching: FakeFetching())
        
        XCTAssertEqual(vm.result, [])
        
        vm.$items
            .dropFirst()
            .sink { value in
                XCTAssertEqual(value, items)
                expectation.fulfill()
            }
            .store(in: &cancellables)
        
        let result = await XCTWaiter.wait(for: [expectation], timeout: 1)
        
        XCTAssertEqual(result, .completed)
    }

CodePudding user response:

Expectation-and-wait is correct. You're just using it wrong.

You are way overthinking this. You don't need an async test method. You don't need to call fulfill yourself. You don't need a Combine chain. Simply use a predicate expectation to wait until vm.result is set.

Basically the rule is this: Testing an async method requires an async test method. But testing the asynchronous "result" of a method that happens to make an asynchronous call, like your init method, simply requires good old-fashioned expectation-and-wait test.

I'll give an example. Here's a reduced version of your code; the structure is essentially the same as what you're doing:

protocol Fetching {
    func fetch() async -> String
}
class MyClass {
    var result = ""
    init(fetcher: Fetching) {
        Task {
            self.result = await fetcher.fetch()
        }
    }
}

Okay then, here's how to test it:

final class MockFetcher: Fetching {
    func fetch() async -> String { "howdy" }
}

final class MyLibraryTests: XCTestCase {
    let fetcher = MockFetcher()
    func testMyClassInit() {
        let subject = MyClass(fetcher: fetcher)
        let expectation = XCTNSPredicateExpectation(
            predicate: NSPredicate(block: { _, _ in
                subject.result == "howdy"
            }), object: nil
        )
        wait(for: [expectation], timeout: 2)
    }
}

Extra for experts: A Bool predicate expectation is such a common thing to use, that it will be found useful to have on hand a convenience method that combines the expectation, the predicate, and the wait into a single package:

extension XCTestCase {
    func wait(
        _ condition: @escaping @autoclosure () -> (Bool),
        timeout: TimeInterval = 10)
    {
        wait(for: [XCTNSPredicateExpectation(
            predicate: NSPredicate(block: { _, _ in condition() }), object: nil
        )], timeout: timeout)
    }
}

The outcome is that, for example, the above test code can be reduced to this:

    func testMyClassInit() {
        let subject = MyClass(fetcher: fetcher)
        wait(subject.result == "howdy")
    }

Convenient indeed. In my own code, I often add an explicit assert, even when it is completely redundant, just to make it perfectly clear what I'm claiming my code does:

    func testMyClassInit() {
        let subject = MyClass(fetcher: fetcher)
        wait(subject.result == "howdy")
        XCTAssertEqual(subject.result, "howdy") // redundant but nice
    }

CodePudding user response:

Tnx to matt this is the correct way. No need for async in the test function and just using a predicate did the job.

    func testFetching() {
        let items = [Item(), Item()]
        let fakeFetching = FakeFetching(returnValue: items)

        let expectation = XCTestExpectation()

        let vm = ViewModel(fetching: FakeFetching())
        
        let pred = NSPredicate { _, _ in
            vm.items == items
        }
        let expectation = XCTNSPredicateExpectation(predicate: pred, object: vm)
        
        wait(for: [expectation], timeout: 1)
    }
  • Related