Search code examples
swiftunit-testingasynchronousasync-awaitxctestexpectation

Unit test async UI change in Swift


I am trying to unit test a custom UIView, which changes the UI asynchronously. This is the code for the custom view:

import UIKit

class DemoView: UIView {
    
    var label: UILabel!

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }
    
    func setup() {
        label = UILabel(frame: .zero)
        self.addSubview(label)
        label.translatesAutoresizingMaskIntoConstraints = false
        self.centerXAnchor.constraint(equalTo: label.centerXAnchor).isActive = true
        self.centerYAnchor.constraint(equalTo: label.centerYAnchor).isActive = true
    }
    
    @MainActor
    func setLabel(_ text: String) {
        Task {
            try await Task.sleep(for: .milliseconds(100))
            label.text = text
        }
    }
}

I want to test, that after calling the setLabel(_:) function, the text on the label did change, therefore I wrote the following test:

@MainActor
func testExample() async throws {
    let demoView = DemoView(frame: .zero)
    XCTAssertEqual(demoView.label.text, nil)
    
    demoView.setLabel("New Text")
    let expectLabelChange = expectation(for: NSPredicate(block: { _, _ in
        demoView.label.text != nil
    }), evaluatedWith: demoView.label)
    await waitForExpectations(timeout: 5)
    
    XCTAssertEqual(demoView.label.text, "New Text")
}

But the exception runs into a timeout and the assert fails. When I set breakpoints, I can see that the Task inside setLabel(_:) is executed, but never reenters after sleeping, even though the timeout is long enough. Only after the waitForExpectations finishes, the task inside setLabel(_:) is continued, however this is too late for the assert to catch the changes.

How can I write the test, so that the Task in setLabel(_:) continues?

NOTE: The code is adjusted for demonstrating the issue. In the real app I call an API instead of sleeping.


Solution

  • You don't need async testing for this, and you shouldn't use it. setLabel is not async, and you're using an expectation! This test will pass (and I've rewritten a few minor things along the way):

    @MainActor func testExample() {
        let demoView = DemoView(frame: .zero)
        XCTAssertEqual(demoView.label.text, nil)
    
        demoView.setLabel("New Text")
        let predicate = NSPredicate { _, _ in
            demoView.label.text != nil
        }
        let expectLabelChange = expectation(for: predicate, evaluatedWith: nil)
        wait(for: [expectLabelChange], timeout: 5)
    
        XCTAssertEqual(demoView.label.text, "New Text")
    }
    

    Even better, remove the @MainActor from your setLabel call; you can then remove it from the test function too.


    Under what circumstances would async for the test be appropriate? If setLabel were async! Suppose you rewrite setLabel like this:

    func setLabel(_ text: String) async throws {
        try await Task.sleep(for: .milliseconds(100))
        label.text = text
    }
    

    Now you need your tests to be async — and now you don't need an expectation! Look how simple everything becomes:

    @MainActor
    func testExample() async throws {
        let demoView = DemoView(frame: .zero)
        XCTAssertEqual(demoView.label.text, nil)
    
        try await demoView.setLabel("New Text")
        XCTAssertEqual(demoView.label.text, "New Text")
    }