Unit test async UI change in Swift

557 views Asked by At

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.

1

There are 1 answers

1
matt On BEST ANSWER

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")
}