Home > OS >  Unit test async UI change in Swift
Unit test async UI change in Swift

Time:12-12

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.

This repository contains the demo project for this question.

CodePudding user response:

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