We’ve learned a lot about writing concurrency code, but it’s just as important to know how to write tests for this. Since we’re dealing with asynchronous code, it might not be clear at first how to write proper tests. It’s common to run into so-called flaky tests—tests that only sometimes succeed.
Luckily enough, there are several techniques that make it easier to write tests for async/await and concurrency. Let’s dive in!
System Under Test
Before we dive into the specifics, I want to introduce you to our so-called System Under Test (SUT). This is a common naming convention for the class or type we’re going to test. In this case, I want to use the article searcher that we’ve created in Module 3:
@MainActor
@Observable
final class ArticleSearcher {
static let articleTitlesDatabase = [
"Article one",
"Article two",
"Article three",
]
var searchResults: [String] = ArticleSearcher.articleTitlesDatabase
private var currentSearchTask: Task<Void, Never>?
/// Using manual cancellation management.
func searchWithSearchTask(_ query: String) {
/// Cancel any previous searches that might be 'sleeping'.
currentSearchTask?.cancel()
currentSearchTask = Task {
do {
/// Sleep for 0.5 seconds to wait for a pause in typing before executing the search.
try await Task.sleep(for: .milliseconds(500))
print("Starting to search!")
/// A simplified static result and search implementation.
searchResults = Self.articleTitlesDatabase
.filter { $0.lowercased().contains(query.lowercased()) }
} catch {
print("Search was cancelled!")
}
}
}
/// Async alternative to allow searching using the `task` modifier in SwiftUI.
func search(_ query: String) async {
do {
guard !query.isEmpty else {
searchResults = Self.articleTitlesDatabase
return
}
/// Sleep for 0.5 seconds to wait for a pause in typing before executing the search.
try await Task.sleep(for: .milliseconds(500))
print("Starting to search!")
/// A simplified static result and search implementation.
searchResults = Self.articleTitlesDatabase
.filter { $0.lowercased().contains(query.lowercased()) }
} catch {
print("Search was cancelled!")
}
}
}
It’s a great example to use since it has a few common challenges:
- It’s marked with
@MainActorwhich we’ll have to deal with in our test case - There’s both a synchronous and asynchronous variant of searching
- We’ll have to deal with a sleep, so we need to write a test that is capable of waiting
Altogether, we’ll use this class to go over common scenarios when writing tests using the XCTest framework. Note that I’ve adjusted a few parts to make it a better fit for this lesson:
- I’ve renamed one of the search methods to
searchWithSearchTaskto ensure both search methods have different names. - The
articleTitlesDatabaseproperty is now static to allow us to compare inside tests.
Dealing with @MainActor in tests
Let’s first write a simple test to see if an empty query results in all articles being returned. As soon as we start writing the test, we’ll run into the following error:

We’re running into a compiler error since ArticleSearcher conforms to @MainActor.
We can solve this by marking our individual test or test class with @MainActor as well. This, however, is inconvenient, as all your tests will be running on a single main thread. Especially if you’re running your tests in parallel, you’ll lose efficiency if all tests are dispatched to the main thread. Ideally, you would be able to refactor your search logic to a separate class so we can test it out of the @MainActor. However, that’s a lesson on refactoring, rather than writing tests!
After marking our test method with @MainActor, we can write the test as follows:
@MainActor
func testEmptyQuery() async {
let articleSearcher = ArticleSearcher()
await articleSearcher.search("")
XCTAssertEqual(articleSearcher.searchResults, ArticleSearcher.articleTitlesDatabase, "Should return all articles")
}
Note that we can mark the test method as async, which allows us to call into the search() method using await. This test should be straightforward to understand; it’s similar to concurrency code as we’ve written in other lessons. The key point to understand is that you can mark test methods with async and actors just like you can for regular non-testing code.
Dealing with sleeps inside tests
Testing logic becomes more challenging when we have to deal with sleeps or delays. If we change our logic to make use of the searchWithSearchTask method:
@MainActor
func testEmptyQuery() async {
let articleSearcher = ArticleSearcher()
articleSearcher.searchWithSearchTask("")
XCTAssertEqual(articleSearcher.searchResults, ArticleSearcher.articleTitlesDatabase, "Should return all articles")
}
Our test succeeds just like before. The reality, however, is that the test completes before the inner currentSearchTask completes running, so the test doesn’t evaluate the actual logic. This becomes more apparent if we write a test using an actual query:
@MainActor
func testWithSearchQuery() async {
let articleSearcher = ArticleSearcher()
articleSearcher.searchWithSearchTask("three")
XCTAssertEqual(articleSearcher.searchResults, ["Article three"], "Should return article three")
}
This test fails consistently because our search logic happens asynchronously and we evaluate results against the default collection of articles:
func searchWithSearchTask(_ query: String) {
currentSearchTask?.cancel()
/// We move the search logic to an asynchronous task here.
currentSearchTask = Task {
do {
try await Task.sleep(for: .milliseconds(500))
searchResults = Self.articleTitlesDatabase
.filter { $0.lowercased().contains(query.lowercased()) }
} catch {
print("Search was cancelled!")
}
}
}
The test consistently fails, even if we remove the sleep. This is because we are not waiting for the currentSearchTask to complete.
We can solve this by using observation tracking. In this case, we’re dealing with @Observable attached to ArticleSearcher, but you can use @Published properties when you’re dealing with an ObservableObject. The focus for this lesson is on writing expectations that work with async/await, no matter which technique you’ve used.
Altogether, the test could look as follows:
@MainActor
func testWithSearchQuery() async {
let articleSearcher = ArticleSearcher()
let expectation = self.expectation(description: "Search complete")
/// Use observation tracking to track changes to the @Observable searchResults property.
_ = withObservationTracking {
articleSearcher.searchResults
} onChange: {
expectation.fulfill()
}
/// Perform the actual search for Article Three.
articleSearcher.searchWithSearchTask("three")
/// Asynchronously await for the expectation to fulfill.
await fulfillment(of: [expectation], timeout: 10.0)
/// Assert the result.
XCTAssertEqual(articleSearcher.searchResults, ["Article three"], "Should return article three")
}
The test succeeds and works since it’s now waiting for the search results to change.
Preventing deadlocks by using the fulfillment method
Note that we can’t use wait(for: [expectation]) as we would get:
The error description is clear and tells us what to use instead. It’s important to use this fulfillment method in all your test classes that work with concurrency. Otherwise, you risk running into deadlocks since the regular methods to wait for expectations are not designed to work in a concurrency environment.
Calling asynchronous setup and teardown logic
An XCTestCase allows you to call asynchronous logic in its async setup and teardown methods:
final class ArticleSearcherXCTests: XCTestCase {
override func setUp() async throws {
// Call any asynchronous logic.
}
override func tearDown() async throws {
// Call any asynchronous logic.
}
// ...
}
You need to mark the methods as async throws for this to work. Just like with non-concurrency test classes, these methods will be called before and after each test.
Summary
In this lesson, we’ve learned how to write tests for asynchronous code. Fortunately, XCTest supports concurrency well and makes it easy to transform tests into asynchronous environments to call into async/await methods directly.
In the next lesson, we’re going to look into the same topic, but for the modern Swift Testing framework.