Swift 单元测试入门

编程语言中的单元测试是为了确保编写的代码按预期工作。给定一个特定的输入,您希望代码带有一个特定的输出。通过测试您的代码,能够给您当前的重构和发布建立信心,因为您将能够确保代码在成功运行您的测试套件后按预期工作。

许多开发人员不编写单元测试,因为他们认为这会花费太多时间,有可能错过最后期限。在我看来,单元测试会让你在最后期限前完成更多工作,因为你会花更少的时间解决错误或为关键问题打补丁。

这篇文章内不会涵盖 内存泄漏测试 或 为共享扩展编写 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 来启用单元测试代码覆盖率

您可以从 Scheme 设置窗口启用测试覆盖率。这个窗口可以通过Product ➞ Scheme ➞ Edit Scheme打开。

在修复错误之前编写测试

跳到一个错误上并尽快修复它是很诱人的。虽然这很好,但如果您可以防止将来再次出现相同的错误,那就更好了。通过在修复 bug 之前编写单元测试,可以确保相同的 bug 不会再次发生。将其视为“测试驱动的错误修复”,从现在开始也称为 TDBF 。

其次,您可以开始编写修复程序并运行新的单元测试来验证修复程序是否有效。此技术比运行模拟器来验证您的修复是否有效要快。

结论

编写定性的单元测试是开发人员的基本技能。将能够对您的代码库建立信心,确保您在新版本发布之前没有破坏任何东西。使用正确的断言,您可以更快地解决失败的测试。确保至少测试关键业务代码并避免达到 100% 的代码覆盖率。

译自 Getting started with Unit Tests in Swift

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容