转载

[译] Swift - 网络单元测试

原文地址

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

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

让我们从一个例子开始。

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

TL;DR

本文中,我们将讨论以下内容:

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

Dependency Injection (DI)(依赖注入)

我们将实现一个类“HttpClient”,它应该满足以下要求

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

所以,我们实现了 HttpClient:

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

HttpClient 似乎可以提交一个“GET”请求,并通过闭包“回调”传递返回值。

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

HttpClient 的使用:

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

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

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

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){(success,response) 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 
}
复制代码

这个测试用例可以表示为:

  • Precondition :给定一个url“ https://mockurl ”
  • When :提交一个http GET请求
  • Assert :提交的url应该等于“ https://mockurl ”

接下来我们需要编写断言部分。

那么我们如何知道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 {
    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
    }
}
复制代码

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

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

现在我们可以完成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
    }
}
复制代码

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以进行断言。摩天大楼已经建成了!所有的代码已经编译,测试已经通过! Let’s move on.

Test Behavior

第二个要求是:

  • HttpClient应该提交请求

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

我们创建一个新变量resumewascall来记录简历是否被调用。

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上。这是一个游乐场,我在那里增加了一个测试。请随意下载/派生它,并欢迎任何反馈!

原文  https://juejin.im/post/5efc5ed2f265da22b05adfd7
正文到此结束
Loading...