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