To make our code more testable we'll create a special helper that can load questions for our view controller. The helper will go to the network and fetch the questions. Once the data is loaded, a callback is called to notify the object that initiated the request about the loaded questions. Because we already know that we're going to be testing this code, we'll need to think of a way to make sure that the helper works with both an offline and an online implementation. We wouldn't want the test to fail due to a broken internet connection.
Because tests should rely on as few outside factors as possible, removing the networking layer from this test would be great. This means that we're going to need to split our helper up in two parts; the actual helper and a fetcher. The fetcher will implement a protocol that defines the interface that a question fetcher must have, which will allow us to inject either an online or offline fetcher into the helper.
If the preceding seems a little bit abstract and confusing to you, that's OK. We're going to slowly work our way to the nicely abstracted, testable implementation that we want to end up with. First, let's create the helper struct. Add a new Swift file named QuestionsLoader.swift and add the following implementation to it:
struct QuestionsLoader { typealias QuestionsLoadedCallback = (JSON) -> Void func loadQuestions(callback: @escaping QuestionsLoadedCallback) { guard let url = URL(string: "http://questions.movietrivia.json") else { return } URLSession.shared.dataTask(with: url) { data, response, error in guard let data = data, let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []), let json = jsonObject as? JSON else { return } callback(json) } } }
This struct defines a method to load questions with a callback. This is already nice and a lot more testable than before. We can now isolate the question loader and test it separated from the rest of our app. A test for the helper in its current state would look like the test shown in the following code snippet:
func testLoadQuestions() { let questionsLoader = QuestionsLoader() let questionsLoadedExpectation = expectation(description: "Expected the questions to be loaded") questionsLoader.loadQuestions { _ in questionsLoadedExpectation.fulfill() } waitForExpectations(timeout: 5, handler: nil) }
First, an instance of the QuestionsLoader is created. Next, we create an expectation. An expectation is used when you expect something to eventually happen. In our case, we're loading the trivia questions asynchronously. This means that we can't expect the questions to be loaded by the time our test method is finished executing. The callback that's called when the questions are loaded has a single purpose in our tests: It must fulfill the expectation. In order to make sure that the test waits for us to fulfill the expectation, waitForExpectations(timeout:handler:) is called after loadQuestions(callback:). If we don't fulfill the expectation within the five-second timeout that is specified, the test fails.
Examine this test closely; you should be able to see all of the A's that you read about earlier. The first A, arrange, is where we create the loader and set up the expectation. The second A, act, is when we call loadQuestions(callback:) and make sure that our tests wait for any unfulfilled expectations. The final A, assert, is inside the callback. We're currently not validating anything inside the callback, we'll get to that later.
One issue that we still have with the loader is the reliance on an active connection to the internet. We also assume that the server we're calling is up and that the data it returns is valid. These are all assumptions that influence the reliability of our test. If we don't have an internet connection or if the server is down, our test fails even though our code might be fine. That is not an ideal situation to find yourself in. Tests should be able to run without relying on any external factors.
We can improve this by utilizing some protocol-oriented programming and the dependency injection pattern. This means that we should define a protocol that defines the public API for a networking layer. Then we'll implement a networking struct in the main project that conforms to the protocol and we'll create a property on the QuestionsLoader that holds anything that implements the networking logic. We'll also add a struct in our test target that implements the networking protocol.
By setting the test up like this, we can take the entire networking logic out of the equation and arrange our test in such a way that the networking doesn't matter. Our mock networking layer will respond with valid, reliable responses that we can test.