Not Too Rigid, Not Too Loose - Building URLRequests Just Right

27 Sep 2016 18 min read

Every networking layer I've seen tries to simplify URL construction, and most of them get it wrong in one of two ways. Either the solution is too flexible - a loose collection of extensions with the same host, headers, and timeouts copied across every request. Or too rigid - resulting in the team spending more time fighting it than using it.

Configuring a single URLRequest is easy, but five, ten, twenty of them is hard. What to share? What to allow to be overwritten? How to handle diverse payloads? Each question needs to be answered carefully because URL construction will form a key part of an app's infrastructure - mistakes here can ripple.

Photo of three cranes on a construction site

This post will explore how to build a URL construction component that is flexible enough to support all HTTP network requests in your app.

This post assumes your network requests are going to an HTTP API that accepts and sends JSON payloads.

Looking at What We Need to Build

It's common for an app to communicate with different servers for the stages of the software development lifecycle. The need to support multiple server environments means that the different parts of a network request can be thought of as falling into one of two categories:

  • Environment-dependent - changes with environment, such as Scheme, Host, Port, etc.
  • Endpoint-dependent - stays the same across environments, such as Path, Query-Items, Body, etc.

Headers can straddle both categories.

The environment-dependent parts of a network request tend to be shared across all requests that connect to that environment, whereas the endpoint-dependent parts tend to be tied to just one network request.

So each network request our apps make is made up of both these parts, meaning we have at least two sources of truth for a network request within the app. Whenever we have multiple sources of truth within a project, we need to be very careful about where one responsibility ends and another begins.

Before jumping into building out these sources of truth, let's take a moment to look at the overall architecture we're going to put in place and how each component fits into it.

A class diagram showing the relationships between five components. At the top, RequestFactory uses URLRequestBuilderFactory. URLRequestBuilderFactory then creates EnvironmentConfiguration, reads Environment, and creates URLRequestBuilder

  • EnvironmentConfiguration - represents the environment-dependent values for whichever environment the current build of the app is communicating with.
  • Environment - determines which environment the app should currently be communicating with.
  • RequestFactory - triggers the building of the URLRequest instance by configuring an instance of URLRequestBuilder with the endpoint-dependent values.
  • URLRequestBuilder - takes environment-dependent and endpoint-dependent values, and merges them to create an instance of URLRequest. Intended to be single-use, so a new builder is needed for each URLRequest instance.
  • URLRequestBuilderFactory - creates URLRequestBuilder instances.

I've used generic naming in the class diagram, but in the example, we will be working in concrete types, so EnvironmentConfiguration will become DevelopmentEnvironmentConfiguration, etc., and RequestFactory will become UserRequestFactory, etc.

Don't worry if that doesn't all make sense yet; we will look into each component in greater depth below.

Building out the Environment

For our networking needs, the environment-dependent values will consist of:

  1. Scheme.
  2. Host.
  3. Port.
  4. Cache Policy.
  5. Timeout Interval.

For communicating with our development environment, these values can be represented with a config type:

struct DevelopmentEnvironmentConfiguration {
    // 1
    let urlComponents: URLComponents = {
        var components = URLComponents()
        components.scheme = "http"
        components.host = "localhost"
        components.port = 8080

        return components
    }()

    // 2
    let headers: [String: String] = {
        var headers = [String: String]()

        headers["Content-Type"] = "application/json"
        headers["Accept"] = "application/json"
        headers["X-Environment"] = "development"

        return headers
    }()

    // 3
    let cachePolicy: URLRequest.CachePolicy = {
        return .reloadIgnoringLocalCacheData
    }()

    // 4
    let timeoutInterval: TimeInterval = {
        return 120
    }()
}
  1. urlComponents sets the scheme, host, and port that all development requests will use. Using http and localhost:8080 allows us to target a local server during development. By using URLComponents rather than a raw string like "http://localhost:8080", we get structured access to each part of the URL. URLComponents handles the assembly of its various parts and percent encoding correctly, whereas string concatenation can silently produce malformed URLs when values contain special characters like spaces or ampersands - leaving us to spot those issues without any additional guidance.
  2. headers defines the default headers that will be included with every request. Content-Type and Accept signal that we're sending and expecting JSON, while X-Environment lets the server know this request is coming from a development build.
  3. cachePolicy is set to .reloadIgnoringLocalCacheData so that responses are always fetched fresh during development, avoiding stale data while we're actively working on integrating a new endpoint.
  4. timeoutInterval is set to 120 seconds, which is probably longer than we'd want in production, but in development, it allows for additional time to help debug any server-side issues.

This pattern for defining the configuration for an environment can be extended to define the staging and production configurations - StagingEnvironmentConfiguration and ProductionEnvironmentConfiguration respectively. To support multiple configurations without bloating the code, we need to group these configurations behind a protocol:

protocol EnvironmentConfiguration {
    var urlComponents: URLComponents { get }
    var headers: [String: String] { get }
    var cachePolicy: URLRequest.CachePolicy { get }
    var timeoutInterval: TimeInterval { get }
}

EnvironmentConfiguration mirrors the current implementation of DevelopmentEnvironmentConfiguration.

We can then update our configurations to conform to EnvironmentConfiguration:

struct DevelopmentEnvironmentConfiguration: EnvironmentConfiguration {
    // Omitted functionality
}

struct StagingEnvironmentConfiguration: EnvironmentConfiguration {
    // Omitted functionality
}

struct ProductionEnvironmentConfiguration: EnvironmentConfiguration {
    // Omitted functionality
}

See the accompanying project for the full implementations of StagingEnvironmentConfiguration and ProductionEnvironmentConfiguration.

Now that we have our environment-dependent values, let's see how we can use them.

Building out the Builder

As mentioned, the role of URLRequestBuilder is to take the environment-dependent values and the endpoint-dependent values, and merge them together to create an URLRequest instance. A builder separates the construction of an object from its final representation. Rather than passing every possible parameter into a single initialiser - which can quickly become unwieldy - we will configure the object step by step through a series of method calls, then produce the finished result at the end with a build() call. Each step in the chain is small and readable, with the build() method acting as a clear boundary between configuring and done. To make the steps feel like a flowing sequence, we will chain the methods together.

As the endpoint-dependent values will change for each endpoint, but the environment-dependent values will be consistent across all endpoints, our builder will accept environment-dependent values during initialisation and then accept the endpoint-dependent values as needed for that particular endpoint. Let's start by adding support for accepting environment-dependent values:

final class URLRequestBuilder {
    let configuration: EnvironmentConfiguration
    private var components: URLComponents
    private var headers: [String: String]
    private var cachePolicy: URLRequest.CachePolicy
    private var timeoutInterval: TimeInterval

    // MARK: - Init

    // 1
    init(configuration: EnvironmentConfiguration) {
        self.configuration = configuration
        self.components = configuration.urlComponents
        self.headers = configuration.headers
        self.cachePolicy = configuration.cachePolicy
        self.timeoutInterval = configuration.timeoutInterval
    }
}
  1. The initialiser takes an EnvironmentConfiguration instance and copies its values into the builder's own properties.

While our configs should only have values that are common across all the network requests, there are times when one particular network request needs to tweak a value that comes from the config - we could solve this in several ways: move the offending value out of the config and treat it as an endpoint-dependent value or create multiple, slightly different environment-dependent configs. These, while technically correct, are heavy on the demands they place on us. Instead, we will treat the environment-dependent values as a baseline and allow each endpoint to alter them to match its needs:

final class URLRequestBuilder {
    // Omitted existing functionality

    // 1
    func header(key: String, value: String) -> Self {
        headers[key] = value

        return self
    }

    // 2
    func headers(_ headers: [String: String]) -> Self {
        self.headers.merge(headers) { _, new in new }

        return self
    }

    // 3
    func cachePolicy(_ cachePolicy: URLRequest.CachePolicy) -> Self {
        self.cachePolicy = cachePolicy

        return self
    }

    // 4
    func timeoutInterval(_ timeoutInterval: TimeInterval) -> Self {
        self.timeoutInterval = timeoutInterval

        return self
    }
}
  1. header(key:value:) adds or overwrites a single header. If the configuration already provides a header with the same key, the new value takes precedence. Returns Self to allow chaining.
  2. headers(_:) merges a dictionary of headers into the existing set. The { _, new in new } closure ensures that any conflicts are resolved in favour of the new values, matching the behaviour of the single-header method.
  3. cachePolicy(_:) overrides the cache policy inherited from the configuration. This allows a specific request to opt out of the environment's default caching behaviour - for example, forcing a fresh fetch for a request that must always return the latest data.
  4. timeoutInterval(_:) overrides the timeout inherited from the configuration. Useful when a particular request is expected to take longer than usual, such as a large file upload.

Now that we can set the environment-dependent values, let's add support for the endpoint-dependent values:

// 1
enum HTTPMethod: String, Equatable {
    case GET
    case POST
    case DELETE
    case PUT
    case PATCH
}

final class URLRequestBuilder {
    // Omitted existing functionality

    private var method: HTTPMethod = .GET
    private var headers: [String: String]
    private var body: Data?

    // Omitted existing functionality

    // 2
    func path(_ path: String) -> Self {
        components.path = path

        return self
    }

    // 3
    func method(_ method: HTTPMethod) -> Self {
        self.method = method

        return self
    }

    // 4
    func body(_ body: Data) -> Self {
        self.body = body

        return self
    }

    // 5
    func queryItems(_ queryItems: [URLQueryItem]) -> Self {
        components.queryItems = queryItems.isEmpty ? nil : queryItems

        return self
    }
}
  1. HTTPMethod is a collection of common HTTP Methods. Using an enum avoids having to have a stringy API for setting the httpMethod on the URLRequest instance.
  2. path(_:) sets the path on the URLComponents instance. This replaces whatever path the configuration's urlComponents had, allowing each request to target a specific endpoint like /v3/user while inheriting the scheme and host from the configuration.
  3. method(_:) sets the method for the request httpMethod value. Takes an HTTPMethod enum value rather than a raw string, which prevents typos and limits the options to the methods we actually support.
  4. body(_:) sets the body value for the request httpBody value. It isn't the responsibility of URLRequestBuilder to do any encoding of body, that is the responsibility of the RequestFactory - this allows the URLRequestBuilder to be used with different body format types.
  5. queryItems(_:) sets the queryItems on the URLComponents instance. If the array is empty, it sets queryItems to nil, which avoids appending a trailing ? to the URL with no actual parameters.

Now that we can gather the various parts that make a URLRequest, it's time to build it:

// 1
enum URLRequestBuildingError: Error {
    case urlInvalid
}

final class URLRequestBuilder {
    // Omitted existing functionality

    // 2
    private var hasBuilt = false

    // Omitted existing functionality

    func build() throws -> URLRequest {
        // 2
        guard !hasBuilt else {
            preconditionFailure("Builder.build() must only be called once. Create a new builder for each request.")
        }

        hasBuilt = true

        // 3
        guard let url = components.url else {
            throw URLRequestBuildingError.urlInvalid
        }

        // 4
        var request = URLRequest(url: url,
                                 cachePolicy: cachePolicy,
                                 timeoutInterval: timeoutInterval)

        request.httpMethod = method.rawValue
        request.allHTTPHeaderFields = headers
        request.httpBody = body

        return request
    }
}
  1. URLRequestBuildingError defines the way that building a request can fail: the URL components are in an invalid state.
  2. hasBuilt enforces that each builder instance produces exactly one URLRequest. The preconditionFailure crashes immediately if build() is called a second time, making misuse obvious during development rather than allowing stale state to silently leak between requests.
  3. components.url can return nil if the URLComponents are in an invalid state - for example, if the scheme is missing. Rather than force-unwrapping and crashing, we throw a descriptive error that the caller can handle.
  4. The URLRequest is assembled from the builder's current state in one go. The URL, cache policy, and timeout go in through the initialiser, then the method, headers, and body are set on the resulting request.

As we can see above, we have support for multiple environment configurations, so which one do we pass into our URLRequestBuilder instance?

We don't want to have to figure out the right environment configuration each time we want to build a URLRequestBuilder instance, so instead we will build two helper types to choose the right environment configuration each time: Environment and URLRequestBuilderFactory.

We can select the correct configuration by tying a particular Active Compilation Conditions to a particular environment configuration. This tying can then be encapsulated inside Environment:

// 1
enum Environment {
    case development
    case staging
    case production

    // 2
    static var current: Environment {
        #if RELEASE
        return .production
        #elseif STAGING
        return .staging
        #else
        return .development
        #endif
    }
}
  1. Environment represents the three environments our app can target. Using an enum rather than raw strings means the compiler prevents us from accidentally referencing an environment that doesn't exist.
  2. current uses Active Compilation Conditions to determine which environment we're running in at compile time. These conditions are set in the build settings for each Xcode configuration, so switching from development to production is a matter of changing which scheme you build with - no code changes required.

To ensure that the correct EnvironmentConfiguration is passed into URLRequestBuilder, we always create that builder inside its own factory:

struct URLRequestBuilderFactory {
    // 1
    func createBuilder() -> URLRequestBuilder {
        switch Environment.current {
        case .development:
            return URLRequestBuilder(configuration: DevelopmentEnvironmentConfiguration())
        case .staging:
            return URLRequestBuilder(configuration: StagingEnvironmentConfiguration())
        case .production:
            return URLRequestBuilder(configuration: ProductionEnvironmentConfiguration())
        }
    }
}
  1. createBuilder() maps the Environment.current case to a particular URLRequestBuilder and EnvironmentConfiguration combination.

Now that we can create URLRequestBuilder, let's see how to configure it to communicate with a particular endpoint.

Building out the Endpoints

A RequestFactory groups all request-building methods for one particular endpoint/service into one place. Each method knows the details about what is needed to communicate with a particular endpoint. Let's build a request factory focused on user endpoints:

struct UserRequestFactory {
    private let urlRequestBuilderFactory: URLRequestBuilderFactory

    // 1
    init(urlRequestBuilderFactory: URLRequestBuilderFactory = URLRequestBuilderFactory()) {
        self.urlRequestBuilderFactory = urlRequestBuilderFactory
    }
}
  1. The initialiser has one parameter - urlRequestBuilderFactory - that will be used to build URLRequestBuilder instances configured for the correct environment.

Let's fetch from the /v3/user endpoint:

struct UserRequestFactory {
    // 1
    func createUserGETRequest() throws -> URLRequest {
        // 2
        return try urlRequestBuilderFactory.createBuilder()
            .path("/v3/user")
            .method(.GET)
            .build()
    }
}
  1. createUserGETRequest() creates the URLRequest for a GET request to the user endpoint. It throws as URLRequestBuilder can throw.
  2. A fresh URLRequestBuilder is created, configured with just the path and HTTP method, then built. The factory doesn't set headers, cache policy, or timeout because those come from the configuration the builder was seeded with. Only the values unique to this request appear here.

This pattern can then be extended for other endpoints:

struct User: Codable {
    let name: String
}

struct UserRequestFactory {
    func createUserPOSTRequest(user: User) throws -> URLRequest {
        let body = try JSONEncoder().encode(user)

        return try urlRequestBuilderFactory.createBuilder()
            .path("/v3/user")
            .method(.POST)
            .body(body)
            .build()
    }

    func createUserFeedGETRequest(userID: String, ascending: Bool) throws -> URLRequest {
        let queryItems = [URLQueryItem(name: "order", value: ascending ? "ascending" : "descending")]

        return try urlRequestBuilderFactory.createBuilder()
            .path("/v3/user/\(userID)/feed")
            .method(.GET)
            .queryItems(queryItems)
            .build()
    }
}

The methods that exist in a RequestFactory can be as simple or complex as needed for a given endpoint.

URLRequestBuilder doesn't need to care if the path value was built dynamically or where a particular header comes from; it just focuses on what it needs to build a URLRequest and leaves those other details to the RequestFactory to figure out.

Now that we can build a URLRequest configured for a particular endpoint, let's look at how to test what we have.

Testing What We've Built

So we've produced a really nice solution for making URLRequest instances, but to have real confidence in our solution, we need automated tests.

To see how automated testing leads to greater confidence, read How To Win Over a Unit Testing Sceptic.

Let's begin by writing tests for DevelopmentEnvironmentConfiguration:

class DevelopmentEnvironmentConfigurationTests: XCTestCase {
    // 1
    var sut: DevelopmentEnvironmentConfiguration!

    // 2
    override func setUp() {
        super.setUp()

        sut = DevelopmentEnvironmentConfiguration()
    }

    // 3
    override func tearDown() {
        sut = nil

        super.tearDown()
    }
}
  1. sut - short for "system under test" - holds the current instance of DevelopmentEnvironmentConfiguration that will be used in the tests.
  2. setUp is called before each test method runs, giving us a fresh instance of the configuration. This ensures no state from a previous test can interfere with the next.
  3. tearDown is called after each test method finishes, nilling out the instance. This pairs with setUp to create a clean lifecycle for each test - even though the configuration is a simple struct, the habit is worth maintaining for consistency across the test suite.

Now that we have the test suite set up, it's time to test the functionality of DevelopmentEnvironmentConfiguration:

class DevelopmentEnvironmentConfigurationTests: XCTestCase {
    // Omitted unchanged functionality

    // 1
    func test_givenConfiguration_thenUrlComponentsIsCorrect() {
        XCTAssertEqual(sut.urlComponents, URLComponents(string: "http://localhost:8080"))
    }

    // 2
    func test_givenConfiguration_thenHeadersIsCorrect() {
        XCTAssertEqual(sut.headers, ["X-Environment": "development",
                                     "Accept": "application/json",
                                     "Content-Type": "application/json"])
    }

    // 3
    func test_givenConfiguration_thenCachePolicyIsCorrect() {
        XCTAssertEqual(sut.cachePolicy, .reloadIgnoringLocalCacheData)
    }

    // 4
    func test_givenConfiguration_thenTimeoutIntervalIsCorrect() {
        XCTAssertEqual(sut.timeoutInterval, 120)
    }
}
  1. Tests the URL components by comparing against a URLComponents instance built from a string. This verifies that the scheme, host, and port are all correct in a single assertion.
  2. Tests the headers dictionary by comparing against a literal. Using literal expected values means the test has an independent opinion about what the output should be - if someone changes the configuration, the test fails rather than silently mirroring the change.
  3. Tests that the cache policy is set to ignore local caches, which is the behaviour we want during development to avoid working with stale responses.
  4. Tests that the timeout is set to 120 seconds - the longer development timeout that gives us room to debug without requests timing out.

These tests are repeated (with the values changed) for StagingEnvironmentConfiguration and ProductionEnvironmentConfiguration.

Before we can move on to writing tests for URLRequestBuilder, we first need to introduce an environment configuration test-double:

// 1
struct StubConfiguration: EnvironmentConfiguration {
    let urlComponents: URLComponents
    let headers: [String: String]
    let cachePolicy: URLRequest.CachePolicy
    let timeoutInterval: TimeInterval

    // 2
    init(urlComponents: URLComponents = URLComponents(string: "http://williamboles.com/making-a-request-with-a-side-of-testing/")!,
         headers: [String: String] = [:],
         cachePolicy: URLRequest.CachePolicy = .reloadIgnoringLocalCacheData,
         timeoutInterval: TimeInterval = 30) {
        self.urlComponents = urlComponents
        self.headers = headers
        self.cachePolicy = cachePolicy
        self.timeoutInterval = timeoutInterval
    }
}
  1. StubConfiguration conforms to EnvironmentConfiguration.
  2. Every property has a default value in the initialiser, so a test can create one with just the values it cares about and let the rest fall back to sensible defaults. This keeps tests focused - a test that's verifying header behaviour only needs to specify headers, not the full set of URL components, cache policy, and timeout that the real configurations require.

We have created a test-double of EnvironmentConfiguration rather than using one of the existing environment configurations, as we want to be able to change values as required without affecting the URLRequestBuilder tests.

With StubConfiguration, we can now write tests for URLRequestBuilder:

class URLRequestBuilderTests: XCTestCase {

    // 1
    func test_givenAValidConfiguration_andNoFurtherChangesAreMade_whenRequestIsBuilt_thenDefaultsAreUsed() throws {
        let configuration = StubConfiguration(urlComponents: URLComponents(string: "http://williamboles.com/making-a-request-with-a-side-of-testing/")!,
                                              headers: ["header_A": "value_A", "header_B": "value_B"],
                                              cachePolicy: .reloadIgnoringCacheData,
                                              timeoutInterval: 150)
        let sut = URLRequestBuilder(configuration: configuration)

        let request = try sut.build()

        XCTAssertEqual(request.url?.absoluteString, "http://williamboles.com/making-a-request-with-a-side-of-testing/")
        XCTAssertEqual(request.allHTTPHeaderFields, ["header_A": "value_A", "header_B": "value_B"])
        XCTAssertEqual(request.cachePolicy, .reloadIgnoringCacheData)
        XCTAssertEqual(request.timeoutInterval, 150)
    }

    // 2
    func test_givenAValidConfiguration_andPathIsChanged_whenRequestIsBuilt_thenPathIsChanged() throws {
        let configuration = StubConfiguration(urlComponents: URLComponents(string: "http://williamboles.com/making-a-request-with-a-side-of-testing/")!)
        let sut = URLRequestBuilder(configuration: configuration)

        let request = try sut
            .path("/v4/test_path/")
            .build()

        XCTAssertEqual(request.url?.absoluteString, "http://williamboles.com/v4/test_path/")
    }

    // 3
    func test_givenAValidConfiguration_andNewHeaderIsAdded_whenRequestIsBuilt_thenNewHeaderIsPresent() throws {
        let configuration = StubConfiguration(headers: ["header_A": "value_A", "header_B": "value_B"])
        let sut = URLRequestBuilder(configuration: configuration)

        let request = try sut
            .header(key: "header_C", value: "value_C")
            .build()

        XCTAssertEqual(request.allHTTPHeaderFields, ["header_A": "value_A", "header_B": "value_B", "header_C": "value_C"])
    }

    // 4
    func test_givenAValidConfiguration_andExistingHeaderIsAdded_whenRequestIsBuilt_thenExistingHeaderIsOverriddenWithNewValue() throws {
        let configuration = StubConfiguration(headers: ["header_A": "value_A", "header_B": "value_B"])
        let sut = URLRequestBuilder(configuration: configuration)

        let request = try sut
            .header(key: "header_A", value: "value_C")
            .build()

        XCTAssertEqual(request.allHTTPHeaderFields, ["header_A": "value_C", "header_B": "value_B"])
    }

    // 5
    func test_givenAValidConfiguration_andMultipleNewHeadersAreAdded_whenRequestIsBuilt_thenNewHeadersArePresent() throws {
        let configuration = StubConfiguration(headers: ["header_A": "value_A", "header_B": "value_B"])
        let sut = URLRequestBuilder(configuration: configuration)

        let request = try sut
            .headers(["header_C": "value_C",
                      "header_D": "value_D"])
            .build()

        XCTAssertEqual(request.allHTTPHeaderFields, ["header_A": "value_A", "header_B": "value_B", "header_C": "value_C", "header_D": "value_D"])
    }

    // 6
    func test_givenAValidConfiguration_andNewHeadersContainsExistingHeader_whenRequestIsBuilt_thenExistingHeaderIsOverriddenWithNewValue() throws {
        let configuration = StubConfiguration(headers: ["header_A": "value_A", "header_B": "value_B"])
        let sut = URLRequestBuilder(configuration: configuration)

        let request = try sut
            .headers(["header_A": "value_A",
                      "header_B": "value_C"])
            .build()

        XCTAssertEqual(request.allHTTPHeaderFields, ["header_A": "value_A", "header_B": "value_C"])
    }

    // 7
    func test_givenAValidConfiguration_andCachePolicyIsUpdated_whenRequestIsBuilt_thenNewCachePolicyIsUsed() throws {
        let configuration = StubConfiguration(cachePolicy: .reloadIgnoringCacheData)
        let sut = URLRequestBuilder(configuration: configuration)

        let request = try sut
            .cachePolicy(.reloadIgnoringLocalAndRemoteCacheData)
            .build()

        XCTAssertEqual(request.cachePolicy, .reloadIgnoringLocalAndRemoteCacheData)
    }

    // 8
    func test_givenAValidConfiguration_andTimeoutIntervalIsUpdated_whenRequestIsBuilt_thenNewTimeoutIntervalIsUsed() throws {
        let configuration = StubConfiguration(timeoutInterval: 150)
        let sut = URLRequestBuilder(configuration: configuration)

        let request = try sut
            .timeoutInterval(2000)
            .build()

        XCTAssertEqual(request.timeoutInterval, 2000)
    }

    // 9
    func test_givenAValidConfiguration_andQueryItemsAreAdded_whenRequestIsBuilt_thenQueryItemsAreUsed() throws {
        let configuration = StubConfiguration()
        let sut = URLRequestBuilder(configuration: configuration)

        let request = try sut
            .queryItems([URLQueryItem(name: "item_A", value: "value_A"), URLQueryItem(name: "item_B", value: "value_B")])
            .build()

        XCTAssertEqual(request.url?.absoluteString, "http://williamboles.com/making-a-request-with-a-side-of-testing/?item_A=value_A&item_B=value_B")
    }

     // 10
    func test_givenAValidConfiguration_andBodyIsAdded_whenRequestIsBuilt_thenBodyIsUsed() throws {
        let configuration = StubConfiguration()
        let sut = URLRequestBuilder(configuration: configuration)

        let data = "test_data".data(using: .utf8)!

        let request = try sut
            .body(data)
            .build()

        XCTAssertEqual(request.httpBody, data)
    }

    // 11
    func test_givenAnInvalidPath_whenRequestIsBuilt_thenAnErrorIsThrown() throws {
        let configuration = StubConfiguration()
        let sut = URLRequestBuilder(configuration: configuration)

        XCTAssertThrowsError(try sut.path("@_invalidPath_@").build()) { error in
            guard case .urlInvalid = error as? URLRequestBuildingError else {
                XCTFail("Expected urlInvalid, got \(error)")
                return
            }
        }
    }
}
  1. Tests that when no chaining methods are called, the resulting URLRequest uses exactly the values from the configuration. This is the baseline - everything else in the test suite builds on the assumption that the configuration's defaults flow through correctly.
  2. Tests that calling path(_:) replaces the path from the configuration's urlComponents while preserving the scheme and host. The original path (if it existed) is gone - this isn't an append, it's a replacement.
  3. Tests that adding a new header via header(key:value:) merges it into the existing configuration headers without disturbing them.
  4. Tests the override behaviour of header(key:value:) - when the key matches an existing configuration header, the new value wins. This is the mechanism that lets a factory customise a specific header for one request without affecting the others.
  5. Tests that headers(_:) merges multiple new headers into the existing set in a single call, with all configuration headers preserved alongside the new ones.
  6. Tests that headers(_:) follows the same override rule as header(key:value:) - when keys collide, the new values take precedence.
  7. Tests that cachePolicy(_:) overrides the configuration's default.
  8. Tests that timeoutInterval(_:) overrides the configuration's default, following the same pattern as the cache policy test.
  9. Tests that queryItems(_:) appends query parameters to the URL. The assertion checks the full URL string, which confirms that URLComponents handled the ? separator and & joining correctly.
  10. Tests that the value passed into body(_:) is used as the request's httpBody.
  11. Tests the error path for an invalid URL. When URLComponents.url returns nil due to a malformed path, the builder throws urlInvalid rather than crashing.

Adding tests for URLRequestBuilderFactory requires a bit more refactoring than what we've seen so far. Currently, in the createBuilder() method, we directly use Environment.current to determine which environment configuration to use. This means that we will only be able to test one path through createBuilder() - the one that matches the current Active Compilation Conditions value. We can use Dependency Injection here to pass an Environment case instead of fetching it - this will allow us to test all three paths:

struct URLRequestBuilderFactory {
    // 1
    func createBuilder(for environment: Environment = .current) -> URLRequestBuilder {
        switch environment {
        case .development:
            return URLRequestBuilder(configuration: DevelopmentEnvironmentConfiguration())
        case .staging:
            return URLRequestBuilder(configuration: StagingEnvironmentConfiguration())
        case .production:
            return URLRequestBuilder(configuration: ProductionEnvironmentConfiguration())
        }
    }
}
  1. createBuilder now has one parameter that allows for the Environment to be injected in. To make the production path easier, we default the parameter to .current - we can override this in the tests.

Now that we inject the Environment in, let's write some tests:

final class URLRequestBuilderFactoryTests: XCTestCase {
    var sut: URLRequestBuilderFactory!

    override func setUp() {
        super.setUp()

        sut = URLRequestBuilderFactory()
    }

    override func tearDown() {
        sut = nil

        super.tearDown()
    }

    // 1
    func test_givenAFactory_whenTheEnvironmentIsDevelopment_thenURLBuilderShouldHoldTheCorrectConfiguration() {
        let builder = sut.createBuilder(for: .development)

        XCTAssertTrue(builder.configuration is DevelopmentEnvironmentConfiguration)
    }

    // 2
    func test_givenAFactory_whenTheEnvironmentIsStaging_thenURLBuilderShouldHoldTheCorrectConfiguration() {
        let builder = sut.createBuilder(for: .staging)

        XCTAssertTrue(builder.configuration is StagingEnvironmentConfiguration)
    }

    // 3
    func test_givenAFactory_whenTheEnvironmentIsProduction_thenURLBuilderShouldHoldTheCorrectConfiguration() {
        let builder = sut.createBuilder(for: .production)

        XCTAssertTrue(builder.configuration is ProductionEnvironmentConfiguration)
    }
}
  1. Tests that passing .development to the factory produces a builder holding a DevelopmentEnvironmentConfiguration. The assertion uses is to check the concrete type rather than comparing individual property values - this verifies the correct configuration was selected without duplicating every value from the configuration in the test.
  2. Tests the same mapping for .staging, confirming the factory routes to StagingEnvironmentConfiguration.
  3. Tests the same mapping for .production, confirming the factory routes to ProductionEnvironmentConfiguration.

Together, these three tests exhaustively cover the switch in createBuilder(for:) - every Environment case is accounted for.

Before we can write tests for UserRequestFactory, we need to make a test-double for URLRequestBuilderFactory, which means wrapping the current implementation in a protocol:

// 1
protocol URLRequestBuildingFactory {
    func createBuilder(for environment: Environment) -> URLRequestBuilder
}

// 2
extension URLRequestBuildingFactory {
    func createBuilder() -> URLRequestBuilder {
        createBuilder(for: .current)
    }
}

// 3
struct URLRequestBuilderFactory: URLRequestBuildingFactory {
  // Omitted unchanged functionality
}
  1. URLRequestBuildingFactory abstracts the creation of builders behind a protocol. In production code, the factory returns a builder seeded with the real environment configuration, while in tests, we can substitute a factory that returns a builder seeded with a StubConfiguration instance.
  2. The protocol extension adds a convenience overload that defaults to Environment.current. This is the version that production code calls - no need to specify the environment because the Active Compilation Conditions have already determined it. Having this as an extension rather than a default parameter on the protocol requirement means conformers only need to implement one method while callers get both.
  3. URLRequestBuilderFactory now conforms to URLRequestBuildingFactory.

With URLRequestBuildingFactory, we can now write out a test-double:

// 1
final class StubURLRequestBuildingFactory: URLRequestBuildingFactory {
    // 2
    var urlBuilderToReturn: URLRequestBuilder!

    // 3
    func createBuilder(for environment: Environment) -> URLRequestBuilder {
        return urlBuilderToReturn
    }
}
  1. StubURLRequestBuildingFactory is a test-double that conforms to URLRequestBuildingFactory. It lets us control exactly which builder the request factories receive, decoupling the factory tests from any real environment configuration.
  2. urlBuilderToReturn is set by the test before exercising the system under test. This gives the test full control over what createBuilder() returns.
  3. createBuilder() simply hands back whatever builder the test provided. No environment switching, no configuration logic - just a direct pass-through that keeps the test focused on the request factory's behaviour.

And update UserRequestFactory to take an instance of a type conforming to URLRequestBuildingFactory, which will allow for StubURLRequestBuildingFactory to be passed in during testing:

struct UserRequestFactory {
    private let urlRequestBuilderFactory: URLRequestBuildingFactory

    // MARK: - Init

    init(urlRequestBuilderFactory: URLRequestBuildingFactory = URLRequestBuilderFactory()) {
        self.urlRequestBuilderFactory = urlRequestBuilderFactory
    }

    // Omitted unchanged functionality
}

We can now write the tests for UserRequestFactory:

class UserRequestFactoryTests: XCTestCase {
    var sut: UserRequestFactory!

    // MARK: - Lifecycle

    override func setUp() {
        super.setUp()

        // 1
        let urlRequestBuilderFactory = StubURLRequestBuildingFactory()
        urlRequestBuilderFactory.urlBuilderToReturn = URLRequestBuilder(configuration: StubConfiguration())
        sut = UserRequestFactory(urlRequestBuilderFactory: urlRequestBuilderFactory)
    }

    override func tearDown() {
        sut = nil

        super.tearDown()
    }

    // 2
    func test_givenRequestFactory_whenCreateUserGETRequestIsCalled_thenURLRequestIsCorrectlyBuilt() throws {
        let request = try sut.createUserGETRequest()

        XCTAssertEqual(request.url?.path, "/v3/user")
        XCTAssertEqual(request.httpMethod, "GET")
    }

    // 3
    func test_givenRequestFactory_whenCreateUserPOSTRequestIsCalled_thenURLRequestIsCorrectlyBuilt() throws {
        let user = User(name: "test_name")

        let request = try sut.createUserPOSTRequest(user: user)

        XCTAssertEqual(request.url?.path, "/v3/user")
        XCTAssertEqual(request.httpMethod, "POST")
        XCTAssertEqual(request.httpBody, try JSONEncoder().encode(user))
    }

    // 4
    func test_givenRequestFactory_whenCreateUserFeedGetRequestIsCalled_withAscendingTrue_thenURLRequestIsCorrectlyBuilt() throws {
        let request = try sut.createUserFeedGETRequest(userID: "123", ascending: true)

        XCTAssertEqual(request.url?.path, "/v3/user/123/feed")
        XCTAssertEqual(request.httpMethod, "GET")
        XCTAssertTrue(request.url?.query?.contains("order=ascending") ?? false)
    }

    // 5
    func test_givenRequestFactory_whenCreateUserFeedGetRequestIsCalled_withAscendingFalse_thenURLRequestIsCorrectlyBuilt() throws {
        let request = try sut.createUserFeedGETRequest(userID: "456", ascending: false)

        XCTAssertEqual(request.url?.path, "/v3/user/456/feed")
        XCTAssertEqual(request.httpMethod, "GET")
        XCTAssertTrue(request.url?.query?.contains("order=descending") ?? false)
    }
}
  1. The stub factory is configured to return a real URLRequestBuilder seeded with a StubConfiguration. We use the real builder here rather than a test-double because we want the factory tests to verify the actual URLRequest that comes out the other end - the same thing the server will receive. The only thing we're faking is which configuration the builder starts with, swapping in StubConfiguration so the tests aren't coupled to any real environment's host or headers.
  2. Tests that createUserGETRequest() produces a URLRequest with the correct path and httpMethod values. The assertions check the output directly - there's no spying on which builder methods were called, just verification that the finished request is what we expect.
  3. Tests that createUserPOSTRequest(user:) produces a URLRequest with the correct path, httpMethod, and an encoded body. The expected body data is independently encoded with JSONEncoder, so the assertion confirms that the factory and builder together produce the right result.
  4. Tests that createUserFeedGETRequest(userID:ascending:) produces a URLRequest with the correct path, httpMethod and queryItems for ascending being true.
  5. Tests that createUserFeedGETRequest(userID:ascending:) produces a URLRequest with the correct path, httpMethod and queryItems for ascending being false.

We won't write tests for Environment as there isn't a straightforward way to switch individual Active Compilation Conditions on and off during testing.

And with those final tests, we are done ⭐.

Looking Back

With URL construction, it's easy to fall into either extreme: being too flexible or too rigid. With the builder pattern, we've managed to thread the needle between those two extremes - the environment configuration provides the rigidity, while the chaining methods provide the flexibility. This does come at the cost of more types than a simple URLRequest extension would need, but each new endpoint only has to specify what makes it unique. The shared defaults are already in place, and changing them means editing one configuration rather than hunting through every request in the project.

To see the complete working example, visit the repository and clone the project.

What do you think? Let me know by getting in touch on Mastodon or Bluesky.