In the last post we’ve discussed hot to enhance testability without using protocols. In this one, we will build something similar but using protocols instead.

We will be using dependency injection to be able to inject the real objects in the app, and inject mock objects in the tests / previews.

We will also use the Interface Segregation Principle (ISP) to enforce that the code does not depend on methods it does not use.


We will model the state of the view using a lighter version of ViewState:

enum ViewState<Info> {
    case initial
    case loading
    case loaded(Info)
    case error
}

We can separate this into 3 layers:

1. View

import SwiftUI

struct CatFactView: View {
    @ObservedObject var viewModel: ViewModel

    var body: some View {
        VStack {
            switch viewModel.state {
            case .initial:
                EmptyView()
            case .loading:
                ProgressView()
            case let .loaded(fact):
                Text(fact.fact)
            case .error:
                Text("Error")
                    .foregroundColor(.red)
            }
            Button("Fetch another") {
                viewModel.fetch()
            }
            .disabled(viewModel.state.isLoading)
        }
        .padding()
        .task {
            viewModel.fetch()
        }
    }
}

Almost identical to the one in the previous post, with the difference of the ViewModel not needing the dependencies struct.

2. ViewModel

import SwiftUI

extension CatFactView {
    @MainActor final class ViewModel: ObservableObject {
        @Published var state: ViewState<CatFact> = .initial
        private let service: FetchCatFactProtocol

        init(service: FetchCatFactProtocol) {
            self.service = service
        }

        func fetch() {
            Task {
                do {
                    state = .loading
                    let fact = try await service.fetchCatFact()
                    withAnimation { state = .loaded(fact) }
                } catch {
                    withAnimation { state = .error(error) }
                }
            }
        }
    }
}

Now, we inject a FetchCatFactProtocol object in the initializer. Which is a protocol that defines the fetchCatFact method. We can now proceed to the service layer.

3. Service

import Foundation

protocol FetchCatFactProtocol {
    func fetchCatFact() async throws -> CatFact
}

protocol UpdateCatInformationProtocol {
    func updateName(id: String) async throws
}

struct CatService {}

extension CatService: FetchCatFactProtocol {
    func fetchCatFact() async throws -> CatFact {
        /// This is using: https://github.com/mdb1/CoreNetworking
        try await HTTPClient.shared
            .execute(
                .init(
                    urlString: "https://catfact.ninja/fact/",
                    method: .get([]),
                    headers: [:]
                ),
                responseType: CatFact.self
            )
    }
}

extension CatService: UpdateCatInformationProtocol {
    func updateName(id: String) async throws {
        // Some networking code
    }
}

This is where it gets interesting. Instead of just declaring a CatService struct with a lot of methods inside. We create different protocols for each method and then make the CatService conform to the protocols needed.

By doing this, we enforce the Interface Segregation Principle, and we can now use the protocols for dependency injection. It is impossible for the CatFactView.ViewModel to call the updateName method on the CatService struct, given it doesn’t know it exists.

In the real app code, we can use the CatService object where needed:

CatFactView(viewModel: .init(service: CatService()))

Whereas in the previews/tests, we can just create new mock objects that conform to the protocols:

#if DEBUG

struct FetchCatServiceMock: FetchCatFactProtocol {
    var throwsError: Bool
    var sleepNanoseconds: UInt64 = 1_000_000_000
    func fetchCatFact() async throws -> CatFact {
        try await Task.sleep(nanoseconds: sleepNanoseconds)
        if throwsError {
            throw NSError(domain: "1", code: 1)
        } else {
            return .init(fact: "A mocked fact")
        }
    }
}

struct CatFactView_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            CatFactView(viewModel: .init(service: FetchCatServiceMock(throwsError: false)))
            CatFactView(viewModel: .init(service: FetchCatServiceMock(throwsError: true)))
            Spacer()
        }
    }
}

#endif

Testing

Finally, on the testing side, we can leverage our protocols and leverage the already created mock in the main target.

import XCTest

@MainActor final class CatFactViewModelTests: XCTestCase {
    func testFetchCatFactSuccess() {
        // Given
        let mockFact = CatFact(fact: "A mocked fact")
        let sut = CatFactView.ViewModel(service: FetchCatServiceMock(
            throwsError: false,
            sleepNanoseconds: 0
        ))

        // When
        sut.fetch()

        // Then
        asyncAssert("Fetch, then update the state") {
            XCTAssertEqual(sut.state.info, mockFact)
        }
    }

    func testFetchCatFactSuccessStates() {
        // Given
        let mockFact = CatFact(fact: "A mocked fact")
        let sut = CatFactView.ViewModel(service: FetchCatServiceMock(
            throwsError: false,
            sleepNanoseconds: 0
        ))

        AssertState().assert(
            when: {
                // When
                sut.fetch()
            },
            type: ViewState<CatFact>.self,
            testCase: self,
            publisher: sut.$state,
            valuesLimit: 3,
            initialAssertions: {
                XCTAssertEqual(sut.state, .initial)
            },
            valuesAssertions: { values in
                // Then
                XCTAssertEqual(values.map { $0 }, [.initial, .loading, .loaded(mockFact)])
            }
        )
    }

    func testFetchCatFactError() {
        // Given
        let sut = CatFactView.ViewModel(service: FetchCatServiceMock(
            throwsError: true,
            sleepNanoseconds: 0
        ))

        // When
        sut.fetch()

        // Then
        asyncAssert("Fetch, then update the state") {
            XCTAssertEqual(sut.state, .error(NSError(domain: "1", code: 1)))
        }
    }

    func testFetchCatFactErrorStates() {
        // Given
        let sut = CatFactView.ViewModel(service: FetchCatServiceMock(
            throwsError: true,
            sleepNanoseconds: 0
        ))

        AssertState().assert(
            when: {
                // When
                sut.fetch()
            },
            type: ViewState<CatFact>.self,
            testCase: self,
            publisher: sut.$state,
            valuesLimit: 3,
            initialAssertions: {
                XCTAssertEqual(sut.state, .initial)
            },
            valuesAssertions: { values in
                // Then
                XCTAssertEqual(
                    values.map { $0 },
                    [.initial, .loading, .error(NSError(domain: "1", code: 1))]
                )
            }
        )
    }

    func testMemoryDeallocation() {
        // Given
        let sut = CatFactView.ViewModel(service: FetchCatServiceMock(throwsError: false))

        // Then
        assertMemoryDeallocation(in: sut)
    }
}

The complete code can be found in this repository.