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.