bearjaw.dev

Doing swifty things 1-bit at a time 🚀

Testable networking code - Part 1

A smallish intro

If you browse job listings for iOS developers or generally for developers testability is always highlighted as very important. Clean architecture, writing tests have become buzzwords in the industry. Unfortunately it happens that when you get to peek behind the curtains you discover that there are no tests, the code is tightly coupled, and the task of even introducing tests feels impossible and you're left wondering why you jumped through all those hoops.

To be fair I haven't seen all the code bases in the world so this is purely based on my experiences and chats with other developers. I am sure there are many companies where tests are just as important as the features developers have to write.

I strongly support writing testable, well readable code and well also writing tests. I think what really helped me become a better developer and write better code was to venture out into different fields. At my joby job we tired to re-write our mobile app using a cross platform framework. There were many reason that went into this and my awesome colleague Catalin wrote a really great blog post about how that went and why decided not to (thankfully) ditch our native apps.

While I really disliked the whole cross platform work itself it did teach me quite a lot much. I learned a new stack and was able to also start contributing to our Angular based website. The main point however is that we had a very thorough test driven development approach and it was the one thing I really longed for when getting back to working on the iOS app.

I already added some tests for some newer parts and features of the code base but due to the architecture and the massive code base I was never really able to make our existing ViewModels at least a bit more testable. However reinvigorated with my new knowledge I decided to give this a new try.

Assembling the pieces

So that was a long intro. Let's write some code. The app uses Alamofire under the hood so some of the code is specific to it but it's also quite easy to roll or apply this to your own networking stack. For that reason I decided to write a small wrapper around Alamofire and some of its features so it could be possible in the future to actually exchange the networking stack completely.

I started by abstracting away the actual method call we use to perform networking requests. For this I created an APIService that would eventually make the request and handle the decoding. Generally the APIService is only used by a new Repository class that would have the APIService injected into it.

The APIService itself needs a Session and this is basically Alamofire's main class to make networking requests. Now this can't easily be abstracted away. You could create a protocol and add the request() method and make Session then conform to it but I wasn't convinced that it would bring much value for something that will not change for a very long time. And if it does you only have to fix it in one single place.

I did however write wrapper types Alamofire's HTTPMethod, ParameterEncoding, and HTTPHeaders. Mainly because you'll be using these every time you create an APIRequest and believe me, we have quite a few.

struct APIService {
    
    private let session: Session

    init(session: Session) {
        self.session = session
    }

     func request<T>(request: APIRequest<T>,
                completion: @escaping (Result<T, Error>) -> Void) where T: Decodable {
        session
            .request(request.url,
                     method: request.method.alamofireMethod,
                     parameters: request.parameters,
                     encoding: request.alamofireEncoding
            )
            .responseData { response in
                // handle response
            }
    }
}

For simplicity I omitted the wrappers. Another goal of mine was to tie the response type to the request. This is easily achieved by leveraging generics and conforming T to Decodable. In the app I will not initialise any APIRequest in let's say a ViewModel or a Service. APIRequest come packaged in their API domain which are defined by enums. You'll see in a bit further down how it works.

The APIRequest struct is defined follows:

struct APIRequest<T: Decodable>  {

    let url: String
    let method: HTTPMethod
    let parameters: Parameters?
    let encoding: ParameterEncoding
    let headers: HTTPHeaders?

    init(url: String,
         method: HTTPMethod = .GET,
         parameters: Parameters? = nil,
         encoding: ParameterEncoding = .urlDefault,
         headers: HTTPHeaders? = nil) {
        self.url = url
        self.method = method
        self.parameters = parameters
        self.encoding = encoding
        self.headers = headers
    }

}

Now the last piece of the puzzle is the Repository. Before I started to incrementally introduce the new API approach, each API domain had it's own specific repository implementation. That created a lot of duplicated code. Basically the only thing that really changes are requests. That's why I created a general repository class that would eventually be used by the app to make API requests. The session apiClient is just a global property that returns the production session by default. Alamofire's default session is a singleton so I am not introducing anything fancy here.

final class Repository {

    private let httpClient: APIService

    init(httpClient: APIService = APIService(session: apiClient)) {
        self.httpClient = httpClient
    }

    func performRequest<T>(_ request: APIRequest<T>,
                           completion: @escaping (Result<T, Error>) -> Void) where T : Decodable {
        httpClient.request(request: request, completion: completion)
    }

    deinit {
        LoggingService.networking.log_debug(message: "\n\n Deinit: \(type(of: self)) \n\n")
    }

}

In addition to that I decided to move our API domains into enums and having static methods to create the APIRequest. This makes it quite nice to use. Let's assume I want to get the current user:

extension APIEndpoint {

    enum User {

        private static let baseURL = "api.some.domain.com"

        // MARK: - Me

        static func currentUser() -> APIRequest<User> {
            let url = "\(baseURL))/users/current"
            return APIRequest(url: url)
        }

}

// somewhere in your code
let request = APIEndpoint.User.currentUser()
repository.performRequest(request) { result in
    switch result {
        case let .success(user):
            print("Hello \(user.firstName)")
        case let .failue(error):
            print("Oh no, an error: \(error.localizedDescription)")
    }
}

Testing requests

After all the pieces of the puzzle have finally fallen into place it's time to write some tests.

First, like I did in Angular, I wanted to test my requests. This ensures that the correct url is used, the right parameters are included and so on.

To do this I created a simple extension on XCTestCase. The extension is simply to reduce writing duplicate code and assertions. It also makes it easily extensible again.

  func testRequest<T>(_ request: APIRequest<T>,
                    _ expectedRequest: APIRequest<T>,
                    matchParams: ((Parameters?) -> Bool)? = nil,
                    matchHeaders: ((HTTPHeaders?) -> Bool)? = nil) {

        if let matchHeaders {
            XCTAssertTrue(matchHeaders(request.headers))
        } else {
            XCTAssertNil(request.headers)
        }
        if let matchParams {
            XCTAssertTrue(matchParams(request.parameters))
        } else {
            XCTAssertNil(request.parameters)
        }

        XCTAssertEqual(request, expectedRequest, errorMessage(got: request, expected: expectedRequest))
    }

Now you can easily write a test to make you everything is set up correctly.

final class UserRepositoryTests: XCTestCase {

    func test_current_user_request() {
        let request = APIEndpoint
            .User
            .currentUser()

        let expectedURL = "someURL"
        let expectedRequest = APIRequest<Empty>(url: expectedURL)

        testRequest(request, expectedRequest)
    }
}

That's it for now. In part 2 I will explain how I test the repository itself, making sure the responses are decoded properly. All of this is also doable with async / await. However we still support older iOS versions and can't use that yet but the implementation is pretty much the same. Once we finally move to iOS 13 and upwards the whole code will be even nicer to write.

Thanks for reading 🐶

Tagged with: