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.