Mastodon How to Write Unit Tests for Combine Publishers in Swift Testing | My Portfolio

How to Write Unit Tests for Combine Publishers in Swift Testing

Will Lumley | Nov 27, 2024 min read

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:

  1. Inserts a new item into the database.
  2. Subscribes to the getItemPublisher.
  3. 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:

  1. Managing subscriptions effectively.
  2. Asserting values and errors with precision.
  3. Leveraging tools like withCheckedContinuation to integrate with async/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!