[译] Swift - 网络单元测试

原文地址git

在本文中,咱们将讨论测试101的开始:依赖注入。 假设您正在编写测试。github

若是您的测试目标(SUT,系统测试)在某种程度上与现实世界(如联网和CoreData)相关,那么编写测试代码就会更加复杂。基本上,咱们不但愿咱们的测试代码依赖于现实世界的东西。SUT不该依赖于其余复杂系统,以便咱们可以更快地测试它,时不变和环境不变。此外,重要的是咱们的测试代码不会“污染”生产环境。污染是什么意思?这意味着咱们的测试代码向数据库写入一些测试内容,向生产服务器提交一些测试数据,等等。这就是“依赖注入”存在的缘由。数据库

让咱们从一个例子开始。编程

给定一个应该在生产环境中经过internet执行的类。internet部分称为该类的“依赖项”。如上所述,当咱们运行测试时,该类的internet部分必须可以被一个模拟的或假的环境所替代。换句话说,这个类的依赖关系必须是“可注入的”。依赖注入使咱们的系统更加灵活。咱们能够在生产代码中“注入”真实的网络环境。同时,咱们还能够“注入”模拟网络环境以在不访问internet的状况下运行测试代码。swift

TL;DR

本文中,咱们将讨论如下内容:api

  • 如何使用依赖注入技术来设计一个对象
  • 如何在Swift中使用协议设计模拟对象
  • 如何测试对象使用的数据、如何测试对象的行为

Dependency Injection (DI)(依赖注入)

咱们将实现一个类“HttpClient”,它应该知足如下要求bash

  • HttpClient应该提交与被分配的URL相同的请求。
  • HttpClient应该提交请求。

因此,咱们实现了 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()
    }
}
复制代码

HttpClient 彷佛能够提交一个“GET”请求,并经过闭包“回调”传递返回值。网络

HttpClient().get(url: url) { (success, response) in // Return data }
复制代码

HttpClient 的使用:session

问题是:咱们如何测试它?咱们如何确保代码知足上面列出的需求?直观地说,咱们能够执行代码,将URL分配给HttpClient,而后在控制台中观察结果。然而,这样作意味着咱们每次实现HttpClient时都必须链接到internet。若是测试URL位于生产服务器上,状况彷佛更糟:您的测试运行确实会在必定程度上影响性能,而且您的测试数据将提交给现实世界。如前所述,咱们必须使HttpClient“可测试”。

让咱们看一下URLSession。URLSession是HttpClient的一种“环境”,它是internet的网关。还记得咱们说的“可测试”代码吗?咱们必须使互联网的组成部分是可替换的。因此咱们编辑HttpClient:

class HttpClient {
    typealias completeClosure = ( _ data: Data?, _ error: Error?)->Void
    private let 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()
    }
}
复制代码

We replace the

let task = URLSession.shared.dataTask()
复制代码

with

let task = session.dataTask()
复制代码

而后咱们添加一个新的变量:session,添加一个相应的init。从如今开始,当咱们建立HttpClient时,咱们必须分配session。也就是说,咱们必须将会话“注入”到咱们建立的任何HttpClient对象。如今咱们可使用' URLSession运行生产代码了。并使用一个被注入的模拟会话运行测试代码。

HttpClient的使用变成了:

HttpClient(session: SomeURLSession()).get(url: url){(data,response, error) in 
    // Return data
}
复制代码

为这个HttpClient编写测试代码变得很是容易。因此咱们设置测试环境:

class HttpClientTests: XCTestCase { 
    var httpClient: HttpClient! 
    let session = MockURLSession()
    override func setUp() {
        super.setUp()
        httpClient = HttpClient(session: session)
    }
    override func tearDown() {
        super.tearDown()
    }
}
复制代码

这是一个典型的XCTestCase设置。变量httpClient是被测试系统(SUT),变量session是咱们将注入到httpClient的环境。由于咱们在测试环境中运行代码,因此咱们将MockURLSession对象分配给session。而后咱们将模拟会话注入到httpClient。它使httpClient运行在MockURLSession上,而不是URLSession.shared上。

Test data

如今咱们关注咱们的第一个要求:

  • HttpClient应该用与被分配的URL相同的URL提交请求。 咱们但愿确保请求的url与咱们在开始分配给“get”方法的url彻底相同。

下面是咱们的测试用例:

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 
}
复制代码

这个测试用例能够表示为:

接下来咱们须要编写断言部分。

那么咱们如何知道HttpClient的“get”方法是否提交正确的url呢?让咱们看一下依赖项:URLSession。一般,“get”方法使用给定的url建立一个请求,并将该请求分配给URLSession来提交该请求:

let task = session.dataTask(with: request) { (data, response, error) in
    callback(data, error)
}
task.resume()
复制代码

如今,在测试环境中,请求被分配给MockURLSession。所以,咱们能够侵入咱们拥有的MockURLSession,以检查请求是否被正确建立。

这是MockURLSession的实现:

class MockURLSession {
    var responseData: Data?
    var responseHeader: HTTPURLResponse?
    var responseError: Error?
    //var sessionDataTask = MockURLSessionDataTask() 待会实现
    
    private (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
    }
}
复制代码

MockURLSession的做用相似于URLSession。URLSession和MockURLSession都有相同的方法dataTask()和相同的回调闭包类型。尽管URLSession中的dataTask()执行的任务比MockURLSession多,但它们的接口看起来很类似。因为使用相同的接口,咱们可以用MockURLSession替代URLSession,而不须要更改太多“get”方法的代码。而后咱们建立一个变量lastURL,以跟踪在“get”方法中提交的最终url。简单地说,在测试时,咱们建立一个HttpClient,将MockURLSession注入其中,而后查看先后的url是否相同。

测试用例将长这样:

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)
}
复制代码

咱们断言带有url的lastURL,以查看“get”方法是否正确地用正确的url建立请求。

在上面的代码中,还有一件事须要实现:return // dataTask。在URLSession中,返回值必须是URLSessionDataTask。然而,URLSessionDataTask不能经过编程方式建立,所以,这是一个须要模拟的对象:

class MockURLSessionDataTask {  
    func resume() { }
}
复制代码

与URLSessionDataTask同样,这个mock具备相同的方法resume()。所以,它能够将这个mock处理为dataTask()的返回值。

而后,你会发现一些编译错误在你的代码:

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() } } 复制代码

MockURLSession的接口与URLSession的接口不一样。所以,当咱们尝试注入MockURLSession时,编译器将没法识别它。咱们必须使模拟对象的接口与真实对象相同。那么,让咱们来介绍一下“协议”!

HttpClient的依赖关系是: private let session: URLSession

咱们但愿会话是URLSession或MockURLSession。因此咱们把类型从URLSession改成协议URLSessionProtocol: private let session: URLSessionProtocol

如今咱们能够注入URLSession或MockURLSession或任何符合此协议的对象。

这是协议的实现:

protocol URLSessionProtocol { typealias DataTaskResult = (Data?, URLResponse?, Error?) -> Void
    func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol
}
复制代码

在咱们的测试代码中,咱们只须要一个方法:dataTask(NSURLRequest, DataTaskResult),所以咱们在协议中只定义了一个必需的方法。当咱们想要嘲笑咱们并不拥有的东西时,一般会采用这种技巧。

还记得MockURLDataTask吗?这是另外一个咱们不拥有的东西,咱们会建立另外一个协议。

protocol URLSessionDataTaskProtocol { func resume() }
复制代码

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

extension URLSession: URLSessionProtocol {}
extension URLSessionDataTask: URLSessionDataTaskProtocol {}
复制代码

URLSessionDataTask具备彻底相同的协议方法resume(),所以URLSessionDataTask不会发生任何事情。

问题是,URLSession没有dataTask()返回URLSessionDataTaskProtocol。所以,咱们须要扩展一个方法来遵照协议。

extension URLSession: URLSessionProtocol {
    func dataTask(with request: NSURLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol {
        return dataTask(with: request, completionHandler: completionHandler) as URLSessionDataTask as URLSessionDataTaskProtocol
    }
}
复制代码

这是一个将返回类型从URLSessionDataTask转换为URLSessionDataTaskProtocol的简单方法。它根本不会改变dataTask()的行为。

如今咱们能够完成MockURLSession中缺失的部分了:

class MockURLSession: URLSessionProtocol {
    
    var responseData: Data?
    var responseHeader: HTTPURLResponse?
    var responseError: Error?
    var sessionDataTask = MockURLSessionDataTask()
    
    private(set) var lastURL: URL?
    
    /// 返回值 URLSessionDataTask 不能经过编程方式建立,所以,这是一个须要模拟的对象:
    func dataTask(with request: URLRequest, completionHandler: @escaping DataTaskHandler) -> URLSessionDataTaskProtocol {
        lastURL = request.url
        completionHandler(responseData, responseHeader, responseError)
        return sessionDataTask
    }
}

}
复制代码

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
    }
}
复制代码

这是一个模拟,在咱们的测试环境中相似于URLSession,能够保存url以进行断言

Test Behavior

第二个要求是:

  • HttpClient应该提交请求

咱们但愿确保HttpClient中的“get”方法如期提交请求。 与前一个测试测试数据的正确性不一样,此测试断言是否调用了方法。换句话说,咱们想知道是否调用了URLSessionDataTask.resume()。让咱们玩老把戏:

咱们建立一个新变量 resumewascall 来记录resume()方法是否被调用。

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)
}
复制代码

变量dataTask是一个mock,它属于咱们本身,因此咱们能够添加一个属性来测试resume()的行为:

class MockURLSessionDataTask: URLSessionDataTaskProtocol {
    private (set) var resumeWasCalled = false
    func resume() {
        resumeWasCalled = true
    }
}
复制代码

若是resume()被调用,那么resumeWasCalled就会变成“true”)很简单,对吧?

回顾

在本文中,咱们了解到: 如何适应依赖注入以改变生产/测试环境。 如何利用协议建立模拟。 如何测试传递值的正确性。 如何断言某个函数的行为。 在开始时,您必须花费大量时间编写一个简单的测试。并且,测试代码也是代码,因此您仍然须要使其清晰且结构良好。可是编写测试的好处是无价的。只有经过适当的测试才能扩展代码,而测试能够帮助您避免微小的错误。因此,让咱们开始吧! 示例代码在GitHub上。这是一个游乐场,我在那里增长了一个测试。请随意下载/派生它,并欢迎任何反馈!

Reference

相关文章
相关标签/搜索