Mocking data requests that are triggered by either Alamofire or URLSession
is something we all run into when we start writing tests for our networking layer. When writing tests, it’s important that we don’t actually run the requests so we don’t mess up our backend database with dummy data. Also, this allows us to run our tests offline if needed and it takes away flakiness of tests.
Although many blog posts suggest using dependency injection for mocking tests, it was not what we ended up doing at WeTransfer. It’s definitely a great technique for writing tests in general but it also requires you to alternate code implementations for the sake of testing. Therefore, we decided to write a code solution that allows us to mock data requests using a URLProtocol and prevents us from touching the actual code implementation.
What is a URLProtocol and why should I use it for mocking?
In my blog post Printing data requests using a custom URLProtocol I’ve already explained in more detail how you can use a URLProtocol
. You can basically add a custom URLProtocol
to your URLSession
or Alamofire manager which will then be called for every outgoing request.
Apple describes the class as follows:
An abstract class that handles the loading of protocol-specific URL data.
The class allows us to handle the loading but also manipulate the loading of URL data. In other words, it allows us to alternate the response before it’s been called.
Introducing Mocker: a framework to simplify the mocking of requests
I could dive into writing a custom URLProtocol solution to make it possible to mock requests in this blog post. However, that would require quite some extra explanation of implementation details while we’ve already sorted that out for you in our Mocker framework.
At WeTransfer we’re using Mocker in all our private and public projects like, for example, GitBuddy. We’ve been developing it since 2017 and added a lot of features over the years to suit our needs. This makes it a robust framework with support for many cases including edge cases like redirects.
How does it work?
Before we dive into implementation details it’s good to understand how the Mocker framework works. In its core, it’s making use of the MockingURLProtocol which is responsible for catching requests and returning registered mocked data.
Mocks are registered inside a shared instance of the Mocker struct by instantiating a new Mock instance. All the interesting setup logic can be found in this Mock struct which includes all specific logic to match your request.
After registering the MockingURLProtocol
it’s no longer possible for a request to hit the server. Whenever a request is executed without a matching Mock, a print statement will be shown:
? No mocked data found for url Optional("https://api.example.com/items") method Optional("GET"). Did you forget to use `register()`? ?
This improves debugging while at the same time preventing you from forgetting to mock a certain test.
Mocking Alamofire data requests
Mocking Alamofire requests can be done by registering the MockingURLProtocol
in your manager. This is the only part in which we have to change our code implementation as we have to inject the URLProtocol for tests.
let configuration = URLSessionConfiguration.af.default
configuration.protocolClasses = [MockingURLProtocol.self] + (configuration.protocolClasses ?? [])
let sessionManager = Session(configuration: configuration)
After that, we can run our test to verify that our mocking URLProtocol
is correctly registered. For this example, imagine us having an API to get a user by using the https://api.example.com/user
endpoint. It results in a very simply JSON response of a user with a single name property:
struct User: Codable {
let name: String
}
We could write a test for this endpoint as follows:
/// It should correctly fetch and parse the user.
func testUserFetching() {
let configuration = URLSessionConfiguration.af.default
configuration.protocolClasses = [MockingURLProtocol.self] + (configuration.protocolClasses ?? [])
let sessionManager = Session(configuration: configuration)
let apiEndpoint = URL(string: "https://api.example.com/user")!
let expectedUser = User(name: "Antoine van der Lee")
let requestExpectation = expectation(description: "Request should finish")
sessionManager
.request(apiEndpoint)
.responseDecodable(of: User.self) { (response) in
XCTAssertNil(response.error)
XCTAssertEqual(response.value, expectedUser)
requestExpectation.fulfill()
}.resume()
wait(for: [requestExpectation], timeout: 10.0)
}
In this test we’ve done the following:
- We’ve registered the
MockingURLProtocol
with our Alamofire manager - We execute the request to fetch the user from our endpoint
- We validate in the response that the user is matching our expected outcome user with the name “Antoine van der Lee”
After running this test we can see that our MockingURLProtocol
is registered correctly as the following error is printed out in the console:
? No mocked data found for url Optional("https://api.example.com/user") method Optional("GET"). Did you forget to use `register()`? ?
It’s time to register a Mock for this request so our test succeeds.
Registering a Mock
To make our test succeed we need to create a Mock that’s returning a single user JSON response with a user that takes the name “Antoine van der Lee”. The great thing is: we can simply use our user instance that conforms to the Encodable
protocol!
We do this by creating a new Mock instance that’s initiated with the endpoint URL and our JSON data:
let mockedData = try! JSONEncoder().encode(expectedUser)
let mock = Mock(url: apiEndpoint, dataType: .json, statusCode: 200, data: [.get: mockedData])
mock.register()
Let’s go over the steps taken:
- Our
expectedUser
instance is converted to JSON data using the JSON encoder - A
Mock
instance is created using the JSON data and API endpoint - The data type is set to JSON and the expected status code is set to 200
As you can see, we are able to specify the response in detail. This allows you to write tests for image responses, 400 response codes, and more. All details on possibilities can be found in the Mocker readme.
Our test finally looks as follows:
/// It should correctly fetch and parse the user.
func testUserFetching() {
let configuration = URLSessionConfiguration.af.default
configuration.protocolClasses = [MockingURLProtocol.self] + (configuration.protocolClasses ?? [])
let sessionManager = Session(configuration: configuration)
let apiEndpoint = URL(string: "https://api.example.com/user")!
let expectedUser = User(name: "Antoine van der Lee")
let requestExpectation = expectation(description: "Request should finish")
let mockedData = try! JSONEncoder().encode(expectedUser)
let mock = Mock(url: apiEndpoint, dataType: .json, statusCode: 200, data: [.get: mockedData])
mock.register()
sessionManager
.request(apiEndpoint)
.responseDecodable(of: User.self) { (response) in
XCTAssertNil(response.error)
XCTAssertEqual(response.value, expectedUser)
requestExpectation.fulfill()
}.resume()
wait(for: [requestExpectation], timeout: 10.0)
}
With our Mock, our test succeeds and the registered JSON data is returned as the response, great!
Mocking URLSession requests
Mocking URLSession requests works almost the same as with Alamofire. We first have to register the MockingURLProtocol
with our URLSession
instance:
let configuration = URLSessionConfiguration.default
configuration.protocolClasses = [MockingURLProtocol.self] + (configuration.protocolClasses ?? [])
let sessionManager = URLSession(configuration: configuration)
After that, we can run our request and validate the outcome. For this, we’re using the exact same Mock setup:
let mockedData = try! JSONEncoder().encode(expectedUser)
let mock = Mock(url: apiEndpoint, dataType: .json, statusCode: 200, data: [.get: mockedData])
mock.register()
The final test looks as follows:
/// It should correctly fetch and parse the user.
func testUserFetchingURLSession() {
let configuration = URLSessionConfiguration.default
configuration.protocolClasses = [MockingURLProtocol.self] + (configuration.protocolClasses ?? [])
let sessionManager = URLSession(configuration: configuration)
let apiEndpoint = URL(string: "https://api.example.com/user")!
let expectedUser = User(name: "Antoine van der Lee")
let requestExpectation = expectation(description: "Request should finish")
let mockedData = try! JSONEncoder().encode(expectedUser)
let mock = Mock(url: apiEndpoint, dataType: .json, statusCode: 200, data: [.get: mockedData])
mock.register()
sessionManager.dataTask(with: apiEndpoint) { (data, response, error) in
defer { requestExpectation.fulfill() }
do {
if let error = error {
throw error
}
let user = try JSONDecoder().decode(User.self, from: data!)
XCTAssertEqual(user, expectedUser)
} catch {
XCTFail(error.localizedDescription)
}
}.resume()
wait(for: [requestExpectation], timeout: 10.0)
}
And that’s all it takes to mock your URLSession
requests in a unit test.
Mocking other common use-cases
Now that we know how to write a mock for both Alamofire and URLSession
we can look into a few common use-cases for writing mocks.
Verifying that a request has been called
In some cases, you just want to know whether a certain request is called. You can verify this by making use of the completion
callback on a Mock
:
let mockExpectation = expectation(description: "The content deletion mock should be called")
var mock = Mock(url: apiEndpoint, dataType: .json, statusCode: 204, data: [.delete: Data()])
mock.completion = {
mockExpectation.fulfill()
}
mock.register()
We’ve set the response code to be 204 No Content as expected by our implementation and we set the completion callback before we register the mock.
A final test implementation could look as follows:
/// It should call the delete API endpoint.
func testDeletingContent() {
let configuration = URLSessionConfiguration.default
configuration.protocolClasses = [MockingURLProtocol.self] + (configuration.protocolClasses ?? [])
let sessionManager = URLSession(configuration: configuration)
/// Setup the deleter with our URLSession that's containing the URLProtocol.
let deleter = ContentDeleter(sessionManager: sessionManager)
// Setup the API endpoint
let contentIdentifier = UUID().uuidString
let apiEndpoint = URL(string: "https://api.example.com/content?id=\(contentIdentifier)")!
// Register the mock
let mockExpectation = expectation(description: "The content deletion mock should be called")
var mock = Mock(url: apiEndpoint, dataType: .json, statusCode: 204, data: [.delete: Data()])
mock.completion = {
mockExpectation.fulfill()
}
mock.register()
let contentDeletionExpectation = expectation(description: "Completion should be called after deletion")
deleter.deleteContent(identifier: contentIdentifier) {
contentDeletionExpectation.fulfill()
}
/// Verify both expectations are met in the right order.
wait(for: [mockExpectation, contentDeletionExpectation], timeout: 10.0, enforceOrder: true)
}
Verifying POST body arguments
Your code is often setting up a request with a certain amount of arguments. It’s good to verify that your code is using the expected arguments when executing a request. For this, you can make use of the onRequest
property on a Mock
that gets called with the request and its post body arguments:
let mockExpectation = expectation(description: "The content post mock should be called")
var mock = Mock(url: apiEndpoint, dataType: .json, statusCode: 200, data: [.post: mockedData])
mock.onRequest = { request, httpBodyArguments in
/// Verify the correct base URL
XCTAssertEqual(request.url?.absoluteString, "https://api.example.com/board/\(boardIdentifier)")
/// Verify the POST arguments
XCTAssertEqual(httpBodyArguments as? [String: String], [
"identifier": contentIdentifier,
"name": expectedContentName
])
mockExpectation.fulfill()
}
mock.register()
Verifying GET arguments
The same we could do for the GET arguments by ignoring the post body arguments and simply extracting the GET arguments from the URLRequest
:
let mockExpectation = expectation(description: "The get content list mock should be called")
var mock = Mock(url: apiEndpoint, dataType: .json, statusCode: 200, data: [.get: mockedData])
mock.onRequest = { request, _ in
/// Verify the correct base URL
let urlWithoutQuery = request.url?.absoluteString.replacingOccurrences(of: request.url!.query!, with: "")
XCTAssertEqual(urlWithoutQuery, "https://api.example.com/board/\(boardIdentifier)?")
/// Verify the correct Query parameters
XCTAssertEqual(request.url?.query, "limit=20&offset=0")
mockExpectation.fulfill()
}
mock.register()
Conclusion
That’s it! We’ve covered the basics of mocking Alamofire and URLSession requests by making use of the Mocker framework. The framework has a lot more possibilities which you can find in the readme of the project. Hopefully, this post makes it easier for you to write and maintain mocked data requests.
If you like to improve your Swift knowledge, even more, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.
Thanks!