跳转至

2:TDD循环

在上一章中,你了解到测试驱动开发可以归结为一个简单的过程,叫做TDD循环。它有四个步骤,通常被"彩色编码"如下。

  1. 红色:写一个失败的测试,然后再写任何应用代码。
  2. 绿色:编写最低限度的代码以使测试通过。
  3. 重构:清理你的应用程序和测试代码。
  4. 重复:再次做这个循环,直到所有的功能都实现。

这也被称为"红色-绿色-重构"循环:

image

为什么用颜色编码?这与大多数代码编辑器显示的颜色相对应,包括Xcode

  • 失败的测试用红色的X表示。
  • 通过的测试显示为绿色的复选标记。

image

这一章提供了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())
}

下面是逐行的解释:

  1. 我们在整本书中使用这个惯例来命名测试。

  2. XCTest要求所有的测试方法都以关键字test开始,以便运行。

  3. 接下来,描述被测试的内容。这里,这是init。然后有一个下划线将其与下一部分隔开。
  4. 如果需要特殊的设置,就写在后面。例如,你可以描述测试需要哪些设置条件。但这是可选的,这个测试实际上不包括这部分。如果包括的话,你同样要用下划线作为后缀,把它和最后一部分分开。
  5. 最后,描述预期的结果或成果。在这里,这就是creativeCashRegister

这种惯例导致测试名称易于阅读并提供有意义的背景。如果一个测试失败了,Xcode会告诉你测试的类和方法的名称。通过这样命名你的测试,你可以快速确定问题所在。

  1. 你试图实例化一个新的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

棒极了,你已经使测试通过了! 下一步是重构你的代码。

重构:清理你的代码

在重构步骤中,你将同时清理你的应用程序代码和测试代码。通过这样做,你可以不断维护和改进你的代码。下面是一些你可能需要重构的东西。

  • 重复的逻辑:你能抽出任何属性、方法或类来消除重复吗?
  • 注释:你的注释应该解释为什么要做某事,而不是如何做。尽量消除那些解释代码如何工作的注释。应该通过将大的方法分解成几个名字好的方法,重新命名属性和方法以使其更加清晰,或者有时简单地将你的代码结构化来传达如何做。
  • 代码气味:有时候,一个特定的代码块似乎是错误的。相信你的直觉,试着消除这些"代码气味"。例如,你的逻辑可能做了太多的假设,使用了硬编码的字符串或有其他问题。上面的技巧也适用于此。拔出方法和类,重新命名和重组代码,对解决这些问题有很大的帮助。

现在,CashRegisterCashRegisterTests中没有太多的逻辑,也没有什么需要重构的。所以,你已经完成了这一步--这很容易! 接下来是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)
}

这个测试比第一个测试更复杂,所以你把它分成三个部分:givenwhenthen。以这种方式思考单元测试是很有用的:

  • 鉴于某种条件
  • 当某一行动发生时...
  • 就会出现一个预期的结果。

在这种情况下,你得到的可用资金是小数(100)。当你通过init(availableFunds:)创建sut时,那么你希望sut.availableFunds等于availableFunds

Note

如果givenwhenthen部分非常简单,你可以选择省略这些注释行。我们在本章中包含了这些注释,以使其清晰明了,但在你自己的项目中,请用你自己的判断来决定是拥有还是省略这些注释使代码更容易阅读。

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是否有意义?这对于让testInittestInitAvailableFunds都能编译是很有用的,但这个类是否真的应该有这个?

归根结底,这是一个设计决定:

  • 如果你选择保留默认参数,你可以考虑为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)

这段代码在testInitAvailableFundstestAddItem中是通用的。为了消除这种重复,你将在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()
}

下面是这个的作用:

  1. setUp()中,你首先调用super.setUp(),给超类一个机会来做设置。然后你设置availableFundsSut
  2. tearDown()中,你做的是相反的事情。你将availableFundssut设置为nil,最后调用super.tearDown()

tearDown()中,你应该总是将你在setUp()中设置的任何属性置空。这是由XCTest框架的工作方式决定的。它在你的测试目标中实例化每个XCTestCase子类,直到所有的测试用例运行完毕才释放它们。因此,如果你有很多测试用例,而你没有在tearDown中把它们的属性设置为nil,你就会保留属性的内存超过你需要的时间。如果有足够多的测试用例,这甚至会导致运行测试时的内存和性能问题。

Note

还有其他的设置/拆除方法:setUpWithError() throwstearDownWithError() throws。如果你的代码有可能在设置和/或拆除过程中抛出一个错误,请使用这些方法。

现在你可以使用这些实例属性来摆脱测试方法中重复的逻辑。用这一行替换testInitAvailableFunds的内容:

XCTAssertEqual(sut.availableFunds, availableFunds)

这使得它非常容易阅读,这样就不需要givenwhen的注释了。

接下来,将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_oneItemtestAddItem_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循环。这有四个步骤。

  1. 红色:写一个失败的测试。
  2. 绿色:使测试通过。
  3. 重构:清理你的应用程序和测试代码。
  4. 重复:再做一次,直到所有的功能都实现。

Xcode playgrounds是学习新概念的好方法,就像您在本章中学习TDD循环一样。然而,在现实世界的开发中,你通常在你的iOS项目中创建单元测试目标,而不是使用playground。幸运的是,TDD在应用程序中的效果甚至比playground更好

继续看下一节,了解在iOS应用程序中使用TDD