Faking network responses in tests
Modern applications often rely on data from a network connection to work as intended. Sometimes they rely heavily on the network and are almost worthless without an internet connection while other apps can function mostly fine without a network connection. What these apps have in common is that they contain code that might be challenging to write tests for.
Whenever you write unit tests, you should always strive to make your tests as predictable, reproducible and most importantly independent of external factors as possible. This is a huge difference compared to integration testing where you’d test a certain part of a system within its context. An internet connection is quite possibly one of the most unpredictable factors that you do not want to introduce in a unit test suite.
Why is the network so unpredictable you ask? Reasons include but are not limited to:
- The network connection might suddenly drop, this is outside of your control.
- You might not control the server, changes, bugs or outages on the server would impact your tests.
- Network speeds might vary depending on several factors, this could result in unjust test failures.
There are several things you can do to remove the network as a dependency from your tests. In this post, I will show you how you can build a dedicated networking layer that is abstracted using protocols. This allows you to swap out the networking implementation when running tests, which helps you avoid going to the network altogether. A setup like this allows you to test logic completely independent from what might be happening on a server. Let’s say, for example, that you’re building a reward system. Whenever a user is eligible for a reward, they tap a button. Your app fires off a request to a web server that returns a response to your app that contains between 0 and 2 rewards. There are four reward types:
- Jackpot, you win a free ticket for an event.
- Normal reward, you win a 50% ticket discount for an event.
- Small reward, you win a 25% ticket discount for an event.
- Loyalty points, you win between 50 and 100 loyalty points.
If a user wins the jackpot they should not receive any other prices. A user cannot receive the Small and Normal reward at once. A user can, however, get the Normal or Small reward together with loyalty points.
If a user wins the Jackpot, they should be shown a special Jackpot animation. If a user wins the Normal or Small award, they should be taken to a shopping page where they can use their discount if they desire to do so. If they win loyalty points and a reward, they should see a small banner at the top of the screen and they should also be taken to the shopping page. If a user only wins loyalty points, they should only be shown a banner that informs them about their loyalty points.
These are a lot of different paths that might have to be taken in the app, and testing this with a network connection on a real server isn’t feasible. The selection process is random after all! Complex flows like the one described above are another reason that makes faking network responses so convenient. Let’s get started with implementing this complex feature!
Designing the basics
Since this is a complex feature, we’re going to pick and choose a couple of elements from different common app architectures to use as building blocks. As described in the introduction, we’re building a feature where a user is on a certain screen and depending on the result of a network call, they will be taken to a new screen, and potentially they will see an overlay banner. This sounds like a great job for a ViewModel, and we’ll want to extract the networking logic into an object that the ViewModel can use to make network calls.
In this blog post, we will focus on tests for the networking layer.
Preparing the fake responses
When you’re dealing with responses from a server, you often have some kind of documentation or example response to work from. In this case, we have an example JSON response that contains all the information needed to derive every response we can expect from the server:
{
"rewards": [
{
"type": "jackpot" // jackpot, regular or small
}
],
"loyalty_points": 60 // value between 50 and 100 or null
}
This relatively simple document contains all the information we need right now. It’s tempting to create a bunch of JSON files with different compositions of valid responses and use those while testing. Not a bad approach but we can do better. Since we have the Codable
protocol in Swift, we can easily convert JSON data into model objects, but this also works the other way around! We can define our model in the main project, make it Codable
and then spawn instances of the model in our test suite, convert them to JSON data and use those as inputs for the networking client. Here’s the model definition we’re using:
struct RewardsResponse: Codable {
let rewards: [Reward]
let loyaltyPoints: Int?
}
struct Reward: Codable {
enum RewardType: String, Codable {
case jackpot, regular, small
}
let type: RewardType
}
This simple model should be sufficient to hold the example JSON data. Let’s add a new file to the test target too, it should be a new Swift file named RewardsResponseFactory
. This factory is going to help you create RewardsResponse
objects and it will contain a convenient extension on RewardsResponse
so you can easily convert the response object to JSON using a JSONEncoder
. Add the following code to RewardsResponseFactory.swift
:
@testable import RewardsApp
class RewardsResponseFactory {
static func createResponseWithRewards(_ types: [Reward.RewardType], points: Int?) -> RewardsResponse {
let rewards = types.map { type in Reward(type: type) }
return RewardsResponse(rewards: rewards, loyaltyPoints: points)
}
}
extension RewardsResponse {
var dataValue: Data {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
return try! encoder.encode(self)
}
}
You know have everything in place to start writing some tests and implementing the complex reward flow!
Writing your tests and implementation code
Like good practitioners of TDD, we’ll begin implementing our feature in tests first. Once we have a couple of tests outlined we can go ahead and begin implementing the code for our feature.
In Xcode’s Project Navigator, find the test target and rename the default test file and class to RewardsServiceTests
. Remove the default code from Xcode’s test template. You should now have the following contents in RewardsServiceTests
:
import XCTest
@testable import RewardsApp
class RewardsServiceTests: XCTestCase {
}
We’ll begin by adding tests for the API itself, we want to make sure that the API makes the correct requests and that it converts the JSON data that it receives to RewardResponse
objects which are then passed back to the caller of the RewardsService
’s fetchRewards
method. Quite the mouthful but what we’re testing here is whether the RewardsService
can make and handle requests. Since we can’t use the internet in our test, we’ll need to abstract the networking behind a protocol called Networking
, add a new file called Networking.swift
to your application target and add the following contents to it:
protocol Networking {
func fetch(_ url: URL, completion: @escaping (Data?, URLResponse?, Error?) -> Void)
}
This protocol will allow us to use a fake networking object in our tests and a URLSession
in the app. You can use the following extension on URLSession
to make it usable as a Networking
object in your application:
extension URLSession: Networking {
func fetch(_ url: URL, completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
self.dataTask(with: url) { data, response, error in
completion(data, response, error)
}.resume()
}
}
To write your test, create a new file in your test target and name it MockNetworking.swift
. This file will hold the code used to fake networking responses. Don’t forget to add an @testable
import statement for the app target and add the following implementation to this file:
class MockNetworking: Networking {
var responseData: Data?
var error: Error?
func fetch(_ url: URL, completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
completion(responseData, nil , error)
}
}
Simple as it might look, this mock networking object is really powerful. You can assign the response data and error, and the networking object will simply invoke the fetch completion handler callback with your predefined response. Users of the mock networking object won’t notice the difference between going to the network or receiving this predefined response which is exactly what we want because now you can use the mock network instead of a URLSession
without any changes to your RewardsService
. Let’s write some tests for the rewards service and then implement the service itself. Add the following code to the RewardsServiceTests
file.
class RewardsServiceTests: XCTestCase {
func testRewardServiceCanReceiveEmptyRewardsWithNoPoints() {
let expect = expectation(description: "Expected test to complete")
let response = RewardsResponseFactory.createResponseWithRewards([], points: nil)
let mockNetwork = MockNetworking()
mockNetwork.responseData = response.dataValue
let service = RewardsService(network: mockNetwork)
service.fetchRewards { result in
guard case .success(let response) = result else {
XCTFail("Expected the rewards to be fetched")
return
}
XCTAssertTrue(response.rewards.isEmpty)
XCTAssertNil(response.loyaltyPoints)
expect.fulfill()
}
waitForExpectations(timeout: 1, handler: nil)
}
}
This code uses the factory you created earlier to generate a response object. The extension on RewardsResponse
that you added to the test target is used to convert the response into JSON data and you assign this data to the mock network. Next, the rewards service is initialized with the mock network and we call its fetchRewards
method with a completion closure that fulfills the test expectation and checks whether the rewards response has the expected values.
Tip:
If you’re not quite sure what a test expectation is, check out Getting Started With Unit Testing - part 2. This blog post covers the basics of testing asynchronous code, including test expectations.
Your tests don’t compile yet because you haven’t implemented the RewardsService
yet. Add a new Swift file called RewardsService.swift
to your application target and add the following implementation to this file:
struct RewardsService {
let network: Networking
func fetchRewards(_ completion: @escaping (Result<RewardsResponse, Error>) -> Void) {
network.fetch(URL(string: "https://reward-app.com/rewards")!) { data, urlResponse, error in
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
guard let data = data,
let response = try? decoder.decode(RewardsResponse.self, from: data) else {
// You should call the completion handler with a .failure result and an error
return
}
completion(.success(response))
}
}
}
This is a pretty minimal implementation and that’s fine. Since we’re separating concerns in this little feature as much as possible it isn’t surprising that every single bit of implementation code we’ve written so far was fairly small; every bit of out code only does a single thing, making it easy to use in a test. Go ahead and run your test now, it should succeed!
Try adding a couple more test cases for using the following calls to the RewardsResponseFactory
:
let response = RewardsResponseFactory.createResponseWithRewards([.jackpot], points: nil)
let response = RewardsResponseFactory.createResponseWithRewards([.regular], points: 50)
let response = RewardsResponseFactory.createResponseWithRewards([], points: 100)
let response = RewardsResponseFactory.createResponseWithRewards([.small, .regular], points: 70)
let response = RewardsResponseFactory.createResponseWithRewards([.small, .regular], points: nil)
There are many more possible combinations for you to test, go ahead and add as many as you want. Don’t forget to keep your code DRY and use XCTest's setUp
method for shared configuration to avoid lots of boilerplate code in every test.
Next steps
If you have implemented the tests I suggested, you should currently have a pretty solid test suite set up for the RewardsService
. Your suite tests whether the service can decode many different shapes of responses. If you got stuck implementing these tests, fear not. You can go to this blog post’s accompanying Github Repository and look at the ServiceTests
folder to see the results.
But the feature I described at the start of this post isn’t complete yet! Try to add some tests for a RewardsViewModel
, maybe add some tests to see whether your coordinator can determine the correct next view controller based on the response from the RewardsService
. In the Completed
folder in this post’s Github Repository, you’ll find an example of a completed test suite that covers the entire rewards feature!
In summary
In this post, you have learned a lot about testing code that involves using data that comes from a remote source. You saw how you can hide networking logic behind a protocol, allowing you to mock the object that’s responsible for ultimately making the network call.
I have also shown you how you can leverage extensions and Swift’s Codable
protocol to generate response data based on the objects you ultimately want to decode. Of course, there are many other ways to obtain such mock data, for example by adding prepared JSON files to your test target, or possibly running a local web server that your tests can use instead of a remote server. These are all valid ways to test networking logic, but I use and prefer the method described in this post myself.
Thank you for reading this post! And as always, feedback, compliments, and questions are welcome. You can find me on Twitter if you want to reach out to me.