【iOS开发】了解测试驱动开发 (TDD)

什么是 TDD

测试驱动开发(Test-driven development, 简称 TDD),是一种通过迭代进行许多由测试支持的小更改的迭代开发软件的方法。

它有四个步骤:

  1. 写一个失败的测试
  2. 使测试通过
  3. 重构
  4. 重复

这个步骤也被称为 TDD 循环,能彻底和准确地测试代码。

为什么应该使用 TDD?

TDD 是确保软件能够正常工作并在未来继续良好工作的唯一最佳方法。为什么?

你可以不按照 TDD 的方式来写测试代码。例如,先编写所有代码,然后在写测试;或者完全跳过编写测试代码,直接用手动测试。为什么 TDD 比这些方法更好?

因为 TDD 提供了确保测试良好的方法:

  • 第一步是编写一个失败的测试。根据定义,这证明了测试是可能失败的。

  • 在编写新的测试之前,所有其他以前的测试都必须通过。这确保了测试的可重复性:不只是运行正在进行的单个测试,而是不断地运行所有测试。

  • 通过频繁地运行每个测试,您会受到激励,以确保测试能够快速运行。所有的测试仅需要几秒钟才能运行,最好是一秒钟或更短。

  • 重构时,同时更新代码和测试代码。这可以确保测试得到维护。

  • 通过并行迭代编写代码和测试,可以确保代码是可测试的。如果在完成代码后编写测试,那么代码很可能需要相当多的重构才能完成单元测试。

哪些是需要测试的?

更好的测试覆盖并不总是意味着你的应用程序得到了更好的测试。有些事情你应该测试,有些事情你不应该测试。以下是注意事项:

  • 为无法以自动化方式捕获的代码编写测试。这包括类的方法中的代码、自定义的 getter 和 setter 以及您自己编写的大多数其他内容。

  • 不要为自动生成的代码编写测试。例如,不值得为生成的 getter 和 setter 编写测试。

  • 不要为编译器可能捕捉到的问题编写测试。如果测试的问题将生成错误或警告,Xcode 将为您捕获它。

  • 不要为依赖代码编写测试,例如应用程序使用的系统框架或第三方框架。框架作者负责编写这些测试。例如,不应该为 UIKit 类编写测试,因为 UIKit 开发人员负责编写这些测试。但是,应该为自定义子类编写测试:这是自定义代码,因此你要负责编写测试。

上面的一个例外是编写测试以确定框架如何工作。这是非常有用的。但是,不需要长期保存这些测试。相反,后续应该删除它们。

另一个例外是“健全性测试”,它可以确保第三方代码如您所期望的那样工作。如果库不是完全稳定的,或者您不信任它,那么这类测试非常有用。

TDD 需要花太多时间

关于 TDD 最常见的抱怨是它花费的时间太长了。

但是,一旦你习惯了,TDD 会变得更快。然而,事实是,与根本不编写任何测试相比,您最终编写的代码更多。刚刚开始用 TDD 可能需要更多的时间。

但是,你要知道:开发的成本不仅仅是最开始编写的第一个版本的代码。它还包括随着时间的推移添加新功能、修改现有代码、修复错误等等。从长远来看,遵循 TDD 比不遵循 TDD 花费的时间要少得多,因为它的代码更易于维护,错误更少。

还有另一个要考虑的成本:生产中缺陷对客户的影响。一个问题被发现的时间越长,成本就越高。它可能导致负面评论、失去信任和收入损失。

如果在开发过程中发现了问题,那么调试起来更容易,修复也更快。如果你在几周后发现它,你将花费更多的时间来加速代码的运行并找出根本原因。通过遵循 TDD,你的测试最终有助于保护你的应用程序免受 bug 的影响。

什么时候应该使用 TDD?

TDD 可以在产品生命周期的任何时候使用:新开发的、已存在的应用程序以及介于两者之间的一切。然而,如何以及从哪里开始 TDD 确实取决于项目的状态。

然而,有一个重要的问题需要问:您的项目是否应该使用 TDD?

一般来说,如果你的应用要持续几个月以上,会有多个版本和/或需要复杂的逻辑,那么你最好还是使用 TDD。

如果你为一些临时性的东西创建一个应用程序,你应该评估 TDD 是否有意义。如果真的只有一个版本的应用程序,你可能不会遵循 TDD,或者只对关键或困难的部分进行 TDD。

归根结底,TDD是一种工具,您可以决定何时最好地使用它!

TDD 简单案例

在上面的内容已经提到,TDD 的流程有四个步骤:

  1. 写一个失败的测试
  2. 使测试通过
  3. 重构
  4. 重复

下面通过例子来演示每个步骤。

写一个失败的测试

在 playground 中编写以下测试用例:

class CashRegisterTests: XCTestCase {
    func testInit_createsCashRegister() {
        XCTAssertNotNil(CashRegister())
    }
}

// 调用这个可以在 playground 运行测试
CashRegisterTests.defaultTestSuite.run()

在 iOS 中,测试用例的方法都是以 test 开头。因为没有定义 CashRegister,所以编译器报错。而对于 TDD 循环来说,编译报错可以看做是测试失败,所以到此我们完成第一步:写一个失败的测试。

使测试通过

如果编写最少的代码使得上面的测试通过?当然是定义 CashRegister

class CashRegister {

}

执行 playground 后,得到以下输出:

Test Suite 'CashRegisterTests' started at 2020-08-06 02:17:19.397
Test Case '-[__lldb_expr_1.CashRegisterTests testInit_createsCashRegister]' started.
Test Case '-[__lldb_expr_1.CashRegisterTests testInit_createsCashRegister]' passed (0.210 seconds).
Test Suite 'CashRegisterTests' passed at 2020-08-06 02:17:19.608.
     Executed 1 test, with 0 failures (0 unexpected) in 0.210 (0.212) seconds

可以看到测试通过,所以到此我们完成第二步:使测试通过。

重构

在这一步中,我们将清理应用程序代码和测试代码。通过这样做,可以不断地维护和改进代码。以下是一些可以重构的内容:

  • 重复的逻辑:反问自己,能抽出任何属性、方法或类来消除重复吗?

  • 注释:注释应该解释为什么要做某件事,而不是怎么做的。尽量消除解释代码工作原理的注释。应该通过将复杂方法分解为几个命名良好的方法,重命名属性和方法以使其更清晰,或者有时只是简单地将代码结构更好地表达出来。

  • 错误代码:有时某个特定的代码块似乎是错误的。相信你的直觉,试着消除这些错误代码。例如,你可能有做过多假设的逻辑,使用硬编码字符串或有其他问题。

现在, CashRegisterCashRegisterTests 没有太多的逻辑,也没有什么可以重构的。所以到此我们完成第三步:重构。

重复

前面已经完成了第一个 TDD 周期,现在我们重复这个周期。在这内容,将为 CashRegister 添加以下方法:

  • 接受可用资金参数的初始化器
  • 用于添加 item 的方法

接受可用资金参数的初始化函数

首先写测试用例:

func testInitAvailableFunds_setsAvailableFunds() {
    // given
    let availableFunds: Decimal = 100
    
    // when
    let sut = CashRegister(availableFunds: availableFunds)
    
    // then
    XCTAssertEqual(sut.availableFunds, availableFunds)
}

编译错误,因为 CashRegister 还没有那个初始化函数。下面编写这个函数:

class CashRegister {
    var availableFunds: Decimal
    
    init(availableFunds: Decimal = 0) {
        self.availableFunds = availableFunds
    }
}

编译错误消失,执行 playground 后,得到以下输出:

Test Suite 'CashRegisterTests' started at 2020-08-06 09:42:40.794
Test Case '-[__lldb_expr_3.CashRegisterTests testInit_createsCashRegister]' started.
Test Case '-[__lldb_expr_3.CashRegisterTests testInit_createsCashRegister]' passed (0.174 seconds).
Test Case '-[__lldb_expr_3.CashRegisterTests testInitAvailableFunds_setsAvailableFunds]' started.
Test Case '-[__lldb_expr_3.CashRegisterTests testInitAvailableFunds_setsAvailableFunds]' passed (0.008 seconds).
Test Suite 'CashRegisterTests' passed at 2020-08-06 09:42:40.978.
     Executed 2 tests, with 0 failures (0 unexpected) in 0.182 (0.184) seconds

可以看到测试通过。到此我们完成了两个步骤:1. 写一个失败的测试;2. 使测试通过。

第三步是重构,这一步中我们将清理应用程序代码和测试代码。

对于测试代码,可以发现 testInit_createsCashRegister 是没必要的,所以可以把它删掉。

对于应用代码,初始化器 init(availableFunds: Decimal = 0) 的参数有一个默认值,我们得思考这个默认值是否有必要?这将会产生以下两种情况:

  • 如果保留默认值,那么就得考虑为这个默认值添加测试
  • 如果不保留默认值,那么就把它删掉

在这里,我们把它删掉,变成:

init(availableFunds: Decimal) {
    self.availableFunds = availableFunds
}

重构完成后,继续执行 playground,发现还能测试通过。通过这个例子,可以感觉到遵循 TDD 能让我们在重构时更有信心。

用于添加 item 的方法

首先写测试用例:

func testAddItem_oneItem_addsCostToTransactionTotal() {
    // given
    let availableFunds: Decimal = 100
    let sut = CashRegister(availableFunds: availableFunds)
    let itemCost: Decimal = 42
    
    // when
    sut.addItem(itemCost)
    
    // then
    XCTAssertEqual(sut.transactionTotal, itemCost)
}

编译错误,因为 CashRegister 还没有 addItem 方法和 transactionTotal 属性。下面编写 addItem 方法和 transactionTotal 属性:

class CashRegister {
    var transactionTotal: Decimal = 0
    var availableFunds: Decimal
    
    init(availableFunds: Decimal = 0) {
        self.availableFunds = availableFunds
    }
    
    func addItem(_ cost: Decimal) {
        transactionTotal = cost
    }
}

addItem 方法的实现中,直接把 cost 赋值给 transactionTotal 很明显是不对的。但对于这个测试用例来说,这么做也能让编译错误消失。所以我们暂时先这么做。执行 playground 后,可以看到测试通过。

接下来进行重构。

对于测试代码,可以发现一下的代码在测试用例中重复了:

let availableFunds: Decimal = 100
let sut = CashRegister(availableFunds: availableFunds)

所以我们可以把这两个变量抽出来,作为 CashRegisterTests 的属性,并且在 setUp()tearDown() 方法中初始化和重置他们的值。最终重构后的代码如下:

class CashRegisterTests: XCTestCase {
    
    var availableFunds: Decimal!
    var sut: CashRegister!
    
    override func setUp() {
        super.setUp()
        availableFunds = 100
        sut = CashRegister(availableFunds: availableFunds)
    }

    override func tearDown() {
        availableFunds = nil
        sut = nil
        super.tearDown()
    }
    
    func testInitAvailableFunds_setsAvailableFunds() {
        XCTAssertEqual(sut.availableFunds, availableFunds)
    }
    
    func testAddItem_oneItem_addsCostToTransactionTotal() {
        // given
        let itemCost: Decimal = 42
        
        // when
        sut.addItem(itemCost)
        
        // then
        XCTAssertEqual(sut.transactionTotal, itemCost)
    }
}

setUp() 中初始哈变量的值;在 tearDown() 方法中重置变量的值。另外,我们应该总是在 tearDown() 中把变量设置为 nil,因为 XCTestCase 类只在所有测试完成之后才会释放变量占用的内存,所以如果我们有很多测试用例并且不在 tearDown() 中把变量设置为 nil的话,那么测试的性能可能会受到影响。

添加两个 items

testAddItem_oneItem 测试用例证明了 addItem() 在添加一个 item 时是正确的。如果添加两个或更多 items 时会怎样呢?我们来测试一下。

添加测试用例如下:

func testAddItem_twoItems_addsCostsToTransactionTotal() {
    // given
    let itemCost: Decimal = 42
    let itemCost2: Decimal = 20
    let expectedTotal = itemCost + itemCost2
    
    // when
    sut.addItem(itemCost)
    sut.addItem(itemCost2)
    
    // then
    XCTAssertEqual(sut.transactionTotal, expectedTotal)
}

执行后,发现刚刚添加的测试用例失败了。这证明 addItem() 方法的实现有问题,我们很快找到问题所在,把实现改为:

transactionTotal += cost

再次执行后,所有测试通过。

接下来是重构:仔细查看测试代码,发现 itemCost 可以抽取出来,重构后代码为:

class CashRegisterTests: XCTestCase {
    
    var availableFunds: Decimal!
    var sut: CashRegister!
    var itemCost: Decimal!
    
    override func setUp() {
        super.setUp()
        availableFunds = 100
        sut = CashRegister(availableFunds: availableFunds)
        itemCost = 42
    }

    override func tearDown() {
        availableFunds = nil
        sut = nil
        itemCost = nil
        super.tearDown()
    }
    
    func testInitAvailableFunds_setsAvailableFunds() {
        XCTAssertEqual(sut.availableFunds, availableFunds)
    }
    
    func testAddItem_oneItem_addsCostToTransactionTotal() {
        // when
        sut.addItem(itemCost)
        
        // then
        XCTAssertEqual(sut.transactionTotal, itemCost)
    }
    
    func testAddItem_twoItems_addsCostsToTransactionTotal() {
        // given
        let itemCost2: Decimal = 20
        let expectedTotal = itemCost + itemCost2
        
        // when
        sut.addItem(itemCost)
        sut.addItem(itemCost2)
        
        // then
        XCTAssertEqual(sut.transactionTotal, expectedTotal)
    }
}

参考资料

iOS Test-Driven Development by Tutorials

有问题可以直接留言。