The original address

In this article, we’ll discuss the beginning of Test 101: dependency injection. Suppose you are writing tests.

If your test goal (SUT, system testing) is in some way related to the real world (such as networking and CoreData), writing test code becomes more complex. Basically, we don’t want our test code to depend on something in the real world. SUT should not depend on other complex systems so that we can test it more quickly, time invariant and environment invariant. In addition, it is important that our test code does not “contaminate” the production environment. What does pollution mean? This means that our test code writes some test content to the database, submits some test data to the production server, and so on. This is why dependency injection exists.

Let’s start with an example.

Given a class that should be executed over the Internet in a production environment. The Internet section is called the “dependencies” of the class. As mentioned above, the Internet portion of the class must be able to be replaced by a mock or dummy environment when we run the test. In other words, the class’s dependencies must be injectable. Dependency injection makes our system more flexible. We can “inject” a real network environment into production code. At the same time, we can “inject” a simulated network environment to run the test code without accessing the Internet.

TL; DR

In this article, we will discuss the following:

  • How do I design an object using dependency injection
  • How do I design mock objects using protocols in Swift
  • How do I test the data an object uses and how do I test the behavior of an object

Dependency Injection (DI)

We will implement a class “HttpClient” that should satisfy the following requirements

  • HttpClient should submit the same request as the ASSIGNED URL.
  • HttpClient should submit requests.

So, we implement HttpClient:

class HttpClient { typealias completeClosure = ( _ data: Data? , _ error: Error?) ->Void func get( url: URL, callback: @escaping completeClosure ) {let request = URLRequest(url: url)
        request.httpMethod = "GET"
        let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
            callback(data, error)
        }
        task.resume()
    }
}
Copy the code

HttpClient seems to be able to submit a “GET” request and pass the return value through the closure “callback”.

HttpClient().get(url: url) { (success, response) in // Return data }
Copy the code

HttpClient:

The question is: How do we test it? How do we ensure that the code meets the requirements listed above? Intuitively, we can execute code, assign urls to HttpClient, and then observe the results in the console. However, doing so means that we must connect to the Internet every time we implement HttpClient. If the test URL is on the production server, the situation seems even worse: your test run does affect performance to some extent, and your test data will be submitted to the real world. As mentioned earlier, we must make HttpClient “testable”.

Let’s take a look at URLSession. URLSession is an “environment” of HttpClient, which is the gateway to the Internet. Remember when we said “testable” code? We have to make the components of the Internet replaceable. So we’ll edit HttpClient:

class HttpClient { typealias completeClosure = ( _ data: Data? , _ error: Error?) ->Void privatelet session: URLSessionProtocol
    init(session: URLSessionProtocol) {
        self.session = session
    }
    func get( url: URL, callback: @escaping completeClosure ) {
        let request = URLRequest(url: url)
        request.httpMethod = "GET"
        let task = session.dataTask(with: request) { (data, response, error) in
            callback(data, error)
        }
        task.resume()
    }
}
Copy the code

We replace the

let task = URLSession.shared.dataTask()
Copy the code

with

let task = session.dataTask()
Copy the code

Then we add a new variable :session, and a corresponding init. From now on, when we create HttpClient, we must assign sessions. That is, we must “inject” the session into any HttpClient object we create. Now we can run the production code using ‘URLSession. And run the test code using an injected mock session.

The use of HttpClient becomes:

HttpClient(session: SomeURLSession()).get(url: url){(data, response, error)in 
    // Return data
}
Copy the code

Writing test code for this HttpClient becomes very easy. So we set up the test environment:

class HttpClientTests: XCTestCase { 
    var httpClient: HttpClient! 
    let session = MockURLSession()
    override func setUp() {
        super.setUp()
        httpClient = HttpClient(session: session)
    }
    override func tearDown() {
        super.tearDown()
    }
}
Copy the code

This is a typical XCTestCase setup. The httpClient variable is the system under test (SUT), and the session variable is the environment we will inject into httpClient. Because we are running the code in a test environment, we assign the MockURLSession object to the session. We then inject the mock session into httpClient. It makes httpClient run on MockURLSession instead of urlsession.shared.

Test data

Now let’s focus on our first requirement:

  • HttpClient should submit the request with the same URL as the one assigned. We want to ensure that the REQUESTED URL is exactly the same as the ONE we assigned to the “get” method at the beginning.

Here is our test case:

func test_get_request_withURL() {
    guard let url = URL(string: "https://mockurl") else {
        fatalError("URL can't be empty")
    }
    httpClient.get(url: url) { (success, response) in
        // Return data
    }
    // Assert 
}
Copy the code

This test case can be expressed as:

  • Feed Additive: Given a URL “https://mockurl”
  • When: Submits an HTTP GET request
  • Assert: Submitted urls should equal “https://mockurl”

Next we need to write the assertion section.

So how do we know if HttpClient’s “get” method submits the correct URL? Let’s look at the dependency :URLSession. Typically, the “get” method creates a request with the given URL and assigns the request to URLSession to submit it:

let task = session.dataTask(with: request) { (data, response, error) in
    callback(data, error)
}
task.resume()
Copy the code

Now, in the test environment, the request is assigned to the MockURLSession. Therefore, we can hack into the MockURLSession we have to check that the request was created correctly.

This is an implementation of MockURLSession:

class MockURLSession { var responseData: Data? var responseHeader: HTTPURLResponse? var responseError: Error? // sessionDataTask = MockURLSessionDataTask()set) var lastURL: URL?
    func dataTask(with request: URLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTask {
        lastURL = request.url
        completionHandler(responseData, responseHeader, responseError)     
        return // dataTask, will be impletmented later
    }
}
Copy the code

MockURLSession acts like URLSession. Both URLSession and MockURLSession have the same method dataTask() and the same callback closure type. Although the dataTask() in URLSession performs more tasks than in MockURLSession, their interfaces look similar. Using the same interface, we were able to replace URLSession with MockURLSession without changing the code for the “get” method too much. We then create a variable lastURL to keep track of the final URL submitted in the “get” method. In short, when testing, we create an HttpClient, inject a MockURLSession into it, and then check to see if the URLS are the same before and after.

The test case will look like this:

func test_get_request_withURL() {
    guard let url = URL(string: "https://mockurl") else {
        fatalError("URL can't be empty")
    }
    httpClient.get(url: url) { (success, response) in
        // Return data
    }
    XCTAssert(session.lastURL == url)
}
Copy the code

We assert lastURL with URL to see if the “get” method correctly creates the request with the correct URL.

There is one more thing that needs to be implemented in the above code :return // dataTask. In URLSession, the return value must be URLSessionDataTask. However, URLSessionDataTask cannot be created programmatically, so this is an object to emulate:

class MockURLSessionDataTask {  
    func resume() {}}Copy the code

As with URLSessionDataTask, this mock has the same method resume(). Therefore, it can process this mock as the return value of dataTask().

Then, you will find some compilation errors in your code:

class HttpClientTests: XCTestCase {
    var httpClient: HttpClient!
    let session = MockURLSession()
    override func setUp() {
        super.setUp()
        httpClient = HttpClient(session: session) // Doesn't compile } override func tearDown() { super.tearDown() } }Copy the code

The MockURLSession interface is different from the URLSession interface. Therefore, when we try to inject a MockURLSession, the compiler will not recognize it. We must make the interface of the mock object the same as the real object. So, let’s introduce the “protocol”!

The HttpClient dependencies are: private let Session: URLSession

We want the session to be URLSession or MockURLSession. So we changed the type from URLSession to URLSessionProtocol: Private Let Session: URLSessionProtocol

Now we can inject URLSession or MockURLSession or any object that conforms to this protocol.

This is the implementation of the protocol:

protocol URLSessionProtocol { typealias DataTaskResult = (Data? , URLResponse? , Error?) -> Void func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol }Copy the code

In our test code, we only need one method :dataTask(NSURLRequest, DataTaskResult), so we define only one required method in the protocol. This technique is often used when we want to laugh at something we don’t have.

Remember MockURLDataTask? That’s another thing we don’t have, we’ll create another protocol.

protocol URLSessionDataTaskProtocol { func resume() }
Copy the code

We also have to make the real objects conform the protocols.

extension URLSession: URLSessionProtocol {}
extension URLSessionDataTask: URLSessionDataTaskProtocol {}
Copy the code

URLSessionDataTask has exactly the same protocol method resume(), so nothing happens to URLSessionDataTask.

The problem is that URLSession no dataTask () returns URLSessionDataTaskProtocol. Therefore, we need to extend a method to comply with the protocol.

extension URLSession: URLSessionProtocol {
    func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol {
        return dataTask(with: request, completionHandler: completionHandler) as URLSessionDataTask as URLSessionDataTaskProtocol
    }
}
Copy the code

This is a return type from converting URLSessionDataTask URLSessionDataTaskProtocol simple method. It doesn’t change the behavior of the dataTask() at all.

Now we can complete the missing part of the MockURLSession:

class MockURLSession: URLSessionProtocol {
    
    var responseData: Data?
    var responseHeader: HTTPURLResponse?
    var responseError: Error?
    var sessionDataTask = MockURLSessionDataTask()
    
    private(set) var lastURL: URL? /// The return value URLSessionDataTask cannot be created programmatically, so this is an object to emulate: func dataTask(with request: URLRequest, completionHandler: @escaping DataTaskHandler) -> URLSessionDataTaskProtocol { lastURL = request.url completionHandler(responseData, responseHeader, responseError)return sessionDataTask
    }
}

}
Copy the code

We know the // dataTask… could be a MockURLSessionDataTask:

class MockURLSession: URLSessionProtocol {
    var nextDataTask = MockURLSessionDataTask()
    private (set) var lastURL: URL?
    func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol {
        lastURL = request.url
        completionHandler(nextData, successHttpURLResponse(request: request), nextError)
        return nextDataTask
    }
}
Copy the code

This is a simulation, similar to URLSession in our test environment, that can save urls for assertion

Test Behavior

The second requirement is:

  • HttpClient should submit requests

We want to ensure that the “get” method in HttpClient submits the request as expected. Unlike the previous test, which tests the correctness of the data, this test asserts whether a method was called. In other words, we want to know whether to call URLSessionDataTask. Resume (). Let’s play the old game:

We create a new variable, resumeWasCall, to record whether the resume() method is called.

func test_get_resume_called() {
    let dataTask = MockURLSessionDataTask()
    session.nextDataTask = dataTask
    guard let url = URL(string: "https://mockurl") else {
        fatalError("URL can't be empty")
    }
    httpClient.get(url: url) { (success, response) in
        // Return data
    }
    XCTAssert(dataTask.resumeWasCalled)
}
Copy the code

The dataTask variable is a mock that belongs to us, so we can add an attribute to test the behavior of resume() :

class MockURLSessionDataTask: URLSessionDataTaskProtocol {
    private (set) var resumeWasCalled = false
    func resume() {
        resumeWasCalled = true}}Copy the code

If resume() is called, then resumeWasCalled becomes “true”) easy, right?

review

In this article, we learned how to adapt dependency injection to change the production/test environment. How to create simulations using protocols. How to test the correctness of passed values. How to assert the behavior of a function. In the beginning, you must spend a lot of time writing a simple test. Also, test code is code, so you still need to make it clean and well-structured. But the benefits of writing tests are priceless. You can only extend your code with proper testing, and testing can help you avoid minor bugs. So, let’s get started! The sample code is on GitHub. This is an amusement park, and I added a test there. Feel free to download/derive it and welcome any feedback!

Reference

  • Mocking Classes You Don’t Own
  • Dependency Injection
  • Test-Driven iOS Development with Swift