HTTP request helper is one of the most essential parts of most modern-day apps. It takes care of all the processing work before and after calling a remote API.
Ideally, the implementation of HTTP request helper should start after the remote APIs are ready. However, in most cases, due to a tight project schedule, mobile developers will not be given the luxury to start their development work after the remote APIs are ready.
In this kind of situation, some developers might rework their development plan to work on UI-related features first while waiting for the server to get ready. Some others might set up a temporary local server to test out their networking module.
In this article, I will show you my way of dealing with this kind of situation — using test doubles to replicate the output from the remote APIs.
If you are not familiar with the concept of test doubles in Swift, feel free to check out this article that explains in detail the concept of dummy, fake, stub and mock.
🔗 Test Doubles in Swift: Dummy, Fake, Stub, Mock
The best way to demonstrate how stubbing and mocking a remote API can be done is by using a real life example.
Without further ado, let’s get started!
Disclaimer: The following example might not be fully compliant with the RESTful API Designing guidelines and best practices. This is intended in order to reduce the complexity of the example.
The Overall Architecture
In our example, let’s implement a HTTP request helper that performs a POST request that register a new user to the server.
Following diagram shows the overall architecture of our example.
The registration request helper is the class that we are going to implement. It takes care of all the operations required before performing the registration POST request, such as password encryption and JSON encoding.
Furthermore, it is also in charge of handling responses from the network layer, such as JSON decoding and error handling.
The encryption helper is a utility class that is responsible for password encryption. Here’s the skeleton of the EncryptionHelper
class.
protocol EncryptionHelperProtocol {
func encrypt(_ value: String) -> String
}
class EncryptionHelper: EncryptionHelperProtocol {
func encrypt(_ value: String) -> String {
// Encryption logic here
// ...
// ...
return "some encrypted value";
}
}
Another class to take note in our example is the network layer. It contains all the URLSession
and URLSessionTask
related code.
Following is the simplified skeleton of the NetworkLayer
class. Usually it should contain other methods such as get()
, put()
and delete()
. However, for demonstration purposes, we will only focus on the post()
methods.
protocol NetworkLayerProtocol {
func post(_ url: URL, parameters: Data, completion: (Result<Data, Error>) -> Void)
}
class NetworkLayer: NetworkLayerProtocol {
/// Perform POST request
/// - Parameters:
/// - url: Remote API endpoint
/// - parameters: Request's JSON data
/// - completion: Completion handler that either return response's JSON data or error
func post(_ url: URL, parameters: Data, completion: (Result<Data, Error>) -> Void) {
// Create URL session
// Create session task
// ...
// Perform POST request
// ...
// ...
// Trigger completion handler
}
}
Note that the actual implementation for both NetworkLayer
and EncryptionHelper
are not important, what’s important is their protocol. This is because we are going to create test doubles based on their protocol in a short while.
The Prerequisites
Before we dive into the RegistrationRequestHelper
class’s implementation, there are a few things we need to get it out of our way.
- Identify the
RegistrationRequestHelper
‘s dependencies. - Finalise the request JSON’s structure.
- Finalise the response JSON’s structure.
- Define all possible
RegistrationRequestHelper
‘s errors. - Define unit test cases for
RegistrationRequestHelper
.
Identify the RegistrationRequestHelper’s Dependencies
As mentioned earlier in this article, we will be creating test doubles to test out the RegistrationRequestHelper
, and all the RegistrationRequestHelper
‘s dependencies will be the test doubles that we need to create.
By using the architecture diagram, we can easily identify theRegistrationRequestHelper
‘s dependencies — NetworkLayer
and EncryptionHelper
.
Finalise the request JSON’s structure
In order to implement the RegistrationRequestHelper
, we need to know the request JSON’s structure. In real life, the server side developer should provide this information.
Furthermore, the request JSON will most likely contain a lot of information. However, for simplicity sake, let’s assume the request JSON’s structure is as follows.
{
"username": "swift-senpai",
"password": "abcd1234"
}
Finalise the response JSON’s structure
Next up is to finalise the response JSON’s structure when the API call successful. We need this information so that we can implement the JSON parser correctly.
Let’s assume the server will respond with a user object as shown below.
{
"user_id": 12345,
"username": "swift-senpai",
"email": null,
"phone": null
}
Based on the above sample JSON, we can create a User
class that conform to the Decodable
protocol, so that we can use the JsonDecoder
class for parsing later.
struct User: Decodable {
let userId: Int
let username: String
let email: String? = nil
let phone: String? = nil
}
Define all possible RegistrationRequestHelper’s errors
Do not forget that there might be situations where error occurred during the API call. Thus our RegistrationRequestHelper
will have to handle all the possible errors that might occur.
Again, for simplicity sake, let’s assume there are only 3 possible errors:
- Username already exists — User provided a username that already exist
- Unexpected response — Failed to parse the response JSON
- POST request failed — All other errors
The “username already exists” error should trigger when we receive an error JSON from server. Let’s just assume the JSON is as shown below.
{
"error_code": "E001",
"message": "Username already exists"
}
Following is the RegistrationRequestError
enum.
enum RegistrationRequestError: Error, Equatable {
case usernameAlreadyExists
case unexpectedResponse
case requestFailed
}
Do note that we conformed the RegistrationRequestError
enum to the Equatable
protocol, this is especially useful when we want to verify the RegistrationRequestHelper
‘s output during unit test.
Define unit test cases for RegistrationRequestHelper
Lastly, let’s define the unit test cases that we need to ensure that the RegistrationRequestHelper
is working correctly. Following are the verifications that are required.
- It is posting to the correct URL.
- Password is encrypted before posting.
- The request JSON’s structure is correct.
- The response JSON is parsed correctly.
- The
usernameAlreadyExists
error is handled correctly. - The
unexpectedResponse
error is handled correctly. - The
requestFailed
error is handled correctly.
With all the prerequisites out of the way, is time to buckle up and dive into the RegistrationRequestHelper
class’s implementation.
The Implementation
Let’s start by implementing the RegistrationRequestHelper
class’s initialiser. We will use an initialiser-based dependency injection to inject both networkLayer
and encryptionHelper
into the helper class.
protocol RegistrationHelperProtocol {
func register(_ username: String,
password: String,
completion: (Result<User, RegistrationRequestError>) -> Void)
}
class RegistrationRequestHelper: RegistrationHelperProtocol {
private let networkLayer: NetworkLayerProtocol
private let encryptionHelper: EncryptionHelperProtocol
// Inject networkLayer and encryptionHelper during initialisation
init(_ networkLayer: NetworkLayerProtocol, encryptionHelper: EncryptionHelperProtocol) {
self.networkLayer = networkLayer
self.encryptionHelper = encryptionHelper
}
}
Next we will add a register()
method that accepts a username, password and completion handler.
The register()
method will encrypt the given password, encode all post parameters to JSON data and perform the POST request using the given networkLayer
.
func register(_ username: String,
password: String,
completion: (Result<User, RegistrationRequestError>) -> Void) {
// Remote API URL
let url = URL(string: "https://api-call")!
// Encrypt password using encryptionHelper
let encryptedPassword = encryptionHelper.encrypt(password)
// Encode post parameters to JSON data
let parameters = ["username": username, "password": encryptedPassword]
let encoder = JSONEncoder()
let requestData = try! encoder.encode(parameters)
// Perform POST request using network layer
networkLayer.post(url, parameters: requestData) { (result) in
// Completion handler logic here...
}
}
The final part that we need to implement is the networkLayer
‘s completion handler. We will have to handle both the success
and failure
case of the returned Result
type.
For the success
case, we need to handle 2 types of JSON, the user object JSON and the error JSON. To recap, here are the sample JSONs.
Since we already created a User
class that conform to the Decodable
protocol, we can easily parse the user object JSON by using the JsonDecoder
class.
For the error JSON, we can use JSONSerialization
class to parse and grab the error_code
‘s value, so that we can identify what type of errors are occurring.
If we fail to parse the response JSON from the server, we will return the unexpectedResponse
error.
For the failure
case, we will just return the requestFailed
error.
The above explanation might be a little bit overwhelming, however the sample code below should be able to clear things up for you.
networkLayer.post(url, parameters: requestData) { (result) in
switch result {
case .success(let jsonData):
// Create JSON decoder to decode response JSON to User object
let decoder = JSONDecoder()
// Convert JSON key from snake case to camel case
// Ex: user_id --> userId
decoder.keyDecodingStrategy = .convertFromSnakeCase
if let user = try? decoder.decode(User.self, from: jsonData) {
// Parsing JSON to user object successful
// Trigger completion handler with user object
completion(.success(user))
return
} else if let error = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] {
// Parsing JSON to get error code
if let errorCode = error["error_code"] as? String {
// Error code available
// Use error code to identify the error
switch errorCode {
case "E001":
completion(.failure(.usernameAlreadyExists))
return
default:
break
}
}
}
// Failed to parse response JSON
// Trigger completion handler with error
completion(.failure(.unexpectedResponse))
case .failure:
// HTTP Request failed
// Trigger completion handler with error
completion(.failure(.requestFailed))
}
}
With that, we have done implementing the RegistrationRequestHelper
. Here’s the full implementation of it.
class RegistrationRequestHelper: RegistrationHelperProtocol {
private let networkLayer: NetworkLayerProtocol
private let encryptionHelper: EncryptionHelperProtocol
// Inject networkLayer and encryptionHelper during initialisation
init(_ networkLayer: NetworkLayerProtocol, encryptionHelper: EncryptionHelperProtocol) {
self.networkLayer = networkLayer
self.encryptionHelper = encryptionHelper
}
func register(_ username: String,
password: String,
completion: (Result<User, RegistrationRequestError>) -> Void) {
// Remote API URL
let url = URL(string: "https://api-call")!
// Encrypt password using encryptionHelper
let encryptedPassword = encryptionHelper.encrypt(password)
// Encode post parameters to JSON data
let parameters = ["username": username, "password": encryptedPassword]
let encoder = JSONEncoder()
let requestData = try! encoder.encode(parameters)
// Perform POST request using network layer
networkLayer.post(url, parameters: requestData) { (result) in
switch result {
case .success(let jsonData):
// Create JSON decoder to decode response JSON to User object
let decoder = JSONDecoder()
// Convert JSON key from snake case to camel case
// Ex: user_id --> userId
decoder.keyDecodingStrategy = .convertFromSnakeCase
if let user = try? decoder.decode(User.self, from: jsonData) {
// Parsing JSON to user object successful
// Trigger completion handler with user object
completion(.success(user))
return
} else if let error = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] {
// Parsing JSON to get error code
if let errorCode = error["error_code"] as? String {
// Error code available
// Use error code to identify the error
switch errorCode {
case "E001":
completion(.failure(.usernameAlreadyExists))
return
default:
break
}
}
}
// Failed to parse response JSON
// Trigger completion handler with error
completion(.failure(.unexpectedResponse))
case .failure:
// HTTP Request failed
// Trigger completion handler with error
completion(.failure(.requestFailed))
}
}
}
}
Phew! We have come a long way in implementing the RegistrationRequestHelper
. Now is time to test it out.
The Testing
We have finally reached the most important part of this article. We will use the test doubles concept in unit test to replicate the behaviour of an actual working server.
Here’s a quick recap on what we wanted to verify using unit test.
- It is posting to the correct URL.
- Password is encrypted before posting.
- The request JSON’s structure is correct.
- The response JSON is parsed correctly.
- The
usernameAlreadyExists
error is handled correctly. - The
unexpectedResponse
error is handled correctly. - The
requestFailed
error is handled correctly.
Let’s start with the first 3 items. We will group them into 1 XCTest test case because all of these 3 items can be verified by using the same test doubles. Furthermore, these 3 items are all related to configurations before making a POST request.
Verifications Before POST Request
In the prerequisites section, we have already identified the dependencies for the RegistrationRequestHelper
class — networkLayer
and encryptionHelper
. We will start by creating test doubles of these 2 classes.
class MockNetworkLayer: NetworkLayerProtocol {
var postUrl = URL(string: "http://dummy.com")!
var requestData = Data()
func post(_ url: URL,
parameters: Data,
completion: (Result<Data, Error>) -> Void) {
// Keep track on the given url and parameters value
postUrl = url
requestData = parameters
// Trigger completion with dummy data
completion(.success(Data()))
}
}
class MockEncryptionHelper: EncryptionHelperProtocol {
var encryptCalled = false
func encrypt(_ value: String) -> String {
// Set flag to true When encrypt(_:) is called
encryptCalled = true
// Return a dummy value (this value is not important)
return "1234567890"
}
}
From the code snippet above, you can see that we are declaring variables within the test doubles to keep track on the parameters that we wanted to verify. We call this kind of unit test strategy “mocking”.
With the test doubles ready, we can now start writing our unit test. Note that we will be using the AAA pattern (Arrange-Act-Assert) to write the unit test.
/// Assert configurations and parameters for the post request are correct
func testBeforePostRequest() {
/* Arrange */
// Create dependencies
let username = "swift-senpai"
let password = "abcd1234"
let mockNetworkLayer = MockNetworkLayer()
let mockEncryptionHelper = MockEncryptionHelper()
// Set expectation
let exp = expectation(description: "Post request completed")
/* Act */
// Perform post request
let requestHelper = RegistrationRequestHelper(mockNetworkLayer, encryptionHelper: mockEncryptionHelper)
requestHelper.register(username, password: password) { (result) in
exp.fulfill()
}
waitForExpectations(timeout: 5.0, handler: nil)
/* Assert */
// Assert post url is correct
let expectedUrl = URL(string: "https://api-call")!
let actualUrl = mockNetworkLayer.postUrl
XCTAssertEqual(actualUrl, expectedUrl)
// Assert encrypt is called
XCTAssertTrue(mockEncryptionHelper.encryptCalled)
// Assert post parameters are correct
let encryptedPassword = mockEncryptionHelper.encrypt(password)
var expectedRequestJson = """
{
"username": "\(username)",
"password": "\(encryptedPassword)"
}
"""
expectedRequestJson.trimJSON()
let actualRequestData = mockNetworkLayer.requestData
let actualRequestJson = String(data: actualRequestData, encoding: .utf8)!
XCTAssertEqual(expectedRequestJson, actualRequestJson)
}
// MARK:- Utilities
extension String {
mutating func trimJSON() {
self = self.replacingOccurrences(of: "\n", with: "")
self = self.replacingOccurrences(of: " ", with: "")
}
}
There are 2 things to take note here.
First, note that we are verifying that the mockEncryptionHelper
‘s encrypt(_:)
method has been called. We are only interested in knowing whether the password is being encrypted, the correctness of the encryption logic is not important here.
In order to verify the correctness of the encryption logic, we should create another dedicated test plan for that, however that is beyond the scope of this article.
Second, note that in order to verify that the post parameters are correct, we are using JSON string for assertion instead of JSON data. This is because JSON string is more visualisable compared to JSON data.
To build and run the test, press ⌘Q. You should see a “Test Succeeded” notification from Xcode if you have followed along everything correctly.
Verifications After POST Request
Next up, let’s verify that the response JSON is parsed to User
object correctly.
For this test case, the test doubles that we need is a dummy encryptionHelper
and a networkLayer
that return a user object JSON data.
Note that in the code snippet below, the technique that we used to force a specific output from the networkLayer
‘s post()
method is called “Stubbing”.
/// Dummy object that do nothing
class DummyEncryptionHelper: EncryptionHelperProtocol {
func encrypt(_ value: String) -> String {
return value
}
}
/// NetworkLayer stub that return success result
class StubSuccessNetworkLayer: NetworkLayerProtocol {
func post(_ url: URL,
parameters: Data,
completion: (Result<Data, Error>) -> Void) {
let responseJson = """
{
"user_id": 1001,
"username": "swift-senpai",
"email": null,
"phone": null
}
"""
// Convert JSON string to JSON data
let jsonData = Data(responseJson.utf8)
completion(.success(jsonData))
}
}
The way to utilise the above test doubles is fairly straightforward.
/// Assert that the parsing of User object from JSON is working correctly
func testParseUserObject() {
/* Arrange */
// Create dependencies
let stubNetworkLayer = StubSuccessNetworkLayer()
let dummyEncryptionHelper = DummyEncryptionHelper()
// Set expectation
let exp = expectation(description: "Post request completed")
/* Act */
// Perform post request
var postResult: Result<User, RegistrationRequestError>!
let requestHelper = RegistrationRequestHelper(stubNetworkLayer, encryptionHelper: dummyEncryptionHelper)
requestHelper.register("dummy-username", password: "dummy-password") { (result) in
// Capture post result
postResult = result
exp.fulfill()
}
waitForExpectations(timeout: 5.0, handler: nil)
/* Assert */
let actualUser = try? postResult.get()
let expectedUser = User(userId: 1001, username: "swift-senpai")
// Assert user ID is correct
XCTAssertEqual(expectedUser.userId, actualUser?.userId)
// Assert username is correct
XCTAssertEqual(expectedUser.username, actualUser?.username)
// Assert email & phone is nil
XCTAssertNil(actualUser?.email)
XCTAssertNil(actualUser?.phone)
}
By using the same stubbing strategy, we can proceed to the next test case — Verify that the usernameAlreadyExists
error is being handled correctly.
Below is the required test double.
/// NetworkLayer stub that return "username already exists" error
class StubUsernameExistNetworkLayer: NetworkLayerProtocol {
func post(_ url: URL,
parameters: Data,
completion: (Result<Data, Error>) -> Void) {
let responseJson = """
{
"error_code" : "E001",
"message":"Username already exists"
}
"""
// Convert JSON string to JSON data
let jsonData = Data(responseJson.utf8)
completion(.success(jsonData))
}
}
Here’s the test case.
/// Assert that the username already exists error will trigger
func testUsernameAlreadyExists() {
/* Arrange */
// Create dependencies
let stubNetworkLayer = StubUsernameExistNetworkLayer()
let dummyEncryptionHelper = DummyEncryptionHelper()
// Set expectation
let exp = expectation(description: "Post request completed")
/* Act */
// Perform post request
var postResult: Result<User, RegistrationRequestError>!
let requestHelper = RegistrationRequestHelper(stubNetworkLayer, encryptionHelper: dummyEncryptionHelper)
requestHelper.register("dummy-username", password: "dummy-password") { (result) in
// Capture post result
postResult = result
exp.fulfill()
}
waitForExpectations(timeout: 5.0, handler: nil)
/* Assert */
var actualError: RegistrationRequestError?
XCTAssertThrowsError(try postResult.get()) { (error) in
actualError = error as? RegistrationRequestError
}
let expectedError = RegistrationRequestError.usernameAlreadyExists
XCTAssertEqual(expectedError, actualError)
}
As you can see, the test doubles and test cases above are very similar. Thus, I will leave the final 2 test cases for you.
- The
unexpectedResponse
error is handled correctly. - The
requestFailed
error is handled correctly.
By using the stubbing technique that we discussed just now, you should be able to get them done without any problem.
In case you get stuck in the last 2 test cases, you can find the full sample code and unit test cases here.
Wrapping Up
That’s it! This is how I developed and tested the entire RegistrationRequestHelper
class without depending on a real life working server.
By using the mocking and stubbing strategy in test doubles, we manage to replicate the output of the remote APIs. In fact, I would recommend every developer to perform unit tests on their networking module even though a working server is available.
Here are some other benefits you can get by unit testing your networking module:
- The test cases can act as executable documentation.
- You feel more confident as you know your code is working properly.
- Unit test cases can be executed anytime even without an internet connection.
- Unit test cases are easy and fast to execute.
Next time when you need to implement a HTTP request helper without a working server, just remember this…
Further Readings
- Test Doubles in Swift: Dummy, Fake, Stub, Mock
- Unit tests best practices in Xcode and Swift
- Unit testing asynchronous Swift code
I hope this article gives you a good inspiration on how you can use unit tests to aid your daily development work.
If you like this article, feel free to share it. Let me know your thoughts in the comment section below.
Follow me on Twitter for more articles related to iOS development.
Thanks for reading and happy coding. 👨🏼💻
👋🏻 Hey!
While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.
- ✨ Bartender: Superpower your menu bar and take full control over your menu bar items.
- ✨ CleanShot X: The best screen capture app I’ve ever used.
- ✨ PixelSnap: Measure on-screen elements with ease and precision.
- ✨ iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.