原文地址 翻译:DeveloperLx
单元测试是我们都深深知道需要去做的事之一,但看起来非常得困难和无聊,是一个很辛苦的工作。
编写代码去完成令人激动的事情是多么得有趣,为何人们要花费一半的时间编写代码只是为了进行测试?
为了 把握性 !在本教程中,你将学习如何测试你的代码,以此增强对代码能够正确完成你所期望事情,适应变化,不造成问题的把握力。
本项目使用Swift 3语言,Xcode 8 beta 6以上版本。下载 起始项目 并打开。
假如你已完成过raywenderlich.com中这里的其它教程,你可能会期望拿到这里运行。但这次不会去测试这些。点击 Product 菜单并选择 Test 。注意快捷键 — Command-U — 你将在本教程中使用多次。
当你运行测试时,Xcode将构建app,你会看到几秒钟app的窗口,然后才给出信息“Test Succeeded”。在左侧的 Navigator 面板中,选择 Test navigator 。
这里展示了默认添加的三个测试;每个的旁边都有一个绿色的标记,表示该测试已通过。要查看包含这些测试的文件,可以点击 Test Navigator 中的第二行 High RollerTests ,它带有一个大写T的图标,表示其层级更高。
这里有一些很重要的事值得注意:
-
导入的
XCTest
是由Xcode提供的测试框架。
@testable import High_Roller
则让代码可以访问High_Roller
模块中的所有代码。每个测试文件都需要这样的两个导入。 -
setup()
和tearDown()
:两个方法会在: 每个单个的 测试方法被调用之前和之后调用。 -
testExample()
和testPerformanceExample()
:实际的测试。第一个测试功能,第二个则测试性能。每个测试方法的名称都必须以:test
开头,这样才能被Xcode识别为一个测试的方法去执行。
在你开始编写你的测试之前,我们需要进行一个简短的讨论,单元测试到底是什么,你为何应当使用它。
单元测试是用来测试你的一段代码的功能。它并不包含在你的app之中,但可以在开发期间测试代码是否符合你的期望。
对于单元测试,常见的第一反应是:“你要我写 两次 的代码?一次为了app本身, 另一次 则用来测试这个方法?”实际上有可能比这更糟 — 一些项目的测试代码可能会比产品本身的代码 更多 。
首先,看起来这非常浪费时间和精力 — 但当一个测试捕捉到了你之前未注意过的问题,或警告你出现了副作用的时候,你就会明白它是一个多么棒的工具了。慢慢地,你就会感到一个没有单元测试的项目是多么得脆弱,你做出任何的改动都会顾虑重重,因为你无法确定将会发生什么。
测试驱动开发(Test Driven Development TDD)是单元测试的一个分支,你会从这里开始测试,且只编写测试所需求的代码。这一开始看起来是个非常奇怪的处理方式,且会产生一些非常奇怪的代码。但最终你会发现,它可以在你编码之前帮助你思考编码的目的。
TDD有三个重复的步骤:
- 红色 :编写一个失败的测试。
- 绿色 :编写可以使测试通过的最小代码集。
- 重构 :可选的步骤;如果一个任何的app或测试代码可以通过重构来让它变得更好,那就这么做。
对于有效的TDD,顺序是非常重要和关键的。修复一个失败的测试,可以帮助你了解代码到底在做什么。如果你的测试在没有任何新编写代码的情况下,第一次就通过了,你就无法确知下一阶段的开发该做些什么。
开始,你将使用TDD编写一系列测试和伴随的代码。
这个项目是棋盘玩家的投骰子工具。有过和家人坐在一起玩游戏,但发现骰子却被狗吃了的经历么?这个App可以帮助你解决烦恼。如果有人说“我不相信计算机不会作弊!”你就可以自豪地说这个app已通过了单元测试,证明它可以正确地工作。这一定会给你的家人留下深刻的印象 — 让你们今晚的游戏可以继续下去。:]
这个app的model包含两个主要的对象类型:一个是
Dice
,它包含一个
value
property和一个用来生成任意值的方法;另一个是
Roll
,它含有一个
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 ,在下次构建之前,错误并不会消失。然而,你现在可以以几种不同的方式来运行测试。
如果你点击测试方法旁边的菱形,就 只会运行这一个测试 。现在尝试一把,菱形就会变成绿色的勾,表示这个测试已通过。
任何时候,你都可以点击这个绿色的标记(或表示失败测试的红色标记)来运行测试。这时在类名旁边就会出现另一个绿色的标记。点击它就会运行 在这个类中的 所有测试。此刻点击它和运行单个测试还没有什么区别,但很快就会发生变化。
测试你代码的最后一种方式就是运行所有的测试。
按下 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测试不会再被自动执行了,但仍然可以手动地让它执行。
我们应当选择哪个方法来运行测试?单个,类中,或是全部?
如果你正基于某一个测试工作,通常就选择测试它本身或所在的整个类。通过一个测试后,检查它是否对其它的东西造成了破坏就变得非常关键,因此你应当在这时执行一次完整的测试。
为了进展得更容易些,在 primary editor 中打开 DiceTests.swift ,而在 assistant editor 中打开 Dice.swift 。这是一个非常方便的工作方式,便于完成TDD的序列。
这就完成了TDD序列的第二个步骤。由于没有进行过重构,因此现在就应当返回步骤一,来编写另一个失败的测试。
每个
Dice
对象都有一个
value
,当
Dice
被初始化时,它的值应当为
nil
。
添加下列的测试到 DiceTests.swift 中:
// 1
func testValueForNewDiceIsNil() {
let testDie = Dice()
// 2
XCTAssertNil(testDie.value, "Die value should be nil after init")
}
上述的测试:
-
方法的名称以
'test'
开头,而剩余的部分则表明测试什么。 -
本测试使用
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
结构体最终产生的是随机的值,但由于目前为止,你还没有编写测试来验证这点,因此这个方法还是能够通过目前测试的最小代码集。再次运行测试来证明这点。
拓宽你的思路 — 接下来的几个测试旨在塑造你代码的组织方式。开始你会感到是不又要返工了,但实际上这是让你可以聚焦在你代码真实意图的强有力的方式。
一个标准的骰子有6个面,因此任意一次滚动得出的值都应该在一和六之间。切到
DiceTests.swift
并添加下列的测试,现在又引入了两个
XCTAssert
方法:
func testDiceRoll_ShouldBeFromOneToSix() {
var testDie = Dice()
testDie.rollDie()
XCTAssertTrue(testDie.value! >= 1)
XCTAssertTrue(testDie.value! <= 6)
XCTAssertFalse(testDie.value == 0)
}
运行测试,现在两个断言都会失败。修改
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)")
}
}
上述的测试代码:
-
rollCounter
指示骰子将被滚动的次数。我们认为相应于每个期望的数字滚动100次是一个大致合理的样本数量。 -
如果任何一次循环后value没有值,测试会失败并立刻退出。
XCTFail()
类似于一个断言,它永远都不会通过,非常适合于guard
语句搭配使用。 - 每次滚动之后,你都将结果保存到一个字典中。
- 这个断言确定字典中有六个key,它们都是滚动骰子所期望得到的数字。
-
这个测试使用了一个新的断言:
XCTAssertEqualWithAccuracy()
,它可以进行不精确的比较。由于XCTAssertEqualWithAccuracy()
会被调用非常多次,因此用可选的信息来表示哪一部分的循环失败了。
运行测试,如你所料,测试因为每次滚动都得到的是1失败了。切到 Issue Navigator 可以查看更多详细的错误信息。
最后,添加随机数字生成器到
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面的...该怎么办?
你不想让你的朋友扫兴,因此回到 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 来查看全部的细节。
你应当期望:
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)
}
再次运行所有的测试,来证实上述重构正确。
为了演示另一项非常有用的测试技术,切回
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类。
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部分 ,来学习更多优秀的特性,包括交互测试,网络测试,性能测试和代码覆盖。希望可以在这里看到你!:]