2:TDD循环¶
在上一章中,你了解到测试驱动开发可以归结为一个简单的过程,叫做TDD
循环。它有四个步骤,通常被"彩色编码"如下。
- 红色:写一个失败的测试,然后再写任何应用代码。
- 绿色:编写最低限度的代码以使测试通过。
- 重构:清理你的应用程序和测试代码。
- 重复:再次做这个循环,直到所有的功能都实现。
这也被称为"红色-绿色-重构"循环:
为什么用颜色编码?这与大多数代码编辑器显示的颜色相对应,包括Xcode
:
- 失败的测试用红色的X表示。
- 通过的测试显示为绿色的复选标记。
这一章提供了TDD
循环的介绍,你将在本书的其余部分使用它。然而,它没有详细介绍测试表达式(XCTAssert
,等等)或如何设置测试目标。相反,这些主题将在后面的章节中涵盖。现在,把重点放在学习TDD
循环上,其余的你会随着时间的推移学习。
在实践中学习是最好的,所以让我们直接跳到代码中去吧!
开始学习¶
在本章中,你将创建一个简单的收银机来学习TDD
循环。为了保持对TDD
的关注而不是Xcode
的设置,你将使用一个playground
。在startter
目录下打开CashRegister.playground
,然后打开CashRegister
页面。你会看到这个页面有两个导入,但除此之外,它是空的。
自然地,你将从TDD
循环的第一步开始:红色。
红色:写一个失败的测试¶
在你写任何生产代码之前,你必须先写一个失败的测试。要做到这一点,你需要创建一个测试类。在导入语句的下面添加以下内容:
class CashRegisterTests: XCTestCase {
}
上面,你把CashRegisterTests
声明为XCTestCase
的一个子类,它是XCTest
框架的一部分。你几乎总是子类XCTestCase
来创建你的测试类。
接下来,在playground
的最后添加以下内容:
CashRegisterTests.defaultTestSuite.run()
这告诉playground
运行CashRegisterTests
中定义的测试方法。然而,你实际上还没有编写任何测试。在CashRegisterTests
中添加以下内容,这应该会引起编译器错误:
// 1
func testInit_createsCashRegister() {
// 2
XCTAssertNotNil(CashRegister())
}
下面是逐行的解释:
-
我们在整本书中使用这个惯例来命名测试。
-
XCTest
要求所有的测试方法都以关键字test
开始,以便运行。 - 接下来,描述被测试的内容。这里,这是
init
。然后有一个下划线将其与下一部分隔开。 - 如果需要特殊的设置,就写在后面。例如,你可以描述测试需要哪些设置条件。但这是可选的,这个测试实际上不包括这部分。如果包括的话,你同样要用下划线作为后缀,把它和最后一部分分开。
- 最后,描述预期的结果或成果。在这里,这就是
creativeCashRegister
。
这种惯例导致测试名称易于阅读并提供有意义的背景。如果一个测试失败了,Xcode
会告诉你测试的类和方法的名称。通过这样命名你的测试,你可以快速确定问题所在。
- 你试图实例化一个新的
CashRegister
实例,并将其传递给XCTAssertNil
。这是一个测试表达式,它断定传递给它的东西不是nil
。如果它实际上是空的,测试将被标记为失败。
然而,最后一行并没有编译!这是因为你还没有为CashRegister
创建一个类。这是因为你还没有为CashRegister
创建一个类...那你要如何推进TDD
循环呢?幸运的是,在TDD
中有一条规则。编译失败也算作测试失败。所以,你已经完成了TDD
循环中的红色步骤,可以进入下一个步骤:绿色。
绿色:使测试通过¶
你只允许写最低限度的代码来使测试通过。如果你写的代码比这个多,你的测试就会落后于你的应用程序代码。为了解决这个编译错误,你能写的最低限度的代码是什么?定义CashRegister
!
在CashRegisterTests
类上面直接添加以下内容。
class CashRegister {
}
按Play
键执行playground
,你应该在控制台看到类似以下的输出:
Test Suite 'CashRegisterTests' started at
2021-07-22 16:55:35.336
Test Case '-[__lldb_expr_3.CashRegisterTests
testInit_createsCashRegister]' started.
Test Case '-[__lldb_expr_3.CashRegisterTests
testInit_createsCashRegister]' passed (0.081 seconds).
Test Suite 'CashRegisterTests' passed at
2021-07-22 16:55:35.418.
Executed 1 test, with 0 failures (0 unexpected) in 0.081
(0.082) seconds
棒极了,你已经使测试通过了! 下一步是重构你的代码。
重构:清理你的代码¶
在重构步骤中,你将同时清理你的应用程序代码和测试代码。通过这样做,你可以不断维护和改进你的代码。下面是一些你可能需要重构的东西。
- 重复的逻辑:你能抽出任何属性、方法或类来消除重复吗?
- 注释:你的注释应该解释为什么要做某事,而不是如何做。尽量消除那些解释代码如何工作的注释。应该通过将大的方法分解成几个名字好的方法,重新命名属性和方法以使其更加清晰,或者有时简单地将你的代码结构化来传达如何做。
- 代码气味:有时候,一个特定的代码块似乎是错误的。相信你的直觉,试着消除这些"代码气味"。例如,你的逻辑可能做了太多的假设,使用了硬编码的字符串或有其他问题。上面的技巧也适用于此。拔出方法和类,重新命名和重组代码,对解决这些问题有很大的帮助。
现在,CashRegister
和CashRegisterTests
中没有太多的逻辑,也没有什么需要重构的。所以,你已经完成了这一步--这很容易! 接下来是TDD循环中最重要的一步:重复。
重复:再做一次¶
在整个应用程序的开发过程中使用TDD
,以便从中获得最大的收益。你将在每个TDD
循环中完成一点,你将建立起由测试支持的应用代码。一旦你完成了你的应用程序的所有功能,你将有一个工作的、经过良好测试的系统。
你已经完成了你的第一个TDD
循环,你现在有一个可以实例化的类:CashRegister
。然而,要使这个类变得有用,还有更多的功能需要添加。这是你的待办事项列表:
- 写一个接受可用资金的初始化器。
- 为
addItem
写一个方法,将其添加到交易中。 - 写一个接受付款的方法。你已经得到了这个!
TDDing init(availableFunds:)¶
就像每个TDD
周期一样,你首先需要写一个失败的测试。在前面的测试下面添加以下内容,它应该产生一个编译器错误:
func testInitAvailableFunds_setsAvailableFunds() {
// given
let availableFunds = Decimal(100)
// when
let sut = CashRegister(availableFunds: availableFunds)
// then
XCTAssertEqual(sut.availableFunds, availableFunds)
}
这个测试比第一个测试更复杂,所以你把它分成三个部分:given
、when
和then
。以这种方式思考单元测试是很有用的:
- 鉴于某种条件
- 当某一行动发生时...
- 就会出现一个预期的结果。
在这种情况下,你得到的可用资金是小数(100)。当你通过init(availableFunds:)
创建sut
时,那么你希望sut.availableFunds
等于availableFunds
。
Note
如果given
、when
和then
部分非常简单,你可以选择省略这些注释行。我们在本章中包含了这些注释,以使其清晰明了,但在你自己的项目中,请用你自己的判断来决定是拥有还是省略这些注释使代码更容易阅读。
sut
这个名字是怎么回事? sut
是指被测系统。这是TDD
中一个非常常见的名字,代表你正在测试的东西。这个名字在本书中使用,正是为了这个目的。
这段代码还没有编译,因为你还没有定义init(availableFunds:)
。编译失败被视为测试失败,所以你已经完成了红色步骤。
接下来你需要让它通过。在CashRegister
中加入以下代码:
var availableFunds: Decimal
init(availableFunds: Decimal = 0) {
self.availableFunds = availableFunds
}
CashRegister
现在可以用availableFunds
进行初始化。
按Play
键执行所有的测试,你应该在控制台中看到类似以下的输出:
Test Suite 'CashRegisterTests' started
at 2021-07-22 17:03:58.245
Test Case '-[__lldb_expr_5.CashRegisterTests
testInit_createsCashRegister]' started.
Test Case '-[__lldb_expr_5.CashRegisterTests
testInit_createsCashRegister]' passed (0.081 seconds).
Test Case '-[__lldb_expr_5.CashRegisterTests
testInitAvailableFunds_setsAvailableFunds]' started.
Test Case '-[__lldb_expr_5.CashRegisterTests
testInitAvailableFunds_setsAvailableFunds]' passed
(0.003 seconds).
Test Suite 'CashRegisterTests' passed at
2021-07-22 17:03:58.331.
Executed 2 tests, with 0 failures (0 unexpected) in 0.085
(0.086) seconds
两个测试都通过了,所以你已经完成了绿色步骤。
接下来你需要重构你的应用程序和测试代码。首先,看一下测试代码。
testInit_createsCashRegister
现在已经过时了。现在已经没有init()
方法了。相反,这个测试实际上是在调用init(availableFunds:)
,使用availableFunds
的默认参数值为0
。
完全删除testInit_createsCashRegister
。
应用程序的代码呢?availableFunds
的默认参数值为0
是否有意义?这对于让testInit
和testInitAvailableFunds
都能编译是很有用的,但这个类是否真的应该有这个?
归根结底,这是一个设计决定:
- 如果你选择保留默认参数,你可以考虑为
testInit_setsDefaultAvailableFunds
添加一个测试,在这个测试中,你要验证availableFunds
是否被设置为预期的默认值。 - 另外,如果你决定保留这个参数没有意义,你可以选择删除这个默认参数。
在这个例子中,假设有一个默认参数是没有意义的。所以,删除默认参数值0
。 然后,你的初始化器应该是这样的。
init(availableFunds: Decimal) {
按Play
键来执行你剩下的测试,并验证它仍然通过。
在重构init(availableFunds:)
之后,testInitAvailableFunds
通过的事实给了你一种安全感,你的改变没有破坏现有的功能。这种在重构中增加的信心是TDD
的一个主要好处
现在你已经完成了重构的步骤,你已经准备好进入下一个TDD
循环。
TDDing addItem¶
接下来,你将通过TDD addItem
将一个项目的费用添加到交易中。像往常一样,你首先需要写一个失败的测试。在前面的测试下面添加以下内容,它应该产生编译器错误:
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)
}
这个测试没有被编译,因为你还没有定义addItem(_:)
或transactionTotal
。
为了解决这个问题,在CashRegister
中的availableFunds
后面添加以下属性:
var transactionTotal: Decimal = 0
最后,在init(availableFunds:)
下面添加addItem(_:)
:
func addItem(_ cost: Decimal) {
transactionTotal = cost
}
在这里,你把transactionTotal
设置为传入的费用。但这并不完全正确,或者说是吗?
还记得你应该如何写最低限度的代码来使测试通过吗?在这种情况下,增加一个交易所需的最低限度的代码是将transactionTotal
设置为传入项目的成本,而不是增加它!因此,这就是你所做的。因此,这就是你所做的。
按下Play
键,你应该看到控制台输出显示所有测试都通过了。这在技术上是正确的,但有一点。仅仅因为你已经完成了一个TDD
周期并不意味着你已经完成了。相反,在你完成之前,你必须实现你的应用程序的所有功能
在这种情况下,缺少的功能是为一个交易添加多个项目的能力。在这之前,你需要通过重构你写的东西来完成当前的TDD
周期。
从查看你的测试代码开始。是否有重复的地方?当然有。看一下这些行:
let availableFunds = Decimal(100)
let sut = CashRegister(availableFunds: availableFunds)
这段代码在testInitAvailableFunds
和testAddItem
中是通用的。为了消除这种重复,你将在CashRegisterTests
中创建实例变量。
在CashRegisterTests
的开头大括号后添加以下内容:
var availableFunds: Decimal!
var sut: CashRegister!
就像生产代码一样,你可以自由地定义任何你需要的属性、方法和类来重构你的测试代码。甚至还有一对特殊的方法来"设置"和"关闭"你的测试,方便地命名为setUp()
和teleDown()
。
setUp()
在每个测试方法运行前被调用,而tearDown()
在每个测试方法结束后被调用。
这些方法是移动重复逻辑的完美场所。在你的测试属性下面添加以下内容:
// 1
override func setUp() {
super.setUp()
availableFunds = 100
sut = CashRegister(availableFunds: availableFunds)
}
// 2
override func tearDown() {
availableFunds = nil
sut = nil
super.tearDown()
}
下面是这个的作用:
- 在
setUp()
中,你首先调用super.setUp()
,给超类一个机会来做设置。然后你设置availableFunds
和Sut
。 - 在
tearDown()
中,你做的是相反的事情。你将availableFunds
和sut
设置为nil
,最后调用super.tearDown()
。
在tearDown()
中,你应该总是将你在setUp()
中设置的任何属性置空。这是由XCTest
框架的工作方式决定的。它在你的测试目标中实例化每个XCTestCase
子类,直到所有的测试用例运行完毕才释放它们。因此,如果你有很多测试用例,而你没有在tearDown
中把它们的属性设置为nil
,你就会保留属性的内存超过你需要的时间。如果有足够多的测试用例,这甚至会导致运行测试时的内存和性能问题。
Note
还有其他的设置/拆除方法:setUpWithError() throws
和tearDownWithError() throws
。如果你的代码有可能在设置和/或拆除过程中抛出一个错误,请使用这些方法。
现在你可以使用这些实例属性来摆脱测试方法中重复的逻辑。用这一行替换testInitAvailableFunds
的内容:
XCTAssertEqual(sut.availableFunds, availableFunds)
这使得它非常容易阅读,这样就不需要given
和when
的注释了。
接下来,将testAddItem
的内容替换为以下内容:
// given
let itemCost = Decimal(42)
// when
sut.addItem(itemCost)
// then
XCTAssertEqual(sut.transactionTotal, itemCost)
啊,这也简单多了!你知道吗?通过将初始化代码移到setup()
中,你可以清楚地看到这个方法只是在行使addItem(_:)
。按Play
确认所有测试仍然通过。
这就完成了重构工作,所以你现在可以进入下一个TDD
周期了。
添加两个项目¶
testAddItem_oneItem
证实addItem()
对一个项目通过,但对两个项目不会通过...还是会?一个新的测试可以明确地证明这一点。
在testAddItem_oneItem_addsCostToTransactionTotal
下面添加以下测试:
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
是否累加。
按下Play
,你会看到控制台的输出显示测试失败:
Test Case '-[__lldb_expr_14.CashRegisterTests
testAddItem_twoItems_addsCostsToTransactionTotal]' started.
CashRegister.playground:89: error:
-[__lldb_expr_14.CashRegisterTests
testAddItem_twoItems_addsCostsToTransactionTotal] :
XCTAssertEqual failed: ("20") is not equal to ("62") -
Test Case '-[__lldb_expr_14.CashRegisterTests
testAddItem_twoItems_addsCostsToTransactionTotal]'
failed (0.008 seconds).
...
Test Suite 'CashRegisterTests' failed at
2021-07-22 17:23:52.413
Executed 3 tests, with 1 failure (0 unexpected) in 0.141
(0.142) seconds
接下来你需要让这个测试通过。为此,将addItem(_:)
的内容替换成这样:
transactionTotal += cost
在这里,你用+=
替换了=
操作符,将其添加到transactionTotal
中,而不是设置它。再次按下Play
按钮,现在你会看到所有测试都通过了。
最后,你需要重构你的代码。注意到任何重复的地方吗?在两个addItem
测试中使用的itemCost
变量如何?是的,你应该把它拉到一个实例属性中。
在CashRegisterTests
中的availableFunds
的实例属性下面添加以下内容:
var itemCost: Decimal!
接下来,在setUp()
中设置availableFunds
后,直接添加这一行:
itemCost = 42
由于你在setUp()
中设置了这个属性,你也必须在teleDown()
中将其置零。
在teleDown()
中把availableFunds
设置为nil
后,添加以下内容:
itemCost = nil
接下来,从testAddItem_oneItem
中删除这两行:
// given
let itemCost = Decimal(42)
同样地,从testAddItem_twoItems
中删除这一行:let
itemCost = Decimal(42)
当你完成后,唯一保留的itemCost
应该是CashRegisterTests
上定义的实例属性。然后,按下Play
按钮,验证所有测试是否继续通过。
看到CashRegisterTests
中的任何其他重复吗?这一行呢?
sut.addItem(itemCost)
这对testAddItem_oneItem
和testAddItem_twoItems
来说是共同的。你应该尝试消除这种重复吗?
还记得setUp()
是如何在每个测试方法运行前被调用的吗?你已经有一个测试方法不需要这个调用了,即testInitAvailableFunds
。
当你继续TDD CashRegister
时,你可能会编写其他不需要调用addItem(_:)
的方法。因此,你不应该把这个调用移到setUp()
中。
何时重构代码以消除重复是一门艺术,而不是一门精确的科学。当你在进行时,做你认为最好的事情,但如果需要的话,不要害怕以后改变你的决定
挑战¶
CashRegister
有了一个良好的开端! 然而,还有更多的工作要做。具体来说,你需要一种接受付款的方法。为了简单起见,你只接受现金支付--不允许使用信用卡或欠条
你的挑战是TDD
这个新方法,acceptCashPayment(_ cash:)
。
先试着自己解决这个问题,不需要帮助。如果你被卡住了,请看下面的提示。
对于这个挑战,你需要在CashRegisterTests
中创建两个测试方法。
首先,创建一个名为testAcceptCashPayment_subtractsPaymentFromTransactionTotal
的测试方法。在这个方法中,请执行以下操作。
- 调用
sut.addItem(_:)
来设置一个"正在进行的交易"。 - 调用
sut.acceptCashPayment(_:)
来接受付款。 - 断言
transactionTotal
中已经减去了付款。
接下来,在CashRegister
中实现acceptCashPayment(_:)
,以使测试通过,并根据需要进行重构。
创建第二个测试方法,名为testAcceptCashPayment_addsPaymentToAvailableFunds
。在那里,做以下工作:
- 调用
sut.addItem(_:)
来设置一个当前交易。 - 调用
sut.acceptCashPayment(_:)
来接受付款。 - 断言
availableFunds
中加入了付款。
最后,更新acceptCashPayment(_:)
以使该测试通过,并根据需要进行重构。
关键点¶
你在本章中了解了TDD
循环。这有四个步骤。
- 红色:写一个失败的测试。
- 绿色:使测试通过。
- 重构:清理你的应用程序和测试代码。
- 重复:再做一次,直到所有的功能都实现。
Xcode playgrounds
是学习新概念的好方法,就像您在本章中学习TDD
循环一样。然而,在现实世界的开发中,你通常在你的iOS
项目中创建单元测试目标,而不是使用playground
。幸运的是,TDD
在应用程序中的效果甚至比playground
更好
继续看下一节,了解在iOS
应用程序中使用TDD
。