• The complete Guide to Network Unit Testing in Swift
  • Original post by S.T.Huang
  • The Nuggets translation Project
  • Permanent link to this article: github.com/xitu/gold-m…
  • Translator: swants
  • Proofreader: PTHTC ZhiyuanSun

Admittedly, writing tests for iOS development are not very common (at least compared to back-end writing tests). I used to be an indie developer and had no native “test-driven” development training initially, so I spent a lot of time learning how to write test cases and how to write testable code. This is also my original intention to write this article. I want to share with you what I learned when writing tests with Swift. I hope my insights can help you save learning time and avoid some detdetments.

In this article, we’ll discuss a primer for starting to write tests: dependency injection.

Imagine you’re writing a test. If your test object (the system under test) is connected to the real world, such as Networking and CoreData, writing test code can be complicated. In principle, we don’t want our test code to be affected by things in the objective world. The system under test should not be dependent on other complex systems, so that we can ensure the rapid completion of the test under constant time and environment conditions. It is also important to ensure that our test code does not “contaminate” the production environment. What does “pollution” mean? This means that our test code writes some test objects to the database, submits some test data to the production server, and so on. Avoiding these situations is why DEPENDENCY injection exists.

Let’s start with an example. If you have a class that should be networked and executed in a production environment, the networked part is called a dependency of that class. As mentioned earlier, the networking portion of the class must be able to be replaced by a simulated, or fake, environment when we perform our tests. In other words, the class’s dependencies must support injectable dependency injection, which makes our system more flexible. We can “inject” production code into a real web environment; At the same time, it was possible to “inject” a simulated network environment that allowed us to run the test code without accessing the Internet.

TL; DR

Translator’s note: TL; DR is Too long. Short for Don’t read. Here the meaning is long, do not want to in-depth study, please directly read the summary of the article.

In this article, we will discuss:

  1. How do I design an object using dependency injection
  2. How to design a mock object using protocol in Swift
  3. How do I test the data an object uses and how do I test the behavior of an object

Dependency injection

Get started! Now we are going to implement a class called HttpClient. The HttpClient should meet the following requirements:

  1. The HttpClient should submit the same request for the same URL as the original web component.
  2. HttpClient should be able to submit requests.

So our first implementation of HttpClient looks like this:

class HttpClient { typealias completeClosure = ( _ data: Data? , _ error: Error?) ->Void func get( url: URL, callback: @escaping completeClosure ) {let request = NSMutableURLRequest(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 appears to be able to submit a “GET” request and return the value via a “callback” closure.

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

Usage of HttpClient.

That’s the question: how do we test it? How do we ensure that this code meets both of these requirements? Intuitively, we can pass HttpClient a URL, run the code, and observe the result in the closure. But these operations mean that we must connect to the Internet every time we run HttpClient. It’s even worse if the URL you’re testing is connected to a production server: your test will affect server performance to some extent, and the test data you submit will be submitted to the real world. As we described earlier, we must make HttpClient “testable”.

So let’s look at URLSession. URLSession is an ‘environment’ for HttpClient. It is the gateway through which HttpClient connects to the Internet. Remember when we talked about “testable” code? We needed to make the Internet part replaceable, so we changed the HttpClient implementation:

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

We will be

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

To modify a

let task = session.dataTask()
Copy the code

We added a new variable, session, and the corresponding init method. We must then initialize the session each time we create the HttpClient object. That is, we have “injected” the session into the HttpClient object we created. We can now inject ‘urlsession.shared’ when running production code and a mock session when running test code. Bingo!

HttpClient(session: SomeURLSession()). Get (url: url) {(success, response) in // Return data}

Writing test code to the HttpClient at this point becomes very simple. So we started laying out our 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 canonical XCTestCase setup. The httpClient variable is the system under test, and the session variable is the environment we will inject for httpClient. Since we’re running the code in the test environment, we pass the MockURLSession object to the Session. At this point we inject the mock session into httpClient, making httpClient run with URLSession. Shared replaced with MockURLSession.

The test data

Now let’s pay attention to the first requirement:

  1. The HttpClient and the original network component should submit the same request for the same URL.

What we want to achieve is to ensure that the REQUEST URL is exactly the same as the ONE we passed in to the “get” method.

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: Submit a http GET request
  • Assert: The submitted URL should be equal to “https://mockurl”

We also need to write the assertion section.

But how do we know that HttpClient’s “get” method actually submits the correct URL? Let’s look at dependencies again: URLSession. Normally, the “get” method creates a request from the url and passes the request to the URLSession to complete the submission:

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

Next, the request will be passed to the MockURLSession in the test environment, so we can just hack into our own MockURLSession to see if the request was created correctly.

Here is a rough implementation of MockURLSession:

class MockURLSession {
    private (set) var lastURL: URL?
    func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTask {
        lastURL = request.url
        completionHandler(nextData, successHttpURLResponse(request: request), nextError)        
        return // dataTask, will be impletmented later
    }
}
Copy the code

MockURLSession acts the same as URLSession, which has the same dataTask() method and the same callback closure type. Although URLSession does more work than MockURLSession’s dataTask(), their interfaces are similar. Because their interfaces are similar, we can replace URLSession with MockURLSession without modifying the “get” method too much code. Next we create a lastURL variable to keep track of the final URL submitted by the “get” method. To put it simply, when testing, we create an HttpClient that injects a MockURLSession and see if the URL is the same.

Here is a rough implementation of the 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
    }
    XCTAssert(session.lastURL == url)
}
Copy the code

We add assertions to lastURL and URL so we know if the injected “get” method correctly creates a request with the correct URL.

The above code still has one thing to implement: return // dataTask. In URLSession, the return value must be an URLSessionDataTask object, but the URLSessionDataTask object cannot be created properly, so the URLSessionDataTask object also needs to be created by simulation:

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

As URLSessionDataTask, mock objects need to have the same method resume(). This will treat the mock object as the return value of dataTask().

If you follow along, you’ll notice that your code will get an error from the compiler:

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

This is because MockURLSession and URLSession have different interfaces. So when we try to inject a MockURLSession we find that the MockURLSession is not recognized by the compiler. We had to make simulated objects have the same interface as real objects, so we introduced “protocols”!

HttpClient dependencies:

private let session: URLSession
Copy the code

We want both URLSession and MockURLSession to be used as session objects, so we change the session URLSession type to URLSessionProtocol:

private let session: URLSessionProtocol
Copy the code

So we can inject URLSession or MockURLSession or any other object that follows this protocol.

The following 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 the test code we only need one method: dataTask(NSURLRequest, DataTaskResult), so in the protocol we only need to define one method that must be implemented. This technique is often useful when we need to simulate objects that don’t belong to us.

Remember MockURLDataTask? Another object that doesn’t belong to us, yeah, we’re going to create another protocol.

protocol URLSessionDataTaskProtocol { func resume() }
Copy the code

We also need to make real objects follow this protocol.

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

URLSessionDataTask has the same resume() protocol method, so this change has no effect on URLSessionDataTask.

Problem is no dataTask URLSession () method to return the URLSessionDataTaskProtocol agreement, so we need to develop ways to follow the protocol.

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

From this simple method just returns type URLSessionDataTask changed URLSessionDataTaskProtocol, will not affect the dataTask other behavior ().

Now we can complete the MockURLSession missing parts:

class MockURLSession {
    private (set) var lastURL: URL?
    func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTask {
        lastURL = request.url
        completionHandler(nextData, successHttpURLResponse(request: request), nextError)        
        return // dataTask, will be impletmented later
    }
}
Copy the code

We already know // dataTask… Can 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

The mock object acts as a URLSession in the test environment, and the URL can be logged for assertion purposes. It’s like acorns grow from little acorns! All the code has been compiled and the tests passed without a hitch!

Let’s move on.

Test behavior

The second requirement is:

The HttpClient should submit the request

We expect HttpClient’s “get” method to submit the request as expected.

Unlike the previous tests to verify that the data is correct, we now test that the method is called without a problem. In other words, we want to know URLSessionDataTask. Resume () method is invoked. Let’s continue with the old trick: we create a new resumeWasCalled variable to record whether the resume() method is called.

Let’s write a simple test:

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 our own mock object, so we can add a property to monitor the behavior of the Resume () method:

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

If the resume() method is called, resumeWasCalled is set to true! ๐Ÿ™‚ easy, right?

conclusion

From this article, we learn:

  1. How to adjust dependency injection to change the production/test environment.
  2. How to create mock objects using protocols.
  3. How to check the correctness of the transmitted values.
  4. How to assert the behavior of a function.

When you’re starting out, you have to spend a lot of time writing simple tests, and the test code is code, so you still need to keep the test code simple and well structured. But the benefits of writing test cases are invaluable. Code can only be extended after proper testing, and testing helps you avoid trivial bugs. So let’s write the test together!

All of the sample code is on GitHub, in the form of Playground, which I’ve added an extra test. Feel free to download or fork this code, and welcome any feedback!

Thanks for reading my article at ๐Ÿ’š.

reference

  1. Mocking Classes You Donโ€™t Own
  2. Dependency Injection
  3. Test-Driven iOS Development with Swift

Lisa Dziuba and Ahmed Sulaiman.


The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. The content covers Android, iOS, front-end, back-end, blockchain, products, design, artificial intelligence and other fields. If you want to see more high-quality translation, please continue to pay attention to the Translation plan of Digging Gold, the official Weibo, Zhihu column.