Swift Combine (Part 3 of 3) — Building Real-Case App
Welcome to the last part of my Swift Combine series. In this chapter, you’ll integrate the network request into a real-case SwiftUI application. Covering various topics sequentially and interdependently, from the app’s architecture to testing strategies.
The Objective
Continuing from the network request function developed in the previous chapter, we’ll now construct a SwiftUI application and integrate it. Quickly remind, the application we’re building will enable users to search for posts from the JSONPlaceholder API based on the inputted title, dynamically presenting the results on the screen. The wireframe is illustrated below:
We’ll go through six sections in order, providing brief explanations for each as well as the code snippets and their explanations.
Architecting with MVVM
When laying the foundation for the app, the architectural decisions significantly impact the project’s scalability, maintainability, and testability. In this chapter, we’ll use the Model-View-ViewModel (MVVM) architecture. While it’s important to note that any architectural patterns are viable with Combine Framework, we opted for MVVM due to its popularity among developers and the compelling reasons it offers, including:
- Separation of Concerns: MVVM cleanly separates data, presentation logic, and UI concerns for improved code organization.
- Reactive UI Updates: SwiftUI’s reactivity, combined with MVVM and Combine, ensures real-time updates for a responsive user interface.
- Testability: MVVM’s encapsulation of business logic in the ViewModel enhances unit testability, aided by Combine’s testing features.
- Maintainability and Scalability: MVVM’s modular structure facilitates independent development and scaling without disrupting the entire codebase.
Create a new iOS App Project and implement the following snippet code:
With this code, you:
- Create a
Models
folder and place three Swift files inside it, each corresponding to a data model:User
,Post
, andPostDetail
. These files will define the underlying structure of your app. - Create a
ViewModels
folder and place aViewModel
class namedSearchPostsViewModel
conforming to theObservableObject
protocol. This class will manage the app’s data and business logic. - Inside the
SearchPostsViewModel
class, use a@Published
property wrapper to declare thequeryInput
property. You might wonder why we didn’t useCurrentValueSubject
, as in the previous chapter. It’s because both are quite similar as they can hold a value and subscribe; I see@Published
as syntactic sugar forCurrentValueSubject
. - Create a
Views
folder and place a SwiftUI view namedSearchPostsView
. Declare aSearchPostsViewModel
using the@StateObject
property wrapper. Alternatively, you can use@ObservedObject
if you intend to share an existing instance. The key distinction lies in lifecycle management:@StateObject
creates a new instance for the view, while@ObservedObject
uses an existing one, potentially shared with other views.
Handling The App State
Handling the app state involves managing and responding to the application’s state, ensuring that the user interface reflects the current conditions and interactions within the app. This includes managing data loading, user input, navigation, and responding to various events that impact the application’s behavior. In SwiftUI, you can use property wrappers such as @State
, @ObservedObject
, and @StateObject
to handle the states. For example, you might use a loading
boolean to toggle between the loading and success states. Here’s an elaboration on various states, including initial, loading, success, and errors:
- Initial State: Represents the starting condition of the app, typically encountered upon launch or when loading a specific screen.
- Loading State: Indicates that the app is actively fetching or processing data, and visual indicators are often used to inform users of ongoing background operations.
- Success State: Signifies the successful completion of a task or operation, such as retrieving data from a server and prompting updates to the UI to display the fetched information.
- Error State: Occurs when an unexpected issue, such as a network error or failed data retrieval, is encountered. It prompts communication of the error to the user with relevant information and potential resolution actions.
Continue and implement the following snippet code:
With this code, you:
- Create an
Enums
folder and place a new Swift file namedAppStateEnum
. Define a generic enumeration namedAppState
to represent the various states of your app. The generic typeT
allows flexibility in the data associated with certain cases. - Inside the
SearchPostsViewModel
class, define astate
property with the typeAppState<[PostDetail]>
. It’s initialized with the.initial
case, representing the initial state of the view with associated data of type[PostDetail]
.
Injecting The Service Layer
SwiftUI, as a declarative UI framework, encourages the adoption of clear and modular architecture, making dependency injection a popular choice for structuring projects. Dependency injection (DI) is a design pattern that involves providing a component with its dependencies rather than letting the component create or find them. This pattern enhances testability, maintainability, and flexibility by decoupling components and explicitly specifying their dependencies.
In this context, dependency injection provides the ViewModel
with its dependencies, such as the service layer responsible for making API calls. These dependencies are typically injected through initializers.
Continue and implement the following snippet code:
With this code, you:
- Create a
Services
folder and place a new Swift file namedJSONPlaceholderAPIService
. Define aJSONPlaceholderAPIServiceProtocol
and methods forfetchUsers()
andfetchPosts()
that returns a CombineAnyPublisher
. Using protocols provides a clear and abstract interface for your services, allowing you to define the expected behaviour without specifying the implementation details - Create a
JSONPlaceholderAPIService
class conforming to theJSONPlaceholderAPIServiceProtocol
. This is the concrete implementation of the service layer. - Inside the
JSONPlaceholderAPIService
class, define constants for the API URLs to make them more readable and maintainable. - Declares a
session
private property with the type ofURLSession
and initializes inside theinit
method of theJSONPlaceholderAPIService
class. - Implement the
fetchUsers()
andfetchPosts()
methods, along with thefetchData()
method that encapsulates the generic process of the networking and decoding logic, promoting code reusability across different endpoints. - Inject the
JSONPlaceholderAPIService
layer inside theSearchPostsViewModel
class through the ViewModel’s initializer. This allows different implementations of the service layer, making it easy to switch between a production service and a mock service for testing. - Provide an instance of
JSONPlaceholderAPIService
to theSearchPostsViewModel
initializer. This is where the dependency injection is in action.
Integrating The Network Requests
It’s time to integrate the network requests we developed in the previous chapter into our app. Keep in mind that there are some adjustments to our network request functions as we handle app states and leverage the SwiftUI property wrapper, particularly @Published
. However, the core logic of the functions remains unchanged.
Continue and implement the following snippet code:
With this code, you:
- Declare a set of
AnyCancellable
, which will be used to manage the subscriptions. - Inside the
init
method, call thesetupBindings()
method to establish Combine bindings. This method sets up a pipeline that reacts to changes in thequeryInput
. - If the
query
is not empty, thefetchPosts(with:)
method is called to initiate the network request. Otherwise, the state is set to.initial
. - Inside the
fetchPosts(with:)
method, sets the state to.loading
to indicate that a network request is in progress. - Handle the completion events by updating the state to
.error
if an error occurs, and to.success
if the request is successful.
Creating The UI
With the backend in place, we can now focus on crafting the SearchPostsView
. The view adeptly should respond to changes in the associated SearchPostsViewModel’s
state, dynamically presenting distinct views depending on the state condition, such as prompting the user’s input in the initial state, indicating loading with a ProgressView, displaying search results, or handling errors. Then, integrate the search functionality using the .searchable
modifier bound to the queryInput
property.
Continue and implement the following snippet code:
With this code, you:
- Display different views based on the
state
of the associatedSearchPostsViewModel
. - In the
.initial
state, display astateView
function prompting the user to enter a title to find matching posts. - In the
.loading
state, display aProgressView
, indicating that the app is fetching data. - In the
.success
state, use thepostsList
function to display posts if available; otherwise, display astateView
function suggesting the user try a different title. - In the
.error
state, display astateView
function indicates that something went wrong and advises trying again later. - Apply the
.searchable
modifier to enable search functionality using thequeryInput
property from theviewModel
.
Here is the app demo of what we’ve developed:
Writing The Unit Tests
Unit testing is a crucial practice in software development that involves examining individual code units to ensure their correctness and reliability. When it comes to SwiftUI applications, unit tests are essential for verifying the behaviour of ViewModel
components. By isolating and evaluating these components, we can identify potential bugs early in the development process, streamline debugging efforts, and enhance the overall robustness of our application.
This section will focus on testing the SearchPostsViewModel
to guarantee that it effectively manages search functionality, network requests, and state transitions. We start by creating a unit testing bundle to initiate the testing process. Here are the steps:
- In the Xcode toolbar, click on
File > New > Target…
to open the template chooser. - In the template chooser, under the
Test
category, selectUnit Testing Bundle
. - Click
Next
to proceed. - Give your testing bundle a name. Ensure the
Targets
checkbox is selected for your main app target. - Click
Finish
to create the testing bundle.
Now that we’ve established a dedicated folder for our tests, the next step is creating a mock of the. JSONPlaceholderAPIService
to control and manipulate responses during the testing process.
Create a new Swift file named MockJSONPlaceholderAPIService
and add the following code:
With this code, you:
- Import the necessary modules and classes for testing, specifically importing the main project module
Building_Real_Case_App
. - Create a
MockJSONPlaceholderAPIService
class that subclassesJSONPlaceholderAPIService
to simulate fetching users and posts with predefined data. This mock service is designed for testing purposes and allows controlled responses. - Create a
MockErrorJSONPlaceholderAPIService
class that also subclassesJSONPlaceholderAPIService
but simulates an error scenario by returning a failure with aURLError
. This mock service is useful for testing error handling in the application.
Then, we create a unit testing class named SearchPostsViewModelTests
. Continue and implement the following code snippet.
Note: I only provide
testFetchPostsSuccess()
test in the snippet to minimize a bunch of code; You can always see the full code on the repository.
- Initialize the
MockJSONPlaceholderAPIService
, theSearchPostsViewModel
(sut
), and a set ofcancellables
to handle Combine cancellations. - The
setUp
function is overridden to instantiate and set up the initial state for theservice
, theviewModel
, andcancellables
. - The
tearDown
function is overridden to clean up and release resources after each test, setting theservice
,viewModel
, andcancellables
to nil. - The
testFetchPostsSuccess
function is a unit test focusing on the success scenario when fetching posts. - Set up a
sampleQuery
,expectedResult
for fetching posts, andexpectedStates
- Create an
expectation
to await the fulfilment of asynchronous tasks during the test. - Observe the
viewModel’s state
changes, and the corresponding actions are taken in response to the state changes. - Add an assertion to check whether the received states match the
expectedStates
. - In the success state, add an assertion to check whether the fetched posts match the
expectedResult
. - After ensuring that the
expectation
defined in point 6 is fulfilled, callexpectation.fulfill()
to signal that the asynchronous task (state transition) has been completed, allowing the test to proceed to the next steps or conclude successfully. - Set the
queryInput
to trigger the fetching of posts, and the test waits for the expectation to be fulfilled.
Lastly, to check the code coverage for the SearchPostsViewModel
, you can open the code coverage report to see how much of the code is covered by your tests. Follow the steps below:
- After the tests are run, open the
Report Navigator
by clicking on the square icon on the left side of the Xcode window. - Find and click on the latest test report.
- In the test report, you should see a
Coverage
tab. Click on it to open the code coverage report.
Congrats! You’ve completed my Swift Combine series. I hope this series helped you gain the knowledge and skills needed to excel in Swift development. Remember, the journey doesn’t end here; continue exploring, experimenting, and refining your coding expertise. Happy coding!
If you found this article helpful, kindly share it with your friends and follow my profile for future updates. Your support is much appreciated!
You can access the repository for this series below:
PS: I’m always welcome for pizza 🍕🍕🍕.