Enhancing Testability without protocols

We have all used protocols to enhance testability in our apps, but that can become too verbose, and add extra layers of abstractions to the code.

Here is a different approach, without using protocols, that can achieve the same results with way less code.

Let’s say we have a screen with a button that needs to perform a network request, display a loading while waiting for the response, and then display the result in a label.

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:

struct CatFactView: View {
    @StateObject var viewModel: ViewModel = .init()

    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") {
                Task {
                    await viewModel.fetch()
                }
            }
            .disabled(viewModel.state.isLoading)
        }
        .padding()
        .task {
            await viewModel.fetch()
        }
    }
}

Nothing fancy here, just a view that switches through the view model’s state property.

2. ViewModel

In the ViewModel, we will introduce the Dependencies mechanism, which is just a struct that lets us inject the dependencies of the view model as functions stored in variables.

By doing this, the view model becomes completely decoupled from the implementation of the dependencies, and will only have access to the provided methods. Think of this as a lightweight version of the Interface Segregation Principle (The I in SOLID).

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

        init(dependencies: Dependencies = .default) {
            self.dependencies = dependencies
        }

        func fetch() async {
            do {
                state = .loading
                let fact = try await dependencies.fetchFact()
                state = .loaded(fact)
            } catch {
                state = .error(error)
            }
        }
    }
}

extension CatFactView.ViewModel {
    struct Dependencies {
        // Notice how the view model doesn't care about the implementation,
        // as long as we provide anything that conform to this signature,
        // the view model will compile correctly.
        var fetchFact: () async throws -> CatFact
    }
}

Another benefit of this approach, is that injecting mocked instances is really easy.

For example, we could go to the SwiftUI Previews and just use one mock for the success state and another one for the error state:

struct CatFactView_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            CatFactView(viewModel: .init(dependencies: .init(fetchFact: {
                try await Task.sleep(nanoseconds: 1_000_000_000)
                return .init(fact: "A mocked fact")
            })))

            CatFactView(viewModel: .init(dependencies: .init(fetchFact: {
                try await Task.sleep(nanoseconds: 1_000_000_000)
                throw NSError(domain: "domain", code: 123)
            })))

            Spacer()
        }
    }
}

3. The Service

Now is the time for the service layer, this is the place where the real code will be executed:

struct CatFactsService {
    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
            )
    }
}

struct CatFact: Decodable {
    let fact: String
}

Now we could also add some convenience code to make the initializer of the view model easier to read:

extension CatFactView.ViewModel.Dependencies {
    static var `default`: Self {
        .init(fetchFact: CatFactsService().fetchCatFact)
    }
}

Testing

Using some of the helpers function from this article, and injecting the dependencies as inline methods, we can easily test all the paths of the code in our view model:

@MainActor
final class CatFactViewModelTests: XCTestCase {
    func testFetchCatFactSuccess() async {
        // Given
        let mockFact = CatFact(fact: "A mocked fact")
        let sut = CatFactView.ViewModel(dependencies: .init(fetchFact: {
            mockFact
        }))

        // When
        await sut.fetch()

        // Then
        XCTAssertEqual(sut.state.info, mockFact)
    }

    func testFetchCatFactSuccessStates() {
        // Given
        let mockFact = CatFact(fact: "A mocked fact")
        let sut = CatFactView.ViewModel(dependencies: .init(fetchFact: {
            mockFact
        }))

        AssertState().assert(
            when: {
                Task {
                    await 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() async {
        // Given
        let error = NSError(domain: "12", code: 12)
        let sut = CatFactView.ViewModel(dependencies: .init(fetchFact: {
            throw error
        }))

        // When
        await sut.fetch()

        // Then
        XCTAssertEqual(sut.state, .error(error))
    }

    func testFetchCatFactErrorStates() {
        // Given
        let error = NSError(domain: "12", code: 12)
        let sut = CatFactView.ViewModel(dependencies: .init(fetchFact: {
            throw error
        }))

        AssertState().assert(
            when: {
                Task {
                    await 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(error)])
            }
        )
    }

    func testMemoryDeallocation() {
        // Given
        let mockFact = CatFact(fact: "A mocked fact")
        let sut = CatFactView.ViewModel(dependencies: .init(fetchFact: {
            mockFact
        }))

        // Then
        assertMemoryDeallocation(in: sut)
    }
}
Test Suite 'CatFactViewModelTests' passed at 2023-07-06 20:08:44.436.
Executed 5 tests, with 0 failures (0 unexpected) in 0.024 (0.026) seconds

The complete code can be found in the WithoutProtocols branch of this repository.

You can also check out how to achieve the same results using protocols in this post.

Related Articles

Enhancing Testability without protocols | manu.show
Tags: iOS testing
Share: Twitter LinkedIn