Simplifying Test Writing with Protocol Witnesses in Swift
Oren Idan Yaari
Posted on February 20, 2024
In my previous post, we explored writing testable code and methods to enhance our test-writing skills. This article introduces a technique that significantly simplifies the process of writing tests, resulting in less redundancy and more maintainable tests.
Let's start with a fundamental need of every app - data. Here, we'll introduce our first two protocols to abstract the database and the network. Protocols traditionally serve as our go-to strategy for isolating and managing dependencies, enabling us to alter and mock behaviors during tests. We'll also include another analytics protocol, just for fun:
class ViewModel {
// Protocols
let networkClient: NetworkClientProtocol
let dbClient: DBClientProtocol
let analyticsClient: AnalyticsClientProtocol
@Published var movies: [Movie] = []
@Published var error: Error?
init(
networkClient: NetworkClientProtocol = NetworkClient(),
dbClient: DBClientProtocol = DBClient(),
analyticsClient: AnalyticsClientProtocol
) {
self.networkClient = networkClient
self.dbClient = dbClient
self.analyticsClient = analyticsClient
}
// Fetch the data when the view appears
func onAppear() async {
do {
movies = dbClient.fetchMovies()
if movies.isEmpty {
let url = URL(string: "https://getmovies.com")!
movies = try await networkClient.fetchData(for: url)
}
analyticsClient.log(event: "fetched_movies")
} catch {
self.error = error
analyticsClient.log(event: "fetched_movies_failed")
}
}
}
When the view loads, we check the database for existing movies. If not found, we proceed to retrieve the data directly from the server.
Next, we set out to design our tests to ensure the "happy path" operates as expected, fetching movies from a simulated network and populating the error if any exception arises:
final class BlogCodeTests: XCTestCase {
func testOnAppear_HappyPath() async {
let analytics = FakeAnalytics()
let sut = ViewModel(
networkClient: HappyNetworkClient(),
dbClient: FakeDBClient(),
analyticsClient: analytics
)
await sut.onAppear()
XCTAssertEqual(sut.movies, [Movie(id: 1, title: "The Karate Kid")])
XCTAssertEqual(analytics.eventTracker, "fetched_movies")
}
func testOnAppear_Failure() async {
let analytics = FakeAnalytics()
let sut = ViewModel(
networkClient: FailingNetworkClient(),
dbClient: FakeDBClient(),
analyticsClient: analytics
)
await sut.onAppear()
XCTAssertNotNil(sut.error)
XCTAssertEqual(analytics.eventTracker, "fetched_movies_failed")
}
enum MockError: Error {
case testError
}
class FailingNetworkClient: NetworkClientProtocol {
func fetchData<T: Codable>(for url: URL) async throws -> T {
throw MockError.testError
}
}
class HappyNetworkClient: NetworkClientProtocol {
static var movie = Movie(id: 1, title: "The Karate Kid")
func fetchData<T: Codable>(for url: URL) async throws -> T {
return [Self.movie] as! T
}
}
class FakeDBClient: DBClientProtocol {
func fetchMovies() -> [BlogCode.Movie] {
[]
}
}
class FakeAnalytics: AnalyticsClientProtocol {
var eventTracker: String?
func log(event: String) {
eventTracker = event
}
}
}
Having introduced mock network and database clients, we've enabled testing paths, allowing us to simulate success and failure. Yet, it feels like we are drowning in protocols and we haven't even tested all the cases. Could there be a way to streamline dependency management without protocols?
Enter Protocol Witnesses, a concept I first encountered through the excellent Point Free videos, I highly recommend them.
The idea behind Protocol Witnesses boils down to this: Instead of a protocol, we use a value type with function fields. Simple, right? This means that what was previously a function signature in a protocol now becomes a lambda within a struct.
However, it's important to remember that Protocol Witnesses are another tool in our tool-belt; there's still a place for protocols in our code. Both approaches have their unique strengths and applications, providing us with a more versatile set of options for tackling different coding challenges. Embracing Protocol Witnesses doesn't mean abandoning protocols altogether but rather enriching our toolkit with more ways to write efficient, maintainable, and clean code.
Now, let's dive into how the Movies app undergoes a transformation with Protocol Witnesses:
struct Dependencies {
// Transform the protocol signature into functions
var getMovies: () async throws -> [Movie]
var logEvent: (String) -> Void
// The live implementation to use in production
static func live() -> Self {
let dbClient = DBClient()
let networkClient = NetworkClient()
return Self {
let movies = dbClient.fetchMovies()
if movies.isEmpty {
return try await networkClient.fetchData(for: url)
}
return movies
} logEvent: { event in
// Call the live analytics client
print(event)
}
}
}
class ViewModel {
let dependencies: Dependencies
@Published var movies: [Movie] = []
@Published var error: Error?
// Use the live implementation as a default
init(dependencies: Dependencies = .live()) {
self.dependencies = dependencies
}
func onAppear() async {
do {
movies = try await dependencies.getMovies()
dependencies.logEvent("fetched_movies")
} catch {
self.error = error
dependencies.logEvent("fetched_movies_failed")
}
}
}
With just a few tweaks, we've eliminated the need for protocols altogether. In our production environment, we now rely on live functions for fetching movies and analytics, freeing us from the constraints of protocol adherence.
And here comes the real game changer: let's drastically reduce the boilerplate code associated with protocols in our testing setup:
func testOnAppear_HappyPath() async {
let expectation = XCTestExpectation(description: "analytics call")
// Override the dependencies functions right where they are in use
let sut = ViewModel(
dependencies: Dependencies {
[Movie(id: 1, title: "The Karate Kid")]
} logEvent: { event in
XCTAssertEqual(event, "fetched_movies")
expectation.fulfill()
}
)
await sut.onAppear()
XCTAssertEqual(sut.movies, [Movie(id: 1, title: "The Karate Kid")])
await fulfillment(of: [expectation])
}
func testOnAppear_Failure() async {
let expectation = XCTestExpectation(description: "analytics call")
let sut = ViewModel(
dependencies: Dependencies {
throw MockError.testError
} logEvent: { event in
XCTAssertEqual(event, "fetched_movies_failed")
expectation.fulfill()
}
)
await sut.onAppear()
XCTAssertNotNil(sut.error)
await fulfillment(of: [expectation])
}
We have removed about 30 lines of protocol code allowing us to streamline the testing process and make it more manageable. Using value types instead of protocols significantly reduces the amount of code required and enables us to override data in place without needing additional protocol conformances.
This is just the beginning of what Protocol Witnesses enable us to achieve. Once again, I highly recommend exploring Point Free and their innovative approach, especially their library for dependency injection.
Posted on February 20, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.