DEV Community

Cover image for Simplifying Test Writing with Protocol Witnesses in Swift
Oren Idan Yaari
Oren Idan Yaari

Posted on

Simplifying Test Writing with Protocol Witnesses in Swift

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")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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?

Woody and Buzz Lightyear Everywhere meme

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")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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])
    }
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)