You are currently viewing How to Implement a Swift HTTP Request Helper Without a Working Server

How to Implement a Swift HTTP Request Helper Without a Working Server

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.

Swift networking module architecture
The networking module architecture

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.

  1. Identify the RegistrationRequestHelper‘s dependencies.
  2. Finalise the request JSON’s structure.
  3. Finalise the response JSON’s structure.
  4. Define all possible RegistrationRequestHelper‘s errors.
  5. 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:

  1. Username already exists — User provided a username that already exist
  2. Unexpected response — Failed to parse the response JSON
  3. 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.

User object JSON and Error JSON
User object JSON and Error JSON

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:

  1. The test cases can act as executable documentation.
  2. You feel more confident as you know your code is working properly.
  3. Unit test cases can be executed anytime even without an internet connection.
  4. 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…

Keep calm and use test doubles

Further Readings


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.