編程語言中的單元測試是爲了確保編寫的代碼按預期工作。給定一個特定的輸入,您希望代碼帶有一個特定的輸出。通過測試您的代碼,能夠給您當前的重構和發佈建立信心,因爲您將能夠確保代碼在成功運行您的測試套件後按預期工作。
許多開發人員不編寫單元測試,因爲他們認爲這會花費太多時間,有可能錯過最後期限。在我看來,單元測試會讓你在最後期限前完成更多工作,因爲你會花更少的時間解決錯誤或爲關鍵問題打補丁。
這篇文章內不會涵蓋 內存泄漏測試 或 爲共享擴展編寫 UI 測試,而是主要關注編寫更好的單元測試。我還將分享幫助我開發更好、更穩定的應用程序的最佳實踐。
什麼是單元測試
單元測試是運行和驗證一段代碼(稱爲“單元”)以確保其按預期運行並符合其設計的自動化測試。
單元測試在 Xcode 中有它們的 target,並使用 XCTest 框架編寫。 XCTestCase
的子類包含要運行的測試方法,其中只有以 "test" 開頭的方法纔會被 Xcode 解析並允許運行。
例如,假設有一個字符串擴展方法將第一個字母大寫:
extension String {
func uppercasedFirst() -> String {
let firstCharacter = prefix(1).capitalized
let remainingCharacters = dropFirst().lowercased()
return firstCharacter + remainingCharacters
}
}
我們要確保 uppercasedFirst()
方法按預期工作。如果我們給它一個輸入 antoine
,我們期望它輸出 Antoine
。我們可以使用XCTAssertEqual
方法爲此方法編寫單元測試:
final class StringExtensionsTests: XCTestCase {
func testUppercaseFirst() {
let input = "antoine"
let expectedOutput = "Antoine"
XCTAssertEqual(input.uppercasedFirst(), expectedOutput, "The String is not correctly capitalized.")
}
}
如果我們的方法不再按預期工作(比如上面的擴展代碼不小心被修改了),Xcode 將使用我們提供的描述顯示失敗:
在 Swift 中編寫單元測試
有多種方法可以測試相同的結果,但是當測試失敗時它並不總是給出相同的反饋。以下提示可幫助您編寫測試,通過從詳細的失敗消息中獲益,幫助您更快地解決失敗的測試。
命名測試用例和方法
描述你的單元測試是很重要的,這樣你就會明白測試試圖驗證什麼。如果你不能想出一個簡短的名字,那你可能測試了太多東西。一個好名字還可以幫助您更快地解決失敗的測試。
要快速找到特定類的測試用例,建議使用相同的命名並結合 “test”。就像上面的例子一樣,我們根據我們正在測試一組字符串擴展的事實命名了 StringExtensionTests
。如果您正在測試ContentViewModel
實例,另一個示例可能是 ContentViewModelTests
。
不要所有測試都使用 XCTAssert
許多場景都可以使用 XCTAssert
,但當測試失敗時會導致不同的結果。以下代碼行都測試了完全相同的結果:
func testEmptyListOfUsers() {
let viewModel = UsersViewModel(users: ["Ed", "Edd", "Eddy"])
XCTAssert(viewModel.users.count == 0)
XCTAssertTrue(viewModel.users.count == 0)
XCTAssertEqual(viewModel.users.count, 0)
}
正如你所看到的,該方法使用了一個描述性的名字,告訴人們要測試一個空的用戶列表。然而,我們定義的視圖模型不是空的,因此,所有的斷言都失敗了。
結果顯示了爲什麼必須對驗證類型使用正確的斷言。 XCTAssertEqual
方法爲我們提供了有關斷言失敗原因的更多上下文。這顯示在紅色錯誤和控制檯日誌中,可幫助您快速識別失敗的測試。
Setup and Teardown
多個測試方法中使用的參數可以定義爲測試用例類中的屬性。您可以使用 setUp()
方法爲每個測試方法設置初始狀態,並使用 tearDown()
方法進行清理。有多種設置和拆卸方法的變體供您選擇,例如支持併發的變體或拋出變體,如果設置失敗,您可以在其中提前使測試失敗。
一個可以生成用戶默認實例以用於單元測試的示例:
struct SearchQueryCache {
var userDefaults: UserDefaults = .standard
func storeQuery(_ query: String) {
/// ...
}
}
final class SearchQueryCacheTests: XCTestCase {
private var userDefaults: UserDefaults!
private var userDefaultsSuiteName: String!
override func setUpWithError() throws {
try super.setUpWithError()
userDefaultsSuiteName = UUID().uuidString
userDefaults = UserDefaults(suiteName: userDefaultsSuiteName)
}
override func tearDownWithError() throws {
try super.tearDownWithError()
userDefaults.removeSuite(named: userDefaultsSuiteName)
userDefaults = nil
}
func testSearchQueryStoring() {
/// 使用生成的用戶默認值作爲輸入。
let cache = SearchQueryCache(userDefaults: userDefaults)
/// ... write the test
}
}
這樣做可以確保您不會操縱在模擬器上測試期間使用的標準用戶默認值。其次,您將確保在測試開始時處於乾淨狀態。我們使用了拆卸方法來刪除用戶默認套件並進行相應的清理。
拋出方法
和編寫應用程序代碼時一樣,您也可以定義一個可拋出測試的方法。這允許您在測試中的方法拋出錯誤時使測試失敗。例如,在測試 JSON 響應的解碼時:
func testDecoding() throws {
/// 當數據初始值設定項拋出錯誤時,測試將失敗。
let jsonData = try Data(contentsOf: URL(string: "user.json")!)
/// `XCTAssertNoThrow` 可用於獲取有關拋出的額外上下文
XCTAssertNoThrow(try JSONDecoder().decode(User.self, from: jsonData))
}
當在任何進一步的測試執行中不需要 throwing 方法的結果時,可以使用 XCTAssertNoThrow
方法。您應該使用 XCTAssertThrowsError
方法來匹配預期的錯誤類型。例如,您可以爲證書密鑰驗證程序編寫測試:
struct LicenseValidator {
enum Error: Swift.Error {
case emptyLicenseKey
}
func validate(licenseKey: String) throws {
guard !licenseKey.isEmpty else {
throw Error.emptyLicenseKey
}
}
}
class LicenseValidatorTests: XCTestCase {
let validator = LicenseValidator()
func testThrowingEmptyLicenseKeyError() {
XCTAssertThrowsError(try validator.validate(licenseKey: ""), "An empty license key error should be thrown") { error in
/// 我們確保預期的錯誤被拋出。
XCTAssertEqual(error as? LicenseValidator.Error, .emptyLicenseKey)
}
}
func testNotThrowingLicenseErrorForNonEmptyKey() {
XCTAssertNoThrow(try validator.validate(licenseKey: "XXXX-XXXX-XXXX-XXXX"), "Non-empty license key should pass")
}
}
可選值解包
XCTUnwrap
方法最適合用於拋出測試,因爲它是一個拋出斷言:
func testFirstNameNotEmpty() throws {
let viewModel = UsersViewModel(users: ["Antoine", "Maaike", "Jaap"])
let firstName = try XCTUnwrap(viewModel.users.first)
XCTAssertFalse(firstName.isEmpty)
}
XCTUnwrap
斷言可選變量的值不爲 nil
,如果斷言成功則返回它的值。它會阻止您編寫 XCTAssertNotNil
並結合解包或處理其餘測試代碼的條件鏈接。我鼓勵您閱讀我的文章 《如何使用 XCTest 在 Swift 中測試可選值》以瞭解更多詳細信息。
在 Xcode 中運行單元測試
編寫測試後,就該運行它們了。通過以下提示,這將變得更有效率。
使用測試三角形
您可以使用前導三角形運行單個測試或一組測試:
根據最新的測試運行結果,同一方塊顯示紅色或綠色。
重新運行最新的測試
使用以下命令重新運行上次運行測試:
⌃ Control + ⌥ Option + ⌘ Command + G
.
上面的快捷方式可能是我最常用的快捷方式之一,因爲它可以幫助我在對失敗測試實施修復後快速重新運行測試。
運行測試組合
使用 CTRL 或 SHIFT 選擇要運行的測試,右鍵單擊並選擇“Run X Test Methods”。
在測試導航器中應用過濾器
測試導航器底部的過濾欄允許您縮小測試概覽範圍。
- 使用搜索字段根據名稱搜索特定測試
- 僅顯示當前所選方案的測試。如果您有多個測試方案,這將很有用。
- 只顯示失敗的測試。這將幫助您快速找到失敗的測試。
在側邊欄中啓用覆蓋
測試迭代計數向您顯示在上次運行測試期間是否命中了特定代碼段。
它顯示了迭代次數(在上面的示例中爲 3),一段代碼在到達時變爲綠色。當一段代碼是紅色時,這意味着它在上次運行的測試中沒有被覆蓋。
編寫單元測試時的心態
你的心態是編寫高質量單元測試的一個很好的起點。通過一些基本原則,您可以確保工作效率、保持專注並編寫您的應用程序最需要的測試。
您的測試代碼與您的應用程序代碼一樣重要
在深入探討實用技巧之後,我想介紹一種必要的心態。就像編寫應用程序代碼一樣,您應該盡最大努力編寫高質量的測試代碼。
考慮重用代碼、使用協議、在多個測試中使用時定義屬性,並確保您的測試清理所有創建的數據。這將使您的單元測試更易於維護,並防止不穩定和奇怪的測試失敗。如果您不熟悉片狀的測試,我鼓勵您閱讀我的文章 Flaky tests resolving using Test Repetitions in Xcode。
100% 的代碼覆蓋率不應該是你的目標
儘管它是很多人的目標,但 100% 的覆蓋率不應該是您編寫測試時的主要目標。一個很好的開始是確保至少測試您最關鍵的業務邏輯。覆蓋率達到 100% 可能會很耗時,而收益並不總是那麼顯著。並且達到100%,也意味着可能需要付出很大的努力。
最重要的是,100% 的覆蓋率可能會產生誤導。上面的單元測試示例覆蓋了所有方法,覆蓋率爲 100%。但是,它並沒有測試所有場景,因爲它只測試了一個非空數組。同時,也可能存在空數組的情況,其中 hasUsers
屬性應該返回 false。
您可以從 Scheme 設置窗口啓用測試覆蓋率。這個窗口可以通過Product ➞ Scheme ➞ Edit Scheme
打開。
在修復錯誤之前編寫測試
跳到一個錯誤上並儘快修復它是很誘人的。雖然這很好,但如果您可以防止將來再次出現相同的錯誤,那就更好了。通過在修復 bug 之前編寫單元測試,可以確保相同的 bug 不會再次發生。將其視爲“測試驅動的錯誤修復”,從現在開始也稱爲 TDBF 。
其次,您可以開始編寫修復程序並運行新的單元測試來驗證修復程序是否有效。此技術比運行模擬器來驗證您的修復是否有效要快。
結論
編寫定性的單元測試是開發人員的基本技能。將能夠對您的代碼庫建立信心,確保您在新版本發佈之前沒有破壞任何東西。使用正確的斷言,您可以更快地解決失敗的測試。確保至少測試關鍵業務代碼並避免達到 100% 的代碼覆蓋率。