Skip to content

Latest commit

 

History

History
1446 lines (1428 loc) · 56.3 KB

Unit Testing on macOS - Part 1:2.md

File metadata and controls

1446 lines (1428 loc) · 56.3 KB

单元测试:1/2部分

Unit-Testing-macOS

单元测试是我们都深深知道需要去做的事之一,但看起来非常得困难和无聊,是一个很辛苦的工作。

编写代码去完成令人激动的事情是多么得有趣,为何人们要花费一半的时间编写代码只是为了进行测试?

为了 把握性 !在本教程中,你将学习如何测试你的代码,以此增强对代码能够正确完成你所期望事情,适应变化,不造成问题的把握力。

入门

本项目使用Swift 3语言,Xcode 8 beta 6以上版本。下载 起始项目 并打开。

假如你已完成过raywenderlich.com中这里的其它教程,你可能会期望拿到这里运行。但这次不会去测试这些。点击 Product 菜单并选择 Test 。注意快捷键 — Command-U — 你将在本教程中使用多次。

当你运行测试时,Xcode将构建app,你会看到几秒钟app的窗口,然后才给出信息“Test Succeeded”。在左侧的 Navigator 面板中,选择 Test navigator

TestNavigator2

这里展示了默认添加的三个测试;每个的旁边都有一个绿色的标记,表示该测试已通过。要查看包含这些测试的文件,可以点击 Test Navigator 中的第二行 High RollerTests ,它带有一个大写T的图标,表示其层级更高。

DefaultTests3

这里有一些很重要的事值得注意:

  • 导入的 XCTest 是由Xcode提供的测试框架。 @testable import High_Roller 则让代码可以访问 High_Roller 模块中的所有代码。每个测试文件都需要这样的两个导入。
  • setup() tearDown() :两个方法会在: 每个单个的 测试方法被调用之前和之后调用。
  • testExample() testPerformanceExample() :实际的测试。第一个测试功能,第二个则测试性能。每个测试方法的名称都必须以: test 开头,这样才能被Xcode识别为一个测试的方法去执行。

神马是单元测试?

在你开始编写你的测试之前,我们需要进行一个简短的讨论,单元测试到底是什么,你为何应当使用它。

单元测试是用来测试你的一段代码的功能。它并不包含在你的app之中,但可以在开发期间测试代码是否符合你的期望。

对于单元测试,常见的第一反应是:“你要我写 两次 的代码?一次为了app本身, 另一次 则用来测试这个方法?”实际上有可能比这更糟 — 一些项目的测试代码可能会比产品本身的代码 更多

首先,看起来这非常浪费时间和精力 — 但当一个测试捕捉到了你之前未注意过的问题,或警告你出现了副作用的时候,你就会明白它是一个多么棒的工具了。慢慢地,你就会感到一个没有单元测试的项目是多么得脆弱,你做出任何的改动都会顾虑重重,因为你无法确定将会发生什么。

测试驱动开发

测试驱动开发(Test Driven Development TDD)是单元测试的一个分支,你会从这里开始测试,且只编写测试所需求的代码。这一开始看起来是个非常奇怪的处理方式,且会产生一些非常奇怪的代码。但最终你会发现,它可以在你编码之前帮助你思考编码的目的。

TDD有三个重复的步骤:

  1. 红色 :编写一个失败的测试。
  2. 绿色 :编写可以使测试通过的最小代码集。
  3. 重构 :可选的步骤;如果一个任何的app或测试代码可以通过重构来让它变得更好,那就这么做。

对于有效的TDD,顺序是非常重要和关键的。修复一个失败的测试,可以帮助你了解代码到底在做什么。如果你的测试在没有任何新编写代码的情况下,第一次就通过了,你就无法确知下一阶段的开发该做些什么。

开始,你将使用TDD编写一系列测试和伴随的代码。

dog

测试项目

这个项目是棋盘玩家的投骰子工具。有过和家人坐在一起玩游戏,但发现骰子却被狗吃了的经历么?这个App可以帮助你解决烦恼。如果有人说“我不相信计算机不会作弊!”你就可以自豪地说这个app已通过了单元测试,证明它可以正确地工作。这一定会给你的家人留下深刻的印象 — 让你们今晚的游戏可以继续下去。:]

这个app的model包含两个主要的对象类型:一个是 Dice ,它包含一个 value property和一个用来生成任意值的方法;另一个是 Roll ,它含有一个 Dice 对象的集合,并附有一起滚动骰子,计算总值等等的方法。

第一个测试类针对的是 Dice 对象类型。

Dice测试类

在Xcode中切到 File Navigator 并选择 High RollerTests 组。选择 File/New/File… ,然后点击 macOS/Unit Test Case Class 。点击 Next 并将类命名为 DiceTests 。语言设为Swift。点击 Next Create

选择类内部的全部代码并删除。添加下列的代码到 DiceTests.swift 中,就在 import XCTest 这行的下方:

@testable import High_Roller

现在你就可以删除 HighRollerTests.swift 了,因为你不再需要默认的测试。

第一件要测试的事是 Dice 对象可否被创建。

你的第一个测试

DiceTests 类中,添加下列的测试方法:

  func testForDice() {
    let _ = Dice()
  }

在你运行测试之前,这里会爆出一个编译错误: "Use of unresolved identifier 'Dice'" 。在TDD中,一个未能编译通过的测试会被认做是失败的测试,因此你现在只是完成了TDD顺序中的第一步。

要用最少的代码使这里的代码测试通过,切到 File Navigator ,并在主 High Roller 组中选择 Model 组。点击 File/New/File… 创建一个新的Swift文件并命名为 Dice.swift

添加下列的代码到文件中:

struct Dice {
}

回到 DiceTests.swift ,在下次构建之前,错误并不会消失。然而,你现在可以以几种不同的方式来运行测试。

如果你点击测试方法旁边的菱形,就 只会运行这一个测试 。现在尝试一把,菱形就会变成绿色的勾,表示这个测试已通过。

任何时候,你都可以点击这个绿色的标记(或表示失败测试的红色标记)来运行测试。这时在类名旁边就会出现另一个绿色的标记。点击它就会运行 在这个类中的 所有测试。此刻点击它和运行单个测试还没有什么区别,但很快就会发生变化。

测试你代码的最后一种方式就是运行所有的测试。

RunningTests

按下 Command-U 键就可以运行全部的测试,然后切到 Test Navigator ,你就可以在High RollerTests部分看到你单个的测试。可能你需要将此部分展开才能看到。绿色的勾会出现在每个测试的旁边。如果你将鼠标指针在列表中上下移动,你就会看到出现了小小的播放按钮,你可以点击它来运行任意测试或测试的集合。

Test Navigator 中,你看到 High RollerUITests 也会被运行。带有UI测试的问题是会变慢。你希望你的测试能够尽可能地快,以便频繁地进行测试。要解决这个问题,就需要编辑scheme来使得UI测试不会自动运行。

在工具栏scheme的弹出菜单中选择 Edit scheme… 。点击左侧面板中的. Click Test ,然后取消勾选 High RollerUITests 。关闭scheme的窗口,然后按 Command-U 键再次运行你的测试。这时在 Test Navigator 中,你就会发现UI测试不会再被自动执行了,但仍然可以手动地让它执行。

TurnOffUITests

选择运行哪个测试

我们应当选择哪个方法来运行测试?单个,类中,或是全部?

如果你正基于某一个测试工作,通常就选择测试它本身或所在的整个类。通过一个测试后,检查它是否对其它的东西造成了破坏就变得非常关键,因此你应当在这时执行一次完整的测试。

为了进展得更容易些,在 primary editor 中打开 DiceTests.swift ,而在 assistant editor 中打开 Dice.swift 。这是一个非常方便的工作方式,便于完成TDD的序列。

这就完成了TDD序列的第二个步骤。由于没有进行过重构,因此现在就应当返回步骤一,来编写另一个失败的测试。

测试nil

每个 Dice 对象都有一个 value ,当 Dice 被初始化时,它的值应当为 nil

添加下列的测试到 DiceTests.swift 中:

  // 1
func testValueForNewDiceIsNil() {
let testDie = Dice()
// 2
XCTAssertNil(testDie.value, "Die value should be nil after init")
}

上述的测试:

  1. 方法的名称以 'test' 开头,而剩余的部分则表明测试什么。
  2. 本测试使用 XCTAssert 方法之一来确认value是 nil XCTAssertNil() 方法的第二个参数是一个可选的字符串,当测试失败的时候,用来提供错误信息。我通常偏好使用描述性较强的方法名称,而将这个参数置空,来保持实际测试的代码整洁易读。

这个测试代码会产生一个编译错误: “Value of type 'Dice' has no member 'value'”

为修复这个错误,在 Dice.swift 中添加下列的property声明到 Dice 的结构体中:

  var value: Int?

在app构建之前, DiceTests.swift 中的编译错误并不会消失。按下 Command-U 键来构建app并运行测试,这时测试就应该通过了。此时这里就没有需要重构的地方了。

每个 Dice 对象都应该可以“滚动”并生成它的value。添加下一个测试到 DiceTests.swift 中:

  func testRollDie() {
var testDie = Dice()
testDie.rollDie()
XCTAssertNotNil(testDie.value)
}

这个测试使用了 XCTAssertNotNil() 方法来替换之前测试中的 XCTAssertNil()

由于Dice结构体还没有 rollDie() 方法,此时必然就会出现另一个编译错误。为了修复它,切回到 Assistant Editor 中,并添加下列代码到 Dice.swift 中:

  func rollDie() {
}

运行测试,你会看到一个警告,关于使用 var 来替换 let ,以及一个 XCTAssert 这次将会失败的提示。这是讲得通的,因为 rollDie() 到现在还未做任何事。将 rollDie() 修改为如下的代码:

  mutating func rollDie() {
value = 0
}

现在你已明白了TDD如何产生一些奇怪的代码。你很清楚 Dice 结构体最终产生的是随机的值,但由于目前为止,你还没有编写测试来验证这点,因此这个方法还是能够通过目前测试的最小代码集。再次运行测试来证明这点。

Developing to Tests

拓宽你的思路 — 接下来的几个测试旨在塑造你代码的组织方式。开始你会感到是不又要返工了,但实际上这是让你可以聚焦在你代码真实意图的强有力的方式。

一个标准的骰子有6个面,因此任意一次滚动得出的值都应该在一和六之间。切到 DiceTests.swift 并添加下列的测试,现在又引入了两个 XCTAssert 方法:

  func testDiceRoll_ShouldBeFromOneToSix() {
var testDie = Dice()
testDie.rollDie()
XCTAssertTrue(testDie.value! >= 1)
XCTAssertTrue(testDie.value! <= 6)
XCTAssertFalse(testDie.value == 0)
}

one-sided_dice2

运行测试,现在两个断言都会失败。修改 rollDie() 方法,将 value 设置为1。这次就可以通过测试了,但这样的骰子仍没神马用处!:]

换一个思路,我们何不测试滚动骰子多次,然后统计生成的每种value的个数?可能无法做到完美,但一个足够大的样本数量应该可以足够接近你的测试意图。

DiceTests.swift 中添加另一个测试:

  func testRollsAreSpreadRoughlyEvenly() {
var testDie = Dice()
var rolls: [Int: Double] = [:]
// 1
let rollCounter = 600.0
for  in 0 ..< Int(rollCounter) {
testDie.rollDie()
guard let newRoll = testDie.value else {
// 2
XCTFail()
return
}
// 3
if let existingCount = rolls[newRoll] {
rolls[newRoll] = existingCount + 1
} else {
rolls[newRoll] = 1
}
}
// 4
XCTAssertEqual(rolls.keys.count, 6)
// 5
for (key, roll) in rolls {
XCTAssertEqualWithAccuracy(roll,
rollCounter / 6,
accuracy: rollCounter / 6 * 0.3,
"Dice gave (roll) x (key)")
}
}

上述的测试代码:

  1. rollCounter 指示骰子将被滚动的次数。我们认为相应于每个期望的数字滚动100次是一个大致合理的样本数量。
  2. 如果任何一次循环后value没有值,测试会失败并立刻退出。 XCTFail() 类似于一个断言,它永远都不会通过,非常适合于 guard 语句搭配使用。
  3. 每次滚动之后,你都将结果保存到一个字典中。
  4. 这个断言确定字典中有六个key,它们都是滚动骰子所期望得到的数字。
  5. 这个测试使用了一个新的断言: XCTAssertEqualWithAccuracy() ,它可以进行不精确的比较。由于 XCTAssertEqualWithAccuracy() 会被调用非常多次,因此用可选的信息来表示哪一部分的循环失败了。

运行测试,如你所料,测试因为每次滚动都得到的是1失败了。切到 Issue Navigator 可以查看更多详细的错误信息。

IssueNavigator

最后,添加随机数字生成器到 rollDie() 中。在 Dice.swift 中,将该方法修改成如下的样子:

  mutating func rollDie() {
value = Int(arc4random_uniform(UInt32(6))) + 1
}

上述代码使用了 arc4random_uniform() 产生一个1到6之间的数字。看起来非常得简单,但仍然需要进行测试!再次按下 Command-U 键,所有的测试都通过了。你现在就可以确信 Dice 结构体大致是以你所期望的比例产生数字了。如果有人说你的app作弊,你就可以把测试结果给它们看说不是的!

完工大吉了! Dice 结构体已完成,喝茶时间...

如果你有这样的朋友,他玩过很多的角色扮演游戏,说你的app不支持多种类型的骰子:四面的,8面的,12面的,20面的甚至100面的...该怎么办?

Dice

修改代码

你不想让你的朋友扫兴,因此回到 DiceTests.swift 并添加另一个测试:

  func testRollingTwentySidedDice() {
var testDie = Dice()
testDie.rollDie(numberOfSides: 20)
XCTAssertNotNil(testDie.value)
XCTAssertTrue(testDie.value! >= 1)
XCTAssertTrue(testDie.value! <= 20)
}

编译器会抱怨说 rollDie() 不可以传任何参数。切到 assistant editor 并在 Dice.swift 中修改 rollDie() 的声明,来添加一个 numberOfSides 的形参:

  mutating func rollDie(numberOfSides: Int) {

但这会导致之前的测试失败,因为它们并没有提供参数。你 可以 将它们全部修改,但大多数的骰子都是6个面的(无需告知你角色扮演的朋友这点)。那么何不给 numberOfSides 参数一个默认值?

rollDie(numberOfSides:) 的声明修改为:

  mutating func rollDie(numberOfSides: Int = 6) {

现在所有的测试就都通过了,但你还在处在之前相同的位置:测试并不会检查当20个面的骰子滚动的时候,产生的值是1到20之间的。

因此现在需要写另一个类似于 testRollsAreSpreadRoughlyEvenly() 的测试了,但针对的是20个面的骰子。

  func testTwentySidedRollsAreSpreadRoughlyEvenly() {
var testDie = Dice()
var rolls: [Int: Double] = [:]
let rollCounter = 2000.0
for  in 0 ..< Int(rollCounter) {
testDie.rollDie(numberOfSides: 20)
guard let newRoll = testDie.value else {
XCTFail()
return
}
if let existingCount = rolls[newRoll] {
rolls[newRoll] = existingCount + 1
} else {
rolls[newRoll] = 1
}
}
XCTAssertEqual(rolls.keys.count, 20)
for (key, roll) in rolls {
XCTAssertEqualWithAccuracy(roll,
rollCounter / 20,
accuracy: rollCounter / 20 * 0.3,
"Dice gave (roll) x (key)")
}
}

这个测试给出了7个失败:key的数量只有6个,且它们的分布并不相等。使用 Issue Navigator 来查看全部的细节。

IssueNavigator2

你应当期望: rollDie(numberOfSides:) 还不使用 numberOfSides 参数。

numberOfSides 来替换 arc4random_uniform() 中的6,然后再次按下 Command-U 键。

成功了!所有的测试都通过了 - 甚至包括之前调用了你刚修改过的方法的那些测试。

重构测试

你有一些很值得去重构的代码。 testRollsAreSpreadRoughlyEvenly() testTwentySidedRollsAreSpreadRoughlyEvenly() 中的代码非常相似,因此你可以将其分离出来作为一个私有方法。

添加下列的extension到 DiceTests.swift 文件的尾部,要在类的外部:

extension DiceTests {
fileprivate func performMultipleRollTests(numberOfSides: Int = 6) {
var testDie = Dice()
var rolls: [Int: Double] = [:]
let rollCounter = Double(numberOfSides) * 100.0
let expectedResult = rollCounter / Double(numberOfSides)
let allowedAccuracy = rollCounter / Double(numberOfSides) * 0.3
for _ in 0 ..< Int(rollCounter) {
testDie.rollDie(numberOfSides: numberOfSides)
guard let newRoll = testDie.value else {
XCTFail()
return
}
if let existingCount = rolls[newRoll] {
rolls[newRoll] = existingCount + 1
} else {
rolls[newRoll] = 1
}
}
XCTAssertEqual(rolls.keys.count, numberOfSides)
for (key, roll) in rolls {
XCTAssertEqualWithAccuracy(roll,
expectedResult,
accuracy: allowedAccuracy,
"Dice gave (roll) x (key)")
}
}
}

这个方法的名称并不以 test 开头,因此不会被当做一个测试去运行。

回到主 DiceTests 类,并将 testRollsAreSpreadRoughlyEvenly() testTwentySidedRollsAreSpreadRoughlyEvenly() 方法替换为下列的代码:

  func testRollsAreSpreadRoughlyEvenly() {
performMultipleRollTests()
}
func testTwentySidedRollsAreSpreadRoughlyEvenly() {
performMultipleRollTests(numberOfSides: 20)
}

再次运行所有的测试,来证实上述重构正确。

使用 #line

为了演示另一项非常有用的测试技术,切回 Dice.swift 并撤销你在 rollDie(numberOfSides:) 中做的有关于20面的骰子的改动:将调用 arc4random_uniform() 中的 numberOfSides 替换为 6 。现在再次运行测试。

testTwentySidedRollsAreSpreadRoughlyEvenly() 失败了,但错误信息却位于 performMultipleRollTests(numberOfSides:) 中 — 不是一个非常有用的地点。

Xcode可以帮助你解决这个问题。当定义一个助手方法的时候,你可以提供一个带有特定默认值 #line 的参数 - 它会包含调用这个方法时的代码的行序号。这个行序号就可以被用到 XCTAssert 方法中,使得错误的信息更有价值。

DiceTests 的extension中,将方法的定义 performMultipleRollTests(numberOfSides:) 修改为如下的代码:

fileprivate func performMultipleRollTests(numberOfSides: Int = 6, line: UInt = #line) {

XCTAsserts 修改为如下的样子:

XCTAssertEqual(rolls.keys.count, numberOfSides, line: line)
for (key, roll) in rolls {
XCTAssertEqualWithAccuracy(roll,
expectedResult,
accuracy: allowedAccuracy,
"Dice gave (roll) x (key)",
line: line)
}

你无需修改调用 performMultipleRollTests(numberOfSides:line:) 方法的代码,因为新的参数会被默认值自动地填充。再次运行测试,你就会发现现在错误的记号位于调用 performMultipleRollTests(numberOfSides:line:) 方法这行了 - 而不是在助手方法之中。

rollDie(numberOfSides:) 方法恢复原状,然后按 Command-U 键确认一切工作正常。

给自己点个赞吧 - 你已学会了如何使用TDD来开发一个经过完整测试的model类。

victory

添加单元测试到已存在的代码中

TDD在开发新的代码时很棒,但你经常会不得不在之前已有的代码中加入测试。步骤和之前基本相同,除了你现在是编写测试,来确认已存在的代码如同期望中的方式工作。

要学习该怎么做,你要为 Roll 结构体添加测试。在这个app中, Roll 包含了一个 Dice 的数组和一个 numberOfSides 的property。它用来处理滚动所有的筛子及统计滚动的结果。

回到 File Navigator ,选择 Roll.swift 。将全部占位的代码替换为如下内容:

struct Roll {
var dice: [Dice] = []
var numberOfSides = 6
mutating func changeNumberOfDice(newDiceCount: Int) {
dice = []
for _ in 0 ..< newDiceCount {
dice.append(Dice())
}
}
var allDiceValues: [Int] {
return dice.flatMap { $0.value}
}
mutating func rollAll() {
for index in 0 ..< dice.count {
dice[index].rollDie(numberOfSides: numberOfSides)
}
}
mutating func changeValueForDie(at diceIndex: Int, to newValue: Int) {
if diceIndex < dice.count {
dice[diceIndex].value = newValue
}
}
func totalForDice() -> Int {
let total = dice
.flatMap { $0.value }
.reduce(0) { $0 - $1 }
return total
}
}

(发现错误了么?现在先忽略它,这就是我们将要测试的地方。:])

File Navigator 中选择 High RollerTests 组,并使用 File/New/File… 中的 macOS/Unit Test Case Class 来添加一个叫做 RollTests 的类。删除其中所有的测试代码。

添加下列的import到 RollTests.swift 中:

@testable import High_Roller

在assistant editor中打开 Roll.swift ,你将在其中编写更多的测试。

首先,你想去测试 Roll 可否被创建,且可以添加 Dice dice 数组中。测试使用5个骰子。

添加测试到 RollTests.swift 中:

  func testCreatingRollOfDice() {
var roll = Roll()
for  in 0 ..< 5 {
roll.dice.append(Dice())
}
XCTAssertNotNil(roll)
XCTAssertEqual(roll.dice.count, 5)
}

运行测试。目前一切都好 - 第一个测试通过了。和TDD不同,一个失败的测试并非是基本的第一步,因为代码(理论上)早已可以正常地工作。

接下来,使用下列的测试,来检查在滚动之前,点数的总数为0:

  func testTotalForDiceBeforeRolling_ShouldBeZero() {
var roll = Roll()
for  in 0 ..< 5 {
roll.dice.append(Dice())
}
let total = roll.totalForDice()
XCTAssertEqual(total, 0)
}

再次成功了,但看起来似乎需要进行一些重构。每个测试的第一部分都设置了一个 Roll 对象,并使用5个骰子来填充它。如果将它移到 setup() 方法中,它就会在每个测试前执行。

不仅如此, Roll 有一个方法来改变自身数组中 Dice 的个数,因此测试也可以使用和测试这里。

RollTests 类的内容替换为:

  var roll: Roll!
override func setUp() {
super.setUp()
roll = Roll()
roll.changeNumberOfDice(newDiceCount: 5)
}
func testCreatingRollOfDice() {
XCTAssertNotNil(roll)
XCTAssertEqual(roll.dice.count, 5)
}
func testTotalForDiceBeforeRolling_ShouldBeZero() {
let total = roll.totalForDice()
XCTAssertEqual(total, 0)
}

按照惯例,再次运行测试,来检查一切是否正常工作。

在6面骰子的情况下,最小的总数应当是5,而最大的总数则应是30,因此添加下列的测试来验证总数是否处于这个范围之中:

  func testTotalForDiceAfterRolling_ShouldBeBetween5And30() {
roll.rollAll()
let total = roll.totalForDice()
XCTAssertGreaterThanOrEqual(total, 5)
XCTAssertLessThanOrEqual(total, 30)
}

运行测试 - 它失败了!看起来测试已经发现了一个代码中的bug。问题应该是在 rollAll() totalForDice() 中,因为这个测试中只调用过这两个方法。如果 rollAll() 失败的话,总数应该是0.然而,返回的总数是一个负值,因此让我们来看一看 totalForDice() 方法。

问题就在这里: reduce 是减而不是加value。将减号改为加号:

func totalForDice() -> Int {
let total = dice
.flatMap { $0.value }
// .reduce(0) { $0 - $1 }       // bug line
.reduce(0) { $0 + $1 }          // fixed
return total
}

再次运行你的测试 - 现在一切都完美地运行了。

从这儿去向哪里?

你可以在 这里 下载本教程中,这一部分的完整的测试。

请继续阅读 单元测试:2/2部分 ,来学习更多优秀的特性,包括交互测试,网络测试,性能测试和代码覆盖。希望可以在这里看到你!:]