18 February 2023
•
6 minutes read
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 🐶