Unit testing Combine publishers can feel like you’re wrestling with a slippery eel: asynchronous, stateful, and a bit chaotic if not handled correctly. But fear not! Today, I’ll walk you through how to tame these tricky beasts using Swift Testing and its elegant testing features. Whether you’re a seasoned Combine wrangler or new to reactive programming, by the end of this post, you’ll have the tools to confidently write unit tests for your publishers.
Why Test Publishers?
Combine publishers are at the heart of many modern Swift apps, powering everything from data streams to user interactions. Testing these publishers ensures that your streams emit the correct values, complete as expected, and handle errors gracefully. And with Swift Testing’s clean syntax and tools like #expect
and #require
, writing these tests becomes a delight instead of a chore.
Setting the Stage: What We’ll Test
Imagine you’re working on a project with a database manager that has a method for fetching items from the database. Here’s our method under test:
func getItemPublisher(by id: Int) -> AnyPublisher<Item, Never> {
items
.filter { $0.id == id }
.eraseToAnyPublisher()
}
The goal? Ensure this publisher correctly emits the requested item when the database is updated. We’ll write a test for this using Swift Testing that:
- Inserts a new item into the database.
- Subscribes to the getItemPublisher.
- Asserts that the emitted item matches the inserted one.
Writing the Test
Here’s the code for our test:
@Test("Get Item Publisher")
func getItemPublisher() async throws {
// Create a set to manage our Combine subscriptions
var cancellables: Set<AnyCancellable> = []
// GIVEN we have our test subject
let testSubject = DatabaseManager()
// GIVEN an item we want to test
let mockItem = Item.mock(id: 1, name: "Test Item", details: "Details here")
// WHEN we insert the mock item into the database
try await testSubject.insert(item: mockItem)
// THEN the publisher should emit the correct item
await withCheckedContinuation { continuation in
testSubject.getItemPublisher(by: mockItem.id)
.sink { fetchedItem in
// Assert that the fetched item matches the mock item
#expect(fetchedItem == mockItem)
// Finish the test
continuation.resume()
}
.store(in: &cancellables)
}
}
So let’s break down what we just did!
Managing Subcriptions
In Combine, you need to manage your subscriptions to avoid memory leaks. Here, we use a Set<AnyCancellable>
to store our subscription:
Setting Up the Test Subject
We use a mock item and a test instance of our database:
let mockItem = Item.mock(id: 1, name: "Test Item", details: "Details here")
let testSubject = DatabaseManager()
Mocking data is essential for tests, as it allows us to control inputs and outputs without relying on external dependencies.
Performing the Test
The test revolves around inserting the mock item and observing the publisher for the expected output:
try await testSubject.insert(item: mockItem)
Once the item is inserted, the getItemPublisher should emit the mock item.
Asserting the Results
Within the sink closure, we use Swift Testing’s handy #expect macro to make assertions:
#expect(fetchedItem == mockItem)
These ensure the emitted item matches our expectations.
Continuation for Async Handling
Combine works asynchronously, so we use withCheckedContinuation to integrate Combine with Swift’s async
/await
syntax:
await withCheckedContinuation { continuation in
testSubject.getItemPublisher(by: mockItem.id)
.sink { fetchedItem in
#expect(fetchedItem == mockItem)
continuation.resume()
}
.store(in: &cancellables)
}
This ensures the test completes only after the publisher emits a value and our assertions are verified.
Testing Errors and Edge Cases
Combine tests aren’t just about the happy path. You’ll want to test error handling and edge cases as well. Here’s an example for a publisher that emits errors:
@Test("Error Handling Publisher")
func errorPublisher() async throws {
var cancellables: Set<AnyCancellable> = []
// GIVEN a faulty publisher
let faultyPublisher = PassthroughSubject<Item, Error>()
// WHEN the publisher fails
faultyPublisher.send(completion: .failure(SomeError()))
// THEN the test should catch the error
await withCheckedContinuation { continuation in
faultyPublisher
.sink(receiveCompletion: { completion in
if case .failure(let error) = completion {
#expect(error is SomeError)
continuation.resume()
}
}, receiveValue: { _ in
Issue.record("Publisher should not emit a value")
})
.store(in: &cancellables)
}
}
Wrapping Up
Testing Combine publishers doesn’t have to be daunting. With Swift Testing’s powerful macros and the structured approach we explored today, you can write clean, effective tests that validate your reactive streams. Remember, the key to mastering Combine testing lies in:
- Managing subscriptions effectively.
- Asserting values and errors with precision.
- Leveraging tools like
withCheckedContinuation
to integrate withasync
/await
.
Go ahead, write your tests, and let your Combine code shine with confidence! And if you’re stuck, drop a comment or reach out - I’d love to help. Happy testing!