原文地址
在本文中,我们将讨论测试101的开始: 依赖注入 。 假设您正在编写测试。
如果您的测试目标(SUT,系统测试)在某种程度上与现实世界(如联网和CoreData)相关,那么编写测试代码就会更加复杂。基本上,我们不希望我们的测试代码依赖于现实世界的东西。SUT不应依赖于其他复杂系统,以便我们能够更快地测试它,时不变和环境不变。此外,重要的是我们的测试代码不会“污染”生产环境。污染是什么意思?这意味着我们的测试代码向数据库写入一些测试内容,向生产服务器提交一些测试数据,等等。这就是“依赖注入”存在的原因。
让我们从一个例子开始。
给定一个应该在生产环境中通过internet执行的类。internet部分称为该类的“依赖项”。如上所述,当我们运行测试时,该类的internet部分必须能够被一个模拟的或假的环境所替代。换句话说,这个类的依赖关系必须是“可注入的”。依赖注入使我们的系统更加灵活。我们可以在生产代码中“注入”真实的网络环境。同时,我们还可以“注入”模拟网络环境以在不访问internet的情况下运行测试代码。
本文中,我们将讨论以下内容:
我们将实现一个类“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上。
现在我们关注我们的第一个要求:
下面是我们的测试用例:
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 { 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.
第二个要求是:
我们希望确保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上。这是一个游乐场,我在那里增加了一个测试。请随意下载/派生它,并欢迎任何反馈!