Not Too Rigid, Not Too Loose - Building URLRequests Just Right
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.

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.
Headerscan 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.

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 theURLRequestinstance by configuring an instance ofURLRequestBuilderwith the endpoint-dependent values.URLRequestBuilder- takes environment-dependent and endpoint-dependent values, and merges them to create an instance ofURLRequest. Intended to be single-use, so a new builder is needed for eachURLRequestinstance.URLRequestBuilderFactory- createsURLRequestBuilderinstances.
I've used generic naming in the class diagram, but in the example, we will be working in concrete types, so
EnvironmentConfigurationwill becomeDevelopmentEnvironmentConfiguration, etc., andRequestFactorywill becomeUserRequestFactory, 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:
- Scheme.
- Host.
- Port.
- Cache Policy.
- 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
}()
}
urlComponentssets the scheme, host, and port that all development requests will use. Usinghttpandlocalhost:8080allows us to target a local server during development. By usingURLComponentsrather than a raw string like"http://localhost:8080", we get structured access to each part of the URL.URLComponentshandles 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.headersdefines the default headers that will be included with every request.Content-TypeandAcceptsignal that we're sending and expecting JSON, whileX-Environmentlets the server know this request is coming from a development build.cachePolicyis set to.reloadIgnoringLocalCacheDataso that responses are always fetched fresh during development, avoiding stale data while we're actively working on integrating a new endpoint.timeoutIntervalis 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
StagingEnvironmentConfigurationandProductionEnvironmentConfiguration.
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
}
}
- The initialiser takes an
EnvironmentConfigurationinstance 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
}
}
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. ReturnsSelfto allow chaining.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.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.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
}
}
HTTPMethodis a collection of common HTTP Methods. Using an enum avoids having to have a stringy API for setting thehttpMethodon theURLRequestinstance.path(_:)sets thepathon theURLComponentsinstance. This replaces whatever path the configuration'surlComponentshad, allowing each request to target a specific endpoint like/v3/userwhile inheriting the scheme and host from the configuration.method(_:)sets themethodfor the requesthttpMethodvalue. Takes anHTTPMethodenum value rather than a raw string, which prevents typos and limits the options to the methods we actually support.body(_:)sets thebodyvalue for the requesthttpBodyvalue. It isn't the responsibility ofURLRequestBuilderto do any encoding ofbody, that is the responsibility of theRequestFactory- this allows theURLRequestBuilderto be used with different body format types.queryItems(_:)sets thequeryItemson theURLComponentsinstance. If the array is empty, it setsqueryItemstonil, 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
}
}
URLRequestBuildingErrordefines the way that building a request can fail: the URL components are in an invalid state.hasBuiltenforces that each builder instance produces exactly oneURLRequest. ThepreconditionFailurecrashes immediately ifbuild()is called a second time, making misuse obvious during development rather than allowing stale state to silently leak between requests.components.urlcan returnnilif theURLComponentsare 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.- The
URLRequestis 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
}
}
Environmentrepresents 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.currentusesActive Compilation Conditionsto 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())
}
}
}
createBuilder()maps theEnvironment.currentcase to a particularURLRequestBuilderandEnvironmentConfigurationcombination.
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
}
}
- The initialiser has one parameter -
urlRequestBuilderFactory- that will be used to buildURLRequestBuilderinstances 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()
}
}
createUserGETRequest()creates theURLRequestfor aGETrequest to theuserendpoint. ItthrowsasURLRequestBuildercan throw.- A fresh
URLRequestBuilderis 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()
}
}
sut- short for "system under test" - holds the current instance ofDevelopmentEnvironmentConfigurationthat will be used in the tests.setUpis 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.tearDownis called after each test method finishes, nilling out the instance. This pairs withsetUpto 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)
}
}
- Tests the URL components by comparing against a
URLComponentsinstance built from a string. This verifies that the scheme, host, and port are all correct in a single assertion. - 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.
- 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.
- 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
StagingEnvironmentConfigurationandProductionEnvironmentConfiguration.
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
}
}
StubConfigurationconforms toEnvironmentConfiguration.- 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
}
}
}
}
- Tests that when no chaining methods are called, the resulting
URLRequestuses 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. - Tests that calling
path(_:)replaces the path from the configuration'surlComponentswhile preserving the scheme and host. The original path (if it existed) is gone - this isn't an append, it's a replacement. - Tests that adding a new header via
header(key:value:)merges it into the existing configuration headers without disturbing them. - 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. - Tests that
headers(_:)merges multiple new headers into the existing set in a single call, with all configuration headers preserved alongside the new ones. - Tests that
headers(_:)follows the same override rule asheader(key:value:)- when keys collide, the new values take precedence. - Tests that
cachePolicy(_:)overrides the configuration's default. - Tests that
timeoutInterval(_:)overrides the configuration's default, following the same pattern as the cache policy test. - Tests that
queryItems(_:)appends query parameters to the URL. The assertion checks the full URL string, which confirms thatURLComponentshandled the?separator and&joining correctly. - Tests that the value passed into
body(_:)is used as the request'shttpBody. - Tests the error path for an invalid URL. When
URLComponents.urlreturnsnildue to a malformed path, the builder throwsurlInvalidrather 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())
}
}
}
createBuildernow has one parameter that allows for theEnvironmentto 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)
}
}
- Tests that passing
.developmentto the factory produces a builder holding aDevelopmentEnvironmentConfiguration. The assertion usesisto 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. - Tests the same mapping for
.staging, confirming the factory routes toStagingEnvironmentConfiguration. - Tests the same mapping for
.production, confirming the factory routes toProductionEnvironmentConfiguration.
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
}
URLRequestBuildingFactoryabstracts 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 aStubConfigurationinstance.- 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. URLRequestBuilderFactorynow conforms toURLRequestBuildingFactory.
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
}
}
StubURLRequestBuildingFactoryis a test-double that conforms toURLRequestBuildingFactory. It lets us control exactly which builder the request factories receive, decoupling the factory tests from any real environment configuration.urlBuilderToReturnis set by the test before exercising the system under test. This gives the test full control over whatcreateBuilder()returns.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)
}
}
- The stub factory is configured to return a real
URLRequestBuilderseeded with aStubConfiguration. We use the real builder here rather than a test-double because we want the factory tests to verify the actualURLRequestthat 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 inStubConfigurationso the tests aren't coupled to any real environment's host or headers. - Tests that
createUserGETRequest()produces aURLRequestwith the correctpathandhttpMethodvalues. 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. - Tests that
createUserPOSTRequest(user:)produces aURLRequestwith the correctpath,httpMethod, and an encodedbody. The expected body data is independently encoded withJSONEncoder, so the assertion confirms that the factory and builder together produce the right result. - Tests that
createUserFeedGETRequest(userID:ascending:)produces aURLRequestwith the correctpath,httpMethodandqueryItemsforascendingbeing true. - Tests that
createUserFeedGETRequest(userID:ascending:)produces aURLRequestwith the correctpath,httpMethodandqueryItemsforascendingbeing false.
We won't write tests for
Environmentas there isn't a straightforward way to switch individualActive Compilation Conditionson 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.