如何在Swift中模擬對象

對於任何一門編程語言,當你編寫單元測試時,模擬對象(Mock Object)都是一門關鍵的技術。 在模擬對象時,我們實際上是在創建它的一個“假”的版本,這個假的對象使用與真實對象相同的API,這讓我們更容易地在測試用例中進行斷言(Assert)和驗證結果。

無論我們是在測試網絡代碼,或則測試依賴於加速度計等硬件傳感器的代碼,還是測試使用位置服務等系統API的代碼,對象模擬都可以讓我們更輕鬆地編寫測試,並以更可可靠的方式,更快地運行這些測試。

但是,也存在可能不需要進行對象模擬的情況。例如有時候,在我們的測試中需要包含真實對象,以便讓我們編寫在實際條件下運行的測試。 現在,我們來看看模擬對象的幾種不同情況,什麼時候應該使用模擬?什麼時候應該避免它?來使我們的測試更容易編寫,讀取和運行。

爲什麼需要模擬對象(Mock Object)?

首先,讓我們來看一下一個實際的例子,假設我們正在構建一個NetworkManager,它允許我們從給定的URL加載數據:

class NetworkManager {
    func loadData(from url: URL,
                  completionHandler: @escaping (NetworkResult) -> Void) {
        let task = URLSession.shared.dataTask(with: url) { data, _, error in
            // 創建並返回Result枚舉對象值 .success 或者 .failure 
            let result = data.map(NetworkResult.success) ?? .failure(error)
            completionHandler(result)
        }

        task.resume()
    }
}

現在,我們要編寫測試來驗證在正確的情況下返回.success和.error。 要做到這一點,我們可以簡單地調用我們的loadData API並等待返回結果,但這兩者都要求我們的測試使用Internet連接運行,這樣測試運行起來會慢很多(因爲我們必須等待 要求執行真正的請求)。

現在,讓我們使用Mock來代替真實的API請求。 我們在這裏要做的是,讓NetworkManager在我們的測試代碼中使用假會話(Session),這個假會話不會通過網絡執行任何請求,而是讓我們準確地控制網絡的行爲方式。

局部模擬

對象模擬有兩種不同的風格 - 局部模擬完全模擬。 在進行局部模擬時,我們修改現有類型,以便在測試中僅部分表現不同,而在完全模擬時,您將替換整個實現。

如果我們想局部模擬它返回的URLSession和URLSessionDataTask,我們可以創建實際對象的子類,每個子類都覆蓋我們期望被調用的方法,以便返回我們可以在測試中控制的特定結果。 讓我們從創建一個模擬數據任務開始,該任務在回調時只運行一個閉包:

// 我們通過繼承原類來創建一個部分模擬的子類
class URLSessionDataTaskMock: URLSessionDataTask {
    private let closure: () -> Void

    init(closure: @escaping () -> Void) {
        self.closure = closure
    }

    // 重載‘resume’方法,直接調用回調closure
    override func resume() {
        closure()
    }
}

現在讓我們對URLSession做同樣的事情,但是這次我們將覆蓋dataTask方法以返回我們的模擬類的實例,如下所示:

class URLSessionMock: URLSession {
    typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void

    // 下面的屬性
    // 我們需要模擬的session對象返回的數據或錯誤對象
    var data: Data?
    var error: Error?

    override func dataTask(
        with url: URL,
        completionHandler: @escaping CompletionHandler
    ) -> URLSessionDataTask {
        let data = self.data
        let error = self.error

        return URLSessionDataTaskMock {
            completionHandler(data, nil, error)
        }
    }
}

Okay,我們的模擬對象準備就緒! 現在我們要向NetworkManager添加依賴注入,以便讓我們注入一個模擬的Session,來替代URLSession.shared:

class NetworkManager {
    private let session: URLSession

    // 使用默認參數(= .shared)可以避免修改我們主app的代碼
    init(session: URLSession = .shared) {
        self.session = session
    }

    func loadData(from url: URL,
                  completionHandler: @escaping (NetworkResult) -> Void) {
        let task = session.dataTask(with: url) { data, _, error in
            let result = data.map(NetworkResult.success) ?? .failure(error)
            completionHandler(result)
        }

        task.resume()
    }
}

最後,讓我們編寫第一個測試,驗證如果從網絡請求返回Data,則返回成功的結果:

class NetworkManagerTests: XCTestCase {
    func testSuccessfulResponse() {
        // 設置我們的mock對象
        let session = URLSessionMock()
        let manager = NetworkManager(session: session)

        // 創建返回數據,並賦值給模擬的session對象
        let data = Data(bytes: [0, 1, 0, 1])
        session.data = data

        // 創建一個URL
        let url = URL(fileURLWithPath: "url")

        // 執行請求並驗證結果
        var result: NetworkResult?
        manager.loadData(from: url) { result = $0 }
        XCTAssertEqual(result, .success(data))
    }
}

我們現在有一個測試,用於驗證我們的NetworkManager是否能夠成功響應?! 厲害了,但還有很大的改進空間。 使用局部模擬,就像我們上面所做的那樣,有時會很有用。但是,它有兩個主要缺點:

  1. 它要求我們編寫相當多的Mock代碼,因爲我們需要主動覆蓋我們期望被調用的所有代碼路徑。
  2. 我們只是部分修改對象。 這意味着我們正在做一些相當困難的假設,假設我們瞭解關於對象如何在內部工作的,以及我們如何在我們自己的代碼中使用它們。如果我們正在Mock的對象發生了變化 - 特別是當涉及像URLSession這樣的系統類時, 這些假設很快就會導致不穩定的測試和誤報。

完全模擬(Complete Mocking)

讓我們改爲使用完全模擬,這意味着我們將用完全模擬的實現,替換整個URLSession類。 要做到這一點,我們不能像創建部分模擬時那樣對URLSession進行子類化,而是將我們需要的API抽象爲協議:

protocol NetworkSession {
    func loadData(from url: URL,
                  completionHandler: @escaping (Data?, Error?) -> Void)
}

然後我們通過使用extension來使URLSession遵循協議接口:

extension URLSession: NetworkSession {
    func loadData(from url: URL,
                  completionHandler: @escaping (Data?, Error?) -> Void) {
        let task = dataTask(with: url) { (data, _, error) in
            completionHandler(data, error)
        }

        task.resume()
    }
}

最後,我們將使NetworkManager在其初始化程序中接受符合NetworkSession的對象,而不是URLSession實例:

class NetworkManager {
    private let session: NetworkSession

    init(session: NetworkSession = URLSession.shared) {
        self.session = session
    }

    func loadData(from url: URL,
                  completionHandler: @escaping (NetworkResult) -> Void) {
        session.loadData(from: url) { data, error in
            let result = data.map(NetworkResult.success) ?? .failure(error)
            completionHandler(result)
        }
    }
}

使用完全模擬的最大好處是,通過簡單地實現NetworkSession協議,我們現在可以更輕鬆地爲我們的測試創建模擬:

class NetworkSessionMock: NetworkSession {
    var data: Data?
    var error: Error?

    func loadData(from url: URL,
                  completionHandler: @escaping (Data?, Error?) -> Void) {
        completionHandler(data, error)
    }
}

沒有關於URLSession內部的假設,我們現在在NetworkManager和它的底層會話之間有一個更強大的API契約?。

使用完全模擬時要記住的一件事是,保持協議儘可能的簡單 (一種方法是儘可能地分解和組合協議),否則你最終必須在你的模擬中實現許多方法和功能。在理想情況下,模擬應該是超級簡單的,根本不應該包含任何邏輯。

避免模擬對象

現在我們已經瞭解了在Swift中實現模擬的各種方法,讓我們看看我們實際上想要避免模擬的一個例子。當習慣於Mocking等技術時,有時你會覺得它能包治百病,所以會在各種情況下使用它。雖然大多數測試確實從模擬中受益,並且更容易單獨測試給定的類,但並不總是必要的。

假設我們正在構建一個FileLoader,它允許我們從文件系統加載文件。爲此,我們需要爲給定的文件名解析文件系統URL,爲此,我們將使用應用程序的Bundle。 Bundle API類似於我們之前使用的URLSession API,因爲它是基於單例的,通常通過訪問其共享實例來使用 - 在本例中爲.main。所以,我們最初的想法可能是做與URLSession完全相同的事情 - 爲它創建一個協議,然後進行模擬。

但是,當涉及到Bundle時,這實際上並不是必需的,實際上,模擬會給我們的測試代碼增加不必要的複雜度。我們可以做的是簡單地使用我們的測試套件的bundle,幷包含我們想要在該包中加載的任何文件。在Xcode中,我們可以創建一個文件 - 讓我們稱之爲TestFile.txt - 並將其添加到我們的測試target中。然後,我們通過在其初始化程序中爲我們的測試用例類提供Bundle,讓FileLoader使用我們的測試包,如下所示:

class FileLoaderTests: XCTestCase {
    func testReadingFileAsString() throws {
        // 將測試用例類作爲參數,初始化Bundle,這樣系統會使用測試bundle而不是主程序bundle
        let bundle = Bundle(for: type(of: self))
        let loader = FileLoader(bundle: bundle)

        let string = try loader.stringFromFile(named: "TestFile.txt")
        XCTAssertEqual(string, "I'm a test file!\n")
    }
}

因此,並不總是需要模擬,如果我們可以避免它們(並且仍然編寫好的和穩定的測試),它有時可以使測試代碼更簡單!?

結論

“模擬或着不模擬,這是個問題。。。”? 我希望這篇文章能讓你深入瞭解如何在Swift中應用各種模擬技術。我的建議是瞭解我們可以使用的各種技術,然後在您認爲最合適的地方應用它們。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章