[譯] 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

相關文章
相關標籤/搜索