A SwiftUI App With Dependency Injection and UnitTests

Lego pieces

You can download the final source code from https://github.com/maysamsh/swiftui-di

When I was working at a startup company, we really didn’t have the time or expertise to implement robust testing practices through dependency injection and unit tests. Back then, I didn’t realize how useful this approach could be for making our code more modular and maintainable. I suppose it’s a common scenario in most small companies, and I can’t blame them much. Now that we are incorporating a more structured approach using dependency injection and creating unit tests, and I am somewhat familiar with these practices, I’ve decided to document our experience. In my opinion, the benefits of practicing dependency injection and unit tests are substantial, as they encourage thinking about modularity and flexibility when writing code.

In our case, there’s a dedicated QE team that handles UITests and runs them alongside other tests. Even for my small personal projects, I’ve decided to incorporate dependency injection and unit tests, as they promote writing organized code, avoiding situations where I might exclaim, “WTF is this?” when looking back.

This SwiftUI app features two levels of navigation. On the initial screen, you encounter a list of images. Upon tapping on an image, the app navigates to the next screen displaying the selected image as a Hero image, along with a title, data, and a description (lorem ipsum text). Each image has a unique background obtained from an API call, which returns the image details.

Now, let’s delve into the app and examine the view models, the main focus of this article. The view models are responsible for fetching data and preparing it for consumption by the views. As illustrated in the following diagram, the view owns the view-model through the @StateObject declaration, and the view receives formatted information for display. All fetching and formatting operations occur within the view-model.

TDD SwiftUI View-ViewModel Diagram
View and ViewModel relation diagram

The networking service is injected into the view-model. For simplicity, the initializer has a default value, a key aspect enabling testing by allowing injection of a mock networking service.

In this straightforward app, our testing goals include API fetching, data formatting, and error handling. After creating wireframes and UI components, the first step is to establish the networking layer. The behavior of this service is simple: it needs to fetch two APIs. This is an ideal scenario to employ protocols, leaving the implementation details to the user. This service is referred to as NetworkingService, utilizing Combine, but you can easily adapt to async methods or completion handlers if you prefer.

protocol NetworkingService: AnyObject {
    func fetchImages() -> AnyPublisher<SampleImagesResponse, Error>
    func fetchImageDetails() -> AnyPublisher<ExtraDataResponse, Error>
}

I have a tendency to append “Response” to models representing API call responses for clarity without delving into their implementations.

Similar to the networking service, the API responses are straightforward, holding basic information. For the initial screen, we aim to display a list of available images with titles.

struct SampleImagesResponse: Codable {
    let sample: [ImageItem]?
    
    struct ImageItem: Codable {
        let description: String?
        let imageUrl: String?
        let id: String?
    }
}

The second API call provides more details about the images. While ideally, there would be a get call accepting an ID parameter, for this simple app, I’m hosting two JSON files on GitHub. The details call involves reading a JSON file and looking up the provided image ID.

struct ExtraDataResponse: Codable {
    let sample: [InfoItem]?

    struct InfoItem: Codable {
        let id: String?
        let story: String?
        let colour: String?
        let date: Date?
}

Given that the date is compatible with the ISO8601 format, I can simply set the decoding strategy to .iso8601. and it will automatically convert the string date into date value.

Here’s how our responses look:

// images-sample.json (only one record is shown)
{
    "sample":[
      {
        "id":"4cdad0d1-aef8-48a5-832b-18c6c973f084",
        "description":"A laptop on a desk",
        "image_url":"https://picsum.photos/id/0/1000/600"
      }]
}

// extra-data-sample.json (only one record is shown)

{
    "sample":[
      {
        "id":"4cdad0d1-aef8-48a5-832b-18c6c973f084",
        "story":"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin sodales tortor tellus, et lacinia ipsum mattis id. Cras ullamcorper eget ex vel accumsan. Fusce bibendum est eget sem suscipit tempor. Donec non elit dictum libero semper blandit. Nam vitae convallis ligula. Ut volutpat interdum viverra. In posuere, neque ac vestibulum ultricies, purus erat varius magna, in finibus leo augue quis tellus. Nullam congue enim nisl, ut porta dolor elementum vitae. Vivamus tempus ex nulla, sit amet tempus lectus dapibus in. Cras porta lacinia hendrerit. Mauris sed urna vitae justo rutrum porta eget nec felis. Nullam scelerisque porttitor congue. Mauris tincidunt lectus massa, sed dapibus nunc vulputate eu. Vestibulum lectus nulla, consectetur sit amet enim id, commodo eleifend arcu.",
        "colour":"#f8805a",
        "date":"2023-11-22T21:04:37Z"
      }]
}

For the snake case conversion you can either use CodingKeys or set decoder’s keyDecodingStrategy to .convertFromSnakeCase.

With the models and responses in place, we can create the MockAPIService. This class reads JSON files and, based on a control flag, returns the expected response or throws an error.

final class MockAPIService: NetworkingService {
    private let contentFile: String
    private let detailFile: String
    private var isSuccessful: Bool
    private let jsonDecoder: JSONDecoder
    
    init(contentFile: String = "images-sample", 
         detailFile: String = "extra-data-sample",
         isSuccessful: Bool = true,
         jsonDecoder: JSONDecoder = JSONDecoder()) {
        self.contentFile = contentFile
        self.detailFile = detailFile
        self.isSuccessful = isSuccessful
        self.jsonDecoder = jsonDecoder
    }
    
    func fetchImages() -> AnyPublisher<SampleImagesResponse, Error> {
        guard isSuccessful else {
            return Fail(outputType: SampleImagesResponse.self, failure: NetworkingError.testing)
                .eraseToAnyPublisher()
        }
        
        do {
            if let data = try StubReader.readJson(self.contentFile) {
                let jsonData = try jsonDecoder.decode(SampleImagesResponse.self, from: data)
                return Result.Publisher(jsonData)
                    .eraseToAnyPublisher()
            } else {
                return Fail(outputType: SampleImagesResponse.self, failure: FileError.badData)
                    .eraseToAnyPublisher()
            }
        } catch {
            return Fail(outputType: SampleImagesResponse.self, failure: error)
                .eraseToAnyPublisher()
        }
    }
    
    func fetchImageDetails() -> AnyPublisher<ExtraDataResponse, Error> {
        guard isSuccessful else {
            return Fail(outputType: ExtraDataResponse.self, failure: NetworkingError.testing)
                .eraseToAnyPublisher()
        }
        
        do {
            if let data = try StubReader.readJson(self.detailFile) {
                let jsonData = try jsonDecoder.decode(ExtraDataResponse.self, from: data)
                return Result.Publisher(jsonData)
                    .eraseToAnyPublisher()
            } else {
                return Fail(outputType: ExtraDataResponse.self, failure: FileError.badData)
                    .eraseToAnyPublisher()
            }
        } catch {
            return Fail(outputType: ExtraDataResponse.self, failure: error)
                .eraseToAnyPublisher()
        }
    }
    
}

One might argue that we should create tests now and then proceed to create the view model. However, in my experience, it’s not always feasible. What I want to ensure is that when my views are loaded, network calls are fired, and data is correctly formatted and passed to the view, which is not possible until the view models are created.

Here’s the first view model, named ContentViewModel, responsible for fetching the list of images and preparing the response for the ContentView, the entry view of the app.

The key part of this view model is the initializer, which takes an argument of type NetworkingService. This could be the real API service or the mock one.

import Foundation
import Combine
import OSLog

final class ContentViewModel: ObservableObject {
    @Published private (set) var images: [ImageModel]?
    @Published private (set) var viewError: Error?
    private let apiService: NetworkingService
    private var cancellable: Set<AnyCancellable>
    private (set) var isAppeared = false

    // MARK: - Public Methods
    init(apiService: NetworkingService = APIService()) {
        self.cancellable = Set<AnyCancellable>()
        self.apiService = apiService
    }
    
    func viewDidAppear() {
        if !isAppeared {
            fetch()
            isAppeared = true
        }
    }
    
    // MARK: - Private Methods
    private func fetch() {
        apiService.fetchImages()
            .receive(on: RunLoop.main)
            .sink(receiveCompletion: { [weak self] result in
                if case let .failure(error) = result {
                    self?.images = nil
                    self?.viewError = error
                    Logger.logError(error)
                }
            }, receiveValue: { [weak self] response in
                self?.images = self?.createImagesList(response)
            })
            .store(in: &cancellable)
    }
    
    func createImagesList(_ response: SampleImagesResponse) -> [ImageModel]? {
        guard let responseImages = response.sample else {
            Logger.logError(NetworkingError.invalidResponse)
            self.viewError = NetworkingError.invalidResponse
            return nil
        }
        
        self.viewError = nil
        return responseImages.compactMap { item in
            extractImage(item)
        }
    }
    
    func extractImage(_ item: SampleImagesResponse.ImageItem) -> ImageModel? {
        if let urlString = item.imageUrl,
           let url = URL(string: urlString),
           let imageID = item.id,
           let title = item.description, 
            url.isValid  {
            return ImageModel(imageID: imageID, title: title, url: url)
        }
        return nil
    }
}

As you can see, the API response isn’t passed directly to the view. createImagesList(_:SampleImagesResponse) and extractImage(_ item: SampleImagesResponse.ImageItem) manage the conversion of the API response to an independent model consumed by the view. While this is a simple call with a few variables, in the real world, views may need to display a much larger number of fields. Keeping the view data not directly tied to the API calls makes maintenance much easier. Handling error scenarios is also part of the view-model’s job. Our view only cares about two variables from the view model, images, and errorView, so they are marked as @Published. Another good practice is handling onAppear logic within the view model; you might need to update the view based on different criteria and test them in your unit tests.

The second view model, DetailsViewModel, is very similar to the first one. It does a little more formatting for the date.

import Foundation
import Combine
import SwiftUI
import OSLog

final class DetailsViewModel: ObservableObject {
    @Published private (set) var imageExtraData: InfoItem?
    @Published private (set) var viewError: Error?
    @Published private var extraData: [InfoItem]?

    private (set) var isAppeared = false
    private var cancellable: Set<AnyCancellable>
    private let apiService: NetworkingService
    private let dateFormatter = DateFormatter()
    let imageModel: ImageModel
    let defaultColourString = "#FFFFFF"
    
    // MARK: - Public Methods
    init(imageModel: ImageModel, apiService: NetworkingService = APIService()) {
        self.apiService = apiService
        self.cancellable = Set<AnyCancellable>()
        self.imageModel = imageModel
        dateFormatter.dateStyle = .long
        dateFormatter.timeStyle = .short
    }
    
    // MARK: - Public Variables
    func viewDidAppear() {
        if !isAppeared {
            fetch()
            isAppeared = true
        }
    }
    
    var viewBackgroundColour: Color {
        imageExtraData?.colour.opacity(0.3) ?? Color(hex: self.defaultColourString)
    }
    
    private func fetch() {
        apiService.fetchImageDetails()
            .receive(on: RunLoop.main)
            .sink(receiveCompletion: { [weak self] result in
                if case let .failure(error) = result {
                    self?.imageExtraData = nil
                    self?.viewError = error
                    Logger.logError(error)
                }
            }, receiveValue: { [weak self] response in
                self?.extraData = self?.createImagesDetailsList(response)
                self?.findDataFor(id: self?.imageModel.imageID)
            })
            .store(in: &cancellable)
    }
    
    // MARK: - Private Methods
    func createImagesDetailsList(_ response: ExtraDataResponse) -> [InfoItem] {
        guard let extraDataResponse = response.sample else {
            self.viewError = NetworkingError.invalidURL
            return []
        }
        
        self.viewError = nil
        return extraDataResponse.compactMap { item in
            extractDetails(item)
        }
    }

    func extractDetails(_ item: ExtraDataResponse.InfoItem) -> InfoItem? {
        if let story  = item.story,
           let dateObject = item.date,
           let imageID = item.id {
            let colourString = item.colour ?? defaultColourString
            let colour = Color(hex: colourString)
            let date = formatDate(from: dateObject)
            return InfoItem(imageID: imageID, story: story, date: date, colour: colour)
        }
        return nil
    }
    
    func findDataFor(id: String?) {
        guard let id else {
            viewError = DetailsViewModelError.nilID
            return
        }
        
        guard let extraData else {
            viewError = DetailsViewModelError.emptyDetailsList
            return
        }
        
        guard let image = extraData.filter({ $0.imageID == id }).first else {
            Logger.logError(DetailsViewModelError.invalidID)
            self.viewError = DetailsViewModelError.invalidID
            return
        }
        self.viewError = nil
        self.imageExtraData = image
    }
    
    func formatDate(from date: Date) -> String {
        dateFormatter.string(from: date)
    }
}

One other thing worth mentioning is using OSLog instead of print() for logging. It provides flexibility when debugging your code.

There are two test files to cover the view models, they cover every method in the view models. Here is the first one, ContentViewModelTests.swift:

import XCTest
import Combine

@testable import SwiftUI_DI

final class ContentViewModelTests: XCTestCase {
    var cancellables = Set<AnyCancellable>()
    let decoder = JSONDecoder()
    
    override func setUp() {
        decoder.dateDecodingStrategy = .iso8601
        decoder.keyDecodingStrategy = .convertFromSnakeCase
    }
    
    func testFetch() {
        let service = MockAPIService(isSuccessful: true, jsonDecoder: self.decoder)
        let sut = ContentViewModel(apiService: service)
        let fetchImagesExpectation = expectation(description: "Fetching images")
        
        sut.$images
            .dropFirst() // Drops the initial value at subscription time
            .sink { value in
                fetchImagesExpectation.fulfill()
                XCTAssertEqual(value?.count ?? 0, 5)
            }
            .store(in: &cancellables)
        sut.viewDidAppear()
        wait(for: [fetchImagesExpectation], timeout: 1)
    }
    
    func testViewDidAppear() {
        let service = MockAPIService(isSuccessful: true, jsonDecoder: self.decoder)
        let sut = ContentViewModel(apiService: service)
    
        XCTAssertFalse(sut.isAppeared)
        sut.viewDidAppear()
        XCTAssertTrue(sut.isAppeared)
    }
    
    func testFetchError() {
        let service = MockAPIService(isSuccessful: false, jsonDecoder: self.decoder)
        let sut = ContentViewModel(apiService: service)
        let fetchImagesExpectation = expectation(description: "Fetching images")
        
        sut.$images
            .receive(on: RunLoop.main)
            .dropFirst()
            .sink { value in
                fetchImagesExpectation.fulfill()
                XCTAssertNotNil(sut.viewError)
                XCTAssertNil(value)
            }
            .store(in: &cancellables)
        sut.viewDidAppear()
        wait(for: [fetchImagesExpectation], timeout: 1)
    }
    
    func testResponseMissingSample() {
        guard let data = try? StubReader.readJson("images-sample-no-sample-object") else {
            XCTFail("Could not read the file.")
            return
        }
        
        guard let response = try? self.decoder.decode(SampleImagesResponse.self, from: data) else {
            XCTFail("Could not read the file.")
            return
        }
        
        let service = MockAPIService(isSuccessful: false, jsonDecoder: self.decoder)
        let sut = ContentViewModel(apiService: service)

        let imagesList = sut.createImagesList(response)
        XCTAssertNotNil(sut.viewError)
        XCTAssertNil(imagesList)
    }
    
    func testImagesListWithBadItems() {
        guard let data = try? StubReader.readJson("images-sample-bad-data") else {
            XCTFail("Could not read the file.")
            return
        }
        
        guard let response = try? self.decoder.decode(SampleImagesResponse.self, from: data) else {
            return
        }
        
        let service = MockAPIService(isSuccessful: false, jsonDecoder: self.decoder)
        let sut = ContentViewModel(apiService: service)

        let imagesList = sut.createImagesList(response)
        XCTAssertEqual(imagesList?.count, 1)
    }
    
    func testBadImageItem() {
        guard let data = try? StubReader.readJson("images-sample-bad-data") else {
            XCTFail("Could not read the file.")
            return
        }
        
        guard let images = try? self.decoder.decode(SampleImagesResponse.self, from: data).sample else {
            XCTFail("Could not decode data.")
            return
        }
        
        guard images.count == 4 else {
            XCTFail("The number of items should be 4.")
            return
        }
        
        let service = MockAPIService(isSuccessful: false, jsonDecoder: self.decoder)
        let sut = ContentViewModel(apiService: service)

        /// Bad URL
        let image1 = images[0]
        let imageModel1 = sut.extractImage(image1)
        XCTAssertNil(imageModel1)
        
        /// Missing ID
        let image2 = images[1]
        let imageModel2 = sut.extractImage(image2)
        XCTAssertNil(imageModel2)
        
        /// Missing Description
        let image3 = images[2]
        let imageModel3 = sut.extractImage(image3)
        XCTAssertNil(imageModel3)
    }
}

I tried to cover every possible case for both showing data and error handling, such as validity of the URL, and existence of all required values. I refrain from posting the other test file due to its size here, you can view it on github.

With these test I could achieve 100% code coverage for both view models. This was possible because it’s a small and simple app, in the real world anything over 85% is usually acceptable.

I’d like to show a diagram of the app’s views here. It’s relatively simple but gives you an idea of how the views are connected.

In conclusion, the adoption of dependency injection and the implementation of unit tests provide substantial benefits for codebase modularity and maintainability. While my experiences in a startup environment initially lacked the time and expertise for such practices, the realization of their significance has led us to document our journey in incorporating dependency injection and unit tests. The advantages of these practices are evident in promoting modularity, fostering flexibility in code design, and ensuring a more organized and maintainable codebase. Whether applied in small personal projects or within a dedicated QE team for larger endeavors, dependency injection and unit tests contribute to code quality and mitigate the risk of encountering perplexing code scenarios in the future.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.