Hello! Welcome back.
In our last few lessons, we've built a solid foundation in automated testing. We started by testing asynchronous code with XCTestExpectation, then used those skills to drive development with the Test-Driven Development (TDD) workflow.
Today, we'll apply these concepts to one of the most important and modern parts of iOS development: the Combine framework. As you know from our earlier module on MVVM, Combine is the engine behind reactive data flow in many modern apps, especially those using SwiftUI. Being able to test this reactive code is a non-negotiable skill for a professional iOS developer.
Our learning outcome for this lesson is to test Combine publishers and subscribers by mocking inputs and verifying outputs. We will learn how to isolate our business logic from external dependencies like network services, control the data streams they produce, and verify that our system reacts as expected.
The Challenge of Testing Reactive Code
Testing code that uses Combine presents a unique challenge compared to traditional, synchronous code. A Combine pipeline is asynchronous and stream-based. This means:
- Timing is unpredictable: Values can be emitted immediately, after a delay, or in response to an event.
- Multiple values: A publisher can emit a sequence of values over its lifetime, not just a single result.
- Completion states: A publisher can either finish successfully or fail with an error.
Our testing strategy must be able to handle all of these possibilities reliably.
The typical scenario you'll encounter is testing a ViewModel in an MVVM architecture. The ViewModel often depends on a service (like a repository or API client) to fetch data. This service provides data via a Combine publisher, and the ViewModel subscribes to it, updating its own @Published properties, which in turn update the UI.

To test this interaction, we'll follow a three-step strategy:
- Mock the Input: Using Dependency Injection, we'll replace the real service with a mock version. This mock will produce a predictable publisher that emits values or errors on our command.
- Subscribe to the Output: In our test, we'll subscribe to the ViewModel's
@Publishedproperty that we expect to change. - Await and Assert: We will use
XCTestExpectationto wait for the publisher to emit the value(s) we're interested in, and then assert that the ViewModel's state is correct.
Step 1: Making Code Testable with Dependency Injection
Before we can write a test, our production code must be testable. This means we need to break the direct dependency on concrete types like a URLSession-based network client. The standard way to do this is by programming to an interface (a protocol in Swift).
The following video from Karin Prater provides a clear walkthrough of refactoring an existing service to use a protocol, making it possible to inject a mock implementation for testing.
Combine framework tutorial - Part 4 - How to include unit testing and dependency injection
Watch this segment from 'Combine framework tutorial - Part 4' by Karin Prater to see how to prepare your code for testing by introducing a protocol and using dependency injection.
Watch from 00:24 to 05:03. Focus on how a DataProvider protocol is created to abstract the fetching logic. Notice how the ViewModel is changed to depend on the protocol, not the concrete APIResources class. This is the key to enabling mocking.
This step is a practical application of the 'D' in SOLID principles (Dependency Inversion), a hallmark of professional-grade code.
Step 2: Creating Mock Publishers for Success and Failure
With our dependency injection structure in place, we can now create a mock service that conforms to our DataProvider protocol. The job of this mock is to return a publisher that we can completely control from our test.
Mocking a Success Case
To simulate a successful data fetch, we can use the Just publisher. Just is a simple publisher that emits a single value and then finishes. It's perfect for testing scenarios where you expect a specific piece of data.
Mocking Success and Failure with Result
A more powerful and flexible technique is to use Swift's Result type. The Combine framework provides a convenient .publisher property on the Result type itself. This creates a publisher that either emits the successful value and finishes, or immediately fails with the specified error. This lets us use a single mechanism in our mock to simulate both happy and unhappy paths.
Let's see how to implement these mock publishers.
Combine framework tutorial - Part 4 - How to include unit testing and dependency injection
Continuing with Karin Prater's tutorial, let's see how to build the mock service. This clip covers both the Just publisher and the more advanced Result.publisher technique.
First, watch from 05:03 to 07:21 to see how to create a mock service returning a Just publisher. Then, watch from 22:55 to 24:02 to learn the elegant technique of using a Result property in your mock, which allows you to easily configure it to return either a success or a failure.
The Result.publisher approach is particularly powerful because it allows you to configure the mock's behavior for each test case without needing multiple different mock implementations.
Step 3: Writing the Tests
Now we have all the pieces: a testable ViewModel, and a mock service we can control. Let's write our tests.
Testing the Success Path
In this test, we will:
- Configure our mock service to return a successful result.
- Inject the mock into our ViewModel.
- Create an
XCTestExpectation. - Subscribe to the ViewModel's
@Published var imagesproperty. - Fulfill the expectation when the
imagesarray is populated. - Wait for the expectation and assert the result.
Combine framework tutorial - Part 4 - How to include unit testing and dependency injection
This clip puts everything together, showing you how to write the actual unit test using the mock service and an XCTestExpectation.
Watch from 07:21 to 11:07. Pay close attention to the Arrange-Act-Assert pattern. Arrange: creating the mock and the ImageCollectionFetcher. Act: the action happens implicitly on initialization. Assert: subscribing with sink, fulfilling the promise (expectation), and waiting.
A Common Gotcha: Handling Initial Values with dropFirst()
There's a subtle but critical detail when testing @Published properties. When you subscribe to a @Published property's publisher, it immediately emits its current value.
In many cases, the initial value is an empty state (e.g., [] or nil). If your test expectation is fulfilled simply upon receiving any value, it might be fulfilled by this initial empty value, and your test will pass incorrectly without ever receiving the real data.
To solve this, we use the dropFirst() operator in our test's subscription pipeline. This tells the publisher to ignore the first value it emits (the initial state) and only pass along subsequent values.
The article "Async Unit Testing: The Comprehensive Guide" provides a great example of this.
Async Unit Testing: The Comprehensive Guide - Jacob's Tech Tavern
This article from Jacob's Tech Tavern shows a practical example of testing a SwiftUI ViewModel. It highlights the problem of initial values and the solution using dropFirst().
Read the sections titled '(#1) — Test @ObservableObject view models' and '(#2) — Test Combine publishers in async methods'. Focus on the code snippets in the test_listenerSentBeersSuccessfully_setsBeers and test_loadBeers_success_sendsBeersToPublisher test functions. Notice the use of guard !$0.isEmpty and the more idiomatic dropFirst() to prevent the expectation from being fulfilled prematurely.
Testing the Error Path
Testing for errors follows the exact same pattern. You configure your mock to return a .failure result and assert that the ViewModel correctly handles the error—for example, by setting an error message property.
Combine framework tutorial - Part 4 - How to include unit testing and dependency injection
Finally, let's see how to test that our ViewModel correctly handles errors from the publisher.
Watch from 24:02 to 28:52. Notice how the mock is configured with a .failure result. The test then subscribes to the errorMessage publisher and asserts that it receives a non-nil value, confirming the error was propagated correctly.
Refactoring Your Tests: A Professional Touch
As you write more Combine tests, you'll notice a lot of boilerplate code for setting up expectations and subscriptions. Just as we refactor our production code, we should also refactor our test code.
A professional practice is to create helper extensions on XCTestCase to encapsulate this common logic. This makes your tests much cleaner, more readable, and easier to write.
Unit testing Combine-based Swift code - Swift by Sundell
The article 'Unit testing Combine-based Swift code' by John Sundell is a masterclass in writing clean, reusable test code for Combine. It introduces powerful helper methods that you can add to your own projects.
Read the sections 'A dedicated method for awaiting the output of a publisher' and 'Testing published properties'. Pay special attention to the awaitPublisher and collectNext extension methods. Think about how these helpers could simplify the tests we've seen so far. This approach—building tools to improve your own development workflow—is a key senior-level skill.
Using helpers like awaitPublisher elevates your tests from just being functional to being expressive and maintainable.
Conclusion
Congratulations! You've just tackled one of the more advanced topics in iOS unit testing. Mastering the ability to test reactive, asynchronous code is a significant step in your journey to becoming a top-tier iOS professional. It gives you the confidence to build complex, data-driven features and ensure they are robust and error-free.
Key Takeaways:
- Testability via Protocols: Always use dependency injection with protocols for your services to enable mocking.
- Control Your Inputs: Create mock services that return controlled publishers, using
Justfor simple success cases andResult.publisherfor flexible success/failure scenarios. - Subscribe and Await: In your tests,
sinkto the output publisher you want to verify and useXCTestExpectationto wait for asynchronous values. - Beware the Initial Value: Use the
dropFirst()operator when testing@Publishedproperties to ignore the initial value and wait for the "real" data. - Refactor Your Tests: Improve the readability and maintainability of your test suite by creating helper methods to encapsulate boilerplate Combine testing logic.
Preview of the Next Lesson:
We have now concluded our module on effective unit testing, covering synchronous logic, asynchronous code, TDD, and the Combine framework. We've been focused on testing the "brains" of your app in isolation.
In the next module, we will move up the testing pyramid to focus on the user interface. Our first lesson will be "Configure accessibility identifiers to make UI elements discoverable by automation," the foundational step for writing any automated UI tests.