Making a Request with a Side of Unit 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 post, I want to describe a pattern I use to construct the actual HTTP 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?
Whoa, 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 a 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 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 making an HTTP request and receiving a JSON response.
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. Using an enum more explicitly indicates to any future developers that this is where the HTTP request methods should be stored and that these are the options currently 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 date values 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
, which 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 separation of host and version has the added benefit of allowing us to change the host or version of each request without having to edit each request source file manually.
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 initialiser 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 its 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 several 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 a subclass 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 needed to be made 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 examples below, 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 the 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 its 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 it 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 that 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 using our custom request instances has simplified our networking code.
Testing our requests
Ok, 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:
- HTTPMethod
- 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:
- HTTPMethod
- Endpoint
- 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 value and key combination in the parameters
dictionary is correctly set. We could have split this out into individual tests for each key, but I grouped them together as I felt that these values 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 a suite of unit tests that ensure a critical part of our infrastructure behaves exactly how we expect it to without having to 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.
To see the above code snippets together in a working example, head over to the repository and clone the project.