Making a request with a side of testing

Most iOS apps will have a networking component, in previous posts I've written about treating the network call and parsing as one task or how to cut down on unneeded network calls by coalescing but in this article I want to describe a pattern I use to construct the actual requests. As we will see this pattern helps to encapsulate knowledge of request structure expected by the API into one suite of classes and also allows us to more easily write unit tests for a critical part of our infrastructure.

What is your request?

Woah, slow down there. Before any good request we must first configure - contain that excitement and let's look at some code 😑.

class RequestConfig: NSObject {
    
    // MARK: Networking
    
    lazy var APIHost: String = {
        var APIHost: String?
        
        #if DEBUG
            APIHost = "https://development.platform.example.com"
        #elseif RELEASE
            APIHost = "https://platform.example.com"
        #endif
        
        assert(APIHost != nil, "Host API URL not set")
        
        return APIHost!
    }()
    
    lazy var APIVersion: Double = {
        var APIVersion: Double?
        
        #if DEBUG
            APIVersion = 2.0
        #elseif RELEASE
            APIVersion = 1.8
        #endif
        
        assert(APIVersion != nil, "API version not set")
        
        return APIVersion!
    }()
    
    lazy var timeInterval: NSTimeInterval = {
        return 45
    }()
    
    lazy var cachePolicy: NSURLRequestCachePolicy = {
        return .UseProtocolCachePolicy
    }()
}

The above class is a simple set of properties that provides us with a central location to store settings that will be used to configure our requests. It uses Custom Flags to dynamically change the API host and API version string depending on what Xcode config we are running the app in - it's fairly common to have staging/debug server that we develop against and a production server that our end users will use. The above properties are set using lazy loading - this could also have been achieved by directly setting the property in a custom init method (I just personally prefer lazy loading as I feel it groups the code into smaller units of functionality).

Ok configuration over, let's get down to some requesting.

iOS comes with a NSMutableURLRequest class that will form the basis of our custom request classes. Before we actually build any specific requests we need to first extend NSMutableURLRequest by adding a few convenience properties.

(The example below is for a JSON request).

enum HTTPRequestMethod: String {
    case GET = "GET"
    case POST = "POST"
    case PUT = "PUT"
    case DELETE = "DELETE"
}

class JSONURLRequest: NSMutableURLRequest {
    
    // MARK: - Static
    
    static let requestDateFormatter: NSDateFormatter = {
        let requestDateFormatter = NSDateFormatter()
        requestDateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
        requestDateFormatter.timeZone = NSTimeZone(name:"UTC")
        
        return requestDateFormatter
    }()
    
    // MARK: - Properties
    
    var parameters: [String: AnyObject]? {
        didSet {
            self.HTTPBody = try! NSJSONSerialization.dataWithJSONObject(parameters!, options: NSJSONWritingOptions(rawValue: 0))
        }
    }
    
    var endpoint: String? {
        didSet {
            let stringURL = "\(requestConfig.APIHost)/v\(requestConfig.APIVersion)/\(endPoint!)"
            self.URL = NSURL(string: stringURL)
        }
    }
    
    var requestConfig: RequestConfig
    
    // MARK: - Init
    
    init(requestConfig: RequestConfig = RequestConfig()) {
        self.requestConfig = requestConfig
        super.init(URL: NSURL(string: requestConfig.APIHost)!, cachePolicy: requestConfig.cachePolicy, timeoutInterval: requestConfig.timeInterval)
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

Let's break code snippet into smaller code snippets which will examine more detail.

enum HTTPRequestMethod: String {
    case GET = "GET"
    case POST = "POST"
    case PUT = "PUT"
    case DELETE = "DELETE"
}

In the above code snippet, we create a string based enum to hold the HTTP request methods that we are going to support. The same could be achieved by using other means but by declaring an enum, it more explicitly indicates to any developers that come after you that this where the HTTP request methods should be stored and that these are the options supported.

static let requestDateFormatter: NSDateFormatter = {
    let requestDateFormatter = NSDateFormatter()
    requestDateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
    requestDateFormatter.timeZone = NSTimeZone(name:"UTC")
    
    return requestDateFormatter
}()

It's common with networking calls to be parsing dates value from one format to another so here we create a generic date formatter that we will use with any request. In a real world project you will need to discuss this with the API/Server team as to what that date format should be but once chosen, it's good practice to insist that that format is the only one used - this will reduce the amount of work that you as an app developer have to do and make your project a little easier to understand.

var parameters: [String: AnyObject]? {
    didSet {
        self.HTTPBody = try! NSJSONSerialization.dataWithJSONObject(parameters!, options: NSJSONWritingOptions(rawValue: 0))
    }
}

NSMutableURLRequest comes with a property for setting the body of a request - HTTPBody. HTTPBody is of type NSData so in the above code snippet we create a new property - parameters. parameters is a dictionary that once set we then convert into an NSData instance using NSJSONSerialization and set as the value of HTTPBody, this allows our subclasses of JSONURLRequest to work at a higher level of abstraction. Please note, when debugging it can be useful to replace NSJSONWritingOptions(rawValue: 0) with NSJSONWritingOptions.PrettyPrinted.

var endpoint: String? {
    didSet {
        let stringURL = "\(requestConfig.APIHost)/v\(requestConfig.APIVersion)/\(endPoint!)"
        self.URL = NSURL(string: stringURL)
    }
}

NSMutableURLRequest comes with a property for setting the URL that your request will be made against - URL. Much like the reasoning behind the parameters property we want to allow the specific request to work at the highest level of abstraction possible, so with endpoint we allow the request to only care about the unique part of their requests e.g. users/me/profile. When this endpoint value is set we can then construct the full url by combining with the APIHost and APIVersion values declared in the RequestConfig class. This has the added benefit of allowing us to change the host or version of each request without actually having to manually edit each request source file.

var requestConfig: RequestConfig

Easy to miss this property as it has no side effects.

init(requestConfig: RequestConfig = RequestConfig()) {
    self.requestConfig = requestConfig
    super.init(URL: NSURL(string: requestConfig.APIHost)!, cachePolicy: requestConfig.cachePolicy, timeoutInterval: requestConfig.timeInterval)
}

This init method makes me a little uncomfortable as we need to call the designated initializer of NSMutableURLRequest which takes an NSURL value that we don't yet know so instead we fake it 😷. We could have altered this class's interface to accept more parameters in it's init signature to avoid this faking however I decided against this as I wanted the requests to only care about the properties that their request needed and not have to pass in a number of nils to an init method that is doing too much. The other interesting part is that the init method takes a defaulted RequestConfig instance as a parameter - this parameter isn't 100% necessary but having it here is really powerful when it comes to writing our unit tests and makes our class's dependency on it more obvious.

With the above class I actually toyed with making it directly as child of NSObject and then implementing a method that takes this NSObject subclass and gives us back a mapped NSURLRequest instance. However that required an additional step for the developer when creating the request and my aim with this solution was to remove the number of steps required by the developer. Trade offs like this happen all the time in development and it's important to have a metric (in this case - developer ease) to help determine what path to go down.

Making your request

Ok so we have seen our generic/base class, let's look at a specific class that actually constructs a request. In the below examples, I've created a suite of requests on a fictional users endpoint and attempted to show user cases for all the HTTP requests defined in HTTPRequestMethod enum.

class UserJSONURLRequest: JSONURLRequest {
    
    // MARK: - Profile
    
    class func retrieveProfileRequest() -> UserJSONURLRequest {
        let request = UserJSONURLRequest()
        
        request.HTTPMethod = HTTPRequestMethod.GET.rawValue
        request.endpoint = "users/me/profile"
        
        return request
    }
    
    class func updateProfileRequest(username: String, emailAddress: String, firstname: String, lastname: String, dateOfBirth: NSDate) -> UserJSONURLRequest {
        let request = UserJSONURLRequest()
        
        request.HTTPMethod = HTTPRequestMethod.PUT.rawValue
        request.endpoint = "users/me/profile"
        request.parameters = ["username": username,
                              "email_address": emailAddress,
                              "first_name": firstname,
                              "last_name": lastname,
                              "date_of_birth": requestDateFormatter.stringFromDate(dateOfBirth)]
        
        return request
    }
    
    // MARK: - Password
    
    class func forgottenPasswordEmailToBeSentRequest(emailAddress: String) -> UserJSONURLRequest {
        let request = UserJSONURLRequest()
        
        request.HTTPMethod = HTTPRequestMethod.POST.rawValue
        request.endpoint = "users/me/password/forgot"
        request.parameters = ["email_address" : emailAddress]
        
        return request
    }
    
    // MARK: - Block
    
    class func blockUserRequest(userID: String) -> UserJSONURLRequest {
        let request = UserJSONURLRequest()
        
        request.HTTPMethod = HTTPRequestMethod.POST.rawValue
        request.endpoint = "users/\(userID)/block"
        
        return request
    }
    
    class func unblockUserRequest(userID: String) -> UserJSONURLRequest {
        let request = UserJSONURLRequest()
        
        request.HTTPMethod = HTTPRequestMethod.DELETE.rawValue
        request.endpoint = "users/\(userID)/block"
        
        return request
    }
}

Let's examine a few of the requests in greater detail.

class func retrieveProfileRequest() -> UserJSONURLRequest {
    let request = UserJSONURLRequest()
    
    request.HTTPMethod = HTTPRequestMethod.GET.rawValue
    request.endpoint = "users/me/profile"
    
    return request
}

The above method constructs a GET request. It creates an instance of UserJSONURLRequest and configures it by setting the HTTPMethod and it's endpoint. A really simple method.

class func forgottenPasswordEmailToBeSentRequest(emailAddress: String) -> UserJSONURLRequest {
    let request = UserJSONURLRequest()
    
    request.HTTPMethod = HTTPRequestMethod.POST.rawValue
    request.endpoint = "users/me/password/forgot"
    request.parameters = ["email_address" : emailAddress]
    
    return request
}

The above method is very similar to the previous one but constructs a POST request rather than a GET request. It takes an emailAddress parameter that we place into a dictionary to be set as the HTTPBody of this request.

It's important to note, that a key part of this solution is that it is only the request methods should know how to transform our model classes/properties into the format and keys expected by the API. This is why in the updateProfileRequest method we pass in the parameters required as individual parameters rather than a pre-built dictionary object.

Our request underway

Ok, let's see our requests in action.

class UserAPIManager: NSObject {

    typealias CompletionClosure = (successful: Bool) -> Void
    
    // MARK: - Profile
    
    class func retrieveProfile(completion: CompletionClosure?) {
        let session = NSURLSession.sharedSession()
        let request = UserJSONURLRequest.retrieveProfileRequest()
        
        let task = session.dataTaskWithRequest(request) { (data: NSData?, response: NSURLResponse?, error: NSError?) -> Void in
                //Parse response
        }
        
        task.resume()
    }
    
    class func updateProfile(username: String, emailAddress: String, firstname: String, lastname: String, dateOfBirth: NSDate, completion: CompletionClosure?) {
        let session = NSURLSession.sharedSession()
        let request = UserJSONURLRequest.updateProfileRequest(username, emailAddress: emailAddress, firstname: firstname, lastname: lastname, dateOfBirth: dateOfBirth)
        
        let task = session.dataTaskWithRequest(request) { (data: NSData?, response: NSURLResponse?, error: NSError?) -> Void in
            //Parse response
        }
        
        task.resume()
    }
    
    // MARK: - Password
    
    class func requestForgottenPasswordEmailToBeSent(emailAddress: String, completion: CompletionClosure?) {
        let session = NSURLSession.sharedSession()
        let request = UserJSONURLRequest.forgottenPasswordEmailToBeSentRequest(emailAddress)
        
        let task = session.dataTaskWithRequest(request) { (data: NSData?, response: NSURLResponse?, error: NSError?) -> Void in
            //Parse response
        }
        
        task.resume()
    }
    
    // MARK: - Block
    
    class func blockUser(userID: String, completion: CompletionClosure?) {
        let session = NSURLSession.sharedSession()
        let request = UserJSONURLRequest.blockUserRequest(userID)
        
        let task = session.dataTaskWithRequest(request) { (data: NSData?, response: NSURLResponse?, error: NSError?) -> Void in
            //Parse response
        }
        
        task.resume()
    }
    
    class func unblockUser(userID: String, completion: CompletionClosure?) {
        let session = NSURLSession.sharedSession()
        let request = UserJSONURLRequest.unblockUserRequest(userID)
        
        let task = session.dataTaskWithRequest(request) { (data: NSData?, response: NSURLResponse?, error: NSError?) -> Void in
            //Parse response
        }
        
        task.resume()
    }
}

The above APIManager pattern used to isolate our networking layer is explored in more detail in a previous post. I've included it here only to show how by using our custom request instances has simplified our networking code.

Testing our requests

Ok so now we have our requests let's look at how we can test them.

class UserJSONURLRequestTests: XCTestCase {
    
    // MARK: - Tests
    
    // MARK: retrieveProfileRequest
    
    func test_retrieveProfileRequest_HTTPMethod() {
        let request = UserJSONURLRequest.retrieveProfileRequest()
        
        XCTAssertEqual(request.HTTPMethod, HTTPRequestMethod.GET.rawValue)
    }
    
    func test_retrieveProfileRequest_endpoint() {
        let request = UserJSONURLRequest.retrieveProfileRequest()
        
        XCTAssertEqual(request.endpoint, "users/me/profile")
    }
    
    // MARK: updateProfileRequest
    
    func test_updateProfileRequest_HTTPMethod() {
        let username = "toby190"
        let emailAddress = "toby@test.com"
        let firstName = "toby"
        let lastName = "tester"
        let dateOfBirth = NSDate()
        
        let request = UserJSONURLRequest.updateProfileRequest(username, emailAddress: emailAddress, firstname: firstName, lastname: lastName, dateOfBirth: dateOfBirth)
        
        XCTAssertEqual(request.HTTPMethod, HTTPRequestMethod.PUT.rawValue)
    }
    
    func test_updateProfileRequest_endpoint() {
        let username = "toby190"
        let emailAddress = "toby@test.com"
        let firstName = "toby"
        let lastName = "tester"
        let dateOfBirth = NSDate()
        
        let request = UserJSONURLRequest.updateProfileRequest(username, emailAddress: emailAddress, firstname: firstName, lastname: lastName, dateOfBirth: dateOfBirth)
        
        XCTAssertEqual(request.endpoint, "users/me/profile")
    }
    
    func test_updateProfileRequest_parameters() {
        let username = "toby190"
        let emailAddress = "toby@test.com"
        let firstName = "toby"
        let lastName = "tester"
        let dateOfBirth = NSDate()
        
        let request = UserJSONURLRequest.updateProfileRequest(username, emailAddress: emailAddress, firstname: firstName, lastname: lastName, dateOfBirth: dateOfBirth)
        
        
        let expectedParameters: [String: AnyObject] = ["username": username,
                                                       "email_address": emailAddress,
                                                       "first_name": firstName,
                                                       "last_name": lastName,
                                                       "date_of_birth": JSONURLRequest.requestDateFormatter.stringFromDate(dateOfBirth)]
        
        XCTAssertEqual(request.parameters!["username"] as? String, expectedParameters["username"] as? String)
        XCTAssertEqual(request.parameters!["email_address"] as? String, expectedParameters["email_address"] as? String)
        XCTAssertEqual(request.parameters!["first_name"] as? String, expectedParameters["first_name"] as? String)
        XCTAssertEqual(request.parameters!["last_name"] as? String, expectedParameters["last_name"] as? String)
        XCTAssertEqual(request.parameters!["date_of_birth"] as? String, expectedParameters["date_of_birth"] as? String)
    }
    
    // MARK: forgottenPasswordEmailToBeSentRequest
    
    func test_forgottenPasswordEmailToBeSentRequest_HTTPMethod() {
        let request = UserJSONURLRequest.forgottenPasswordEmailToBeSentRequest("example@test.com")
        
        XCTAssertEqual(request.HTTPMethod, HTTPRequestMethod.POST.rawValue)
    }
    
    func test_forgottenPasswordEmailToBeSentRequest_endpoint() {
        let request = UserJSONURLRequest.forgottenPasswordEmailToBeSentRequest("example@test.com")
        
        XCTAssertEqual(request.endpoint, "users/me/password/forgot")
    }
    
    func test_forgottenPasswordEmailToBeSentRequest_parameters() {
        let emailAddress = "example@test.com"
        
        let request = UserJSONURLRequest.forgottenPasswordEmailToBeSentRequest(emailAddress)
        
        XCTAssertEqual(request.parameters!["email_address"] as? String, emailAddress)
    }
    
    // MARK: blockUserRequest
    
    func test_blockUserRequest_HTTPMethod() {
        let request = UserJSONURLRequest.blockUserRequest("345")
        
        XCTAssertEqual(request.HTTPMethod, HTTPRequestMethod.POST.rawValue)
    }
    
    func test_blockUserRequest_endpoint() {
        let userID = "345"
        
        let request = UserJSONURLRequest.blockUserRequest(userID)
        
        XCTAssertEqual(request.endpoint, "users/\(userID)/block")
    }
    
    // MARK: unblockUserRequest
    
    func test_unblockUserRequest_HTTPMethod() {
        let request = UserJSONURLRequest.unblockUserRequest("3845")
        
        XCTAssertEqual(request.HTTPMethod, HTTPRequestMethod.DELETE.rawValue)
    }
    
    func test_unblockUserRequest_endpoint() {
        let userID = "3845"
        
        let request = UserJSONURLRequest.unblockUserRequest(userID)
        
        XCTAssertEqual(request.endpoint, "users/\(userID)/block")
    }
    
}

In the above class, we have a suite of unit tests. Let's look at a few of them in more depth.

func test_retrieveProfileRequest_HTTPMethod() {
    let request = UserJSONURLRequest.retrieveProfileRequest()
    
    XCTAssertEqual(request.HTTPMethod, HTTPRequestMethod.GET.rawValue)
}

func test_retrieveProfileRequest_endpoint() {
    let request = UserJSONURLRequest.retrieveProfileRequest()
    
    XCTAssertEqual(request.endpoint, "users/me/profile")
}

In the above code snippet we have the two tests that are required for testing the retrieveProfileRequest request are:

  1. HTTPMethod
  2. Endpoint

They only involve asserting that two strings are equal to two other strings.

func test_forgottenPasswordEmailToBeSentRequest_HTTPMethod() {
    let request = UserJSONURLRequest.forgottenPasswordEmailToBeSentRequest("example@test.com")
    
    XCTAssertEqual(request.HTTPMethod, HTTPRequestMethod.POST.rawValue)
}

func test_forgottenPasswordEmailToBeSentRequest_endpoint() {
    let request = UserJSONURLRequest.forgottenPasswordEmailToBeSentRequest("example@test.com")
    
    XCTAssertEqual(request.endpoint, "users/me/password/forgot")
}

func test_forgottenPasswordEmailToBeSentRequest_parameters() {
    let emailAddress = "example@test.com"
    
    let request = UserJSONURLRequest.forgottenPasswordEmailToBeSentRequest(emailAddress)
    
    XCTAssertEqual(request.parameters!["email_address"] as? String, emailAddress)
}

In the above code snippet we have the three tests that are required for testing the forgottenPasswordEmailToBeSentRequest request are:

  1. HTTPMethod
  2. Endpoint
  3. Parameters

The HTTPMethod and Endpoint tests are the same as the tests for retrieveProfileRequest with the Parameters test being slightly more complex. In the Parameters test we are checking that each individual value and key combination in the parameters dictionary is correctly set. We could have split this out into individual test for each key but i grouped them to together as I felt that these value and key combinations were one unit of work and should pass/fail together.

This pattern of required unit tests is repeated for all requests. which means that we end up with a very simple way to construct suite of unit tests that ensure a critical part of our infrastructure behaves exactly how we expect it to without having too think too intensely about it.

Finishing up 🏁

With this approach, we can create very simple to understand requests that can then be 100% unit tested and encapsulate any API specific keys from the other parts of the our project.

You can find the completed project here.