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

2020-09-20  本文已影响0人  Lebron_James

什么是 TDD

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

它有四个步骤:

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

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

为什么应该使用 TDD?

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

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

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

哪些是需要测试的?

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

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

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

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 添加以下方法:

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

首先写测试用例:

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

有问题可以直接留言。

上一篇下一篇

猜你喜欢

热点阅读