原文地址git
在本文中,咱們將討論測試101的開始:依賴注入。 假設您正在編寫測試。github
若是您的測試目標(SUT,系統測試)在某種程度上與現實世界(如聯網和CoreData)相關,那麼編寫測試代碼就會更加複雜。基本上,咱們不但願咱們的測試代碼依賴於現實世界的東西。SUT不該依賴於其餘複雜系統,以便咱們可以更快地測試它,時不變和環境不變。此外,重要的是咱們的測試代碼不會「污染」生產環境。污染是什麼意思?這意味着咱們的測試代碼向數據庫寫入一些測試內容,向生產服務器提交一些測試數據,等等。這就是「依賴注入」存在的緣由。數據庫
讓咱們從一個例子開始。編程
給定一個應該在生產環境中經過internet執行的類。internet部分稱爲該類的「依賴項」。如上所述,當咱們運行測試時,該類的internet部分必須可以被一個模擬的或假的環境所替代。換句話說,這個類的依賴關係必須是「可注入的」。依賴注入使咱們的系統更加靈活。咱們能夠在生產代碼中「注入」真實的網絡環境。同時,咱們還能夠「注入」模擬網絡環境以在不訪問internet的狀況下運行測試代碼。swift
本文中,咱們將討論如下內容:api
咱們將實現一個類「HttpClient」,它應該知足如下要求bash
因此,咱們實現了 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上。
如今咱們關注咱們的第一個要求:
下面是咱們的測試用例:
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以進行斷言
第二個要求是:
咱們但願確保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上。這是一個遊樂場,我在那裏增長了一個測試。請隨意下載/派生它,並歡迎任何反饋!