iOS 测试驱动开发
iOS 测试驱动开发
测试驱动开发方法的优点:
- 只会包含功能的最简实现代码
- 更好地模块化设计, 包括清晰的模块边界, 以及合理的抽象.
- 提高代码的可维护性
- 代码更易重构, 因为有单元测试保证代码重构后的功能不会改变或出错.
- 测试就可以作为代码的"文档"
- 减少 debug 的时间
测试驱动开发实践中的注意事项:
- 要在清楚需要解决的问题之后, 再开始写测试和实现, 否则再多的单元测试也是无用功或者是用反功.
- 在 TDD 的初期, 可能开发速度会相对更慢
- 团队中的所有成员都必须接受 TDD 思想和 TDD 实践, 包括设计和施工的整个阶段.
- 当需求变更时, 之前的测试需要被维护, 这个也是一个主要的时间开销.(但需求的变更导致的是实现的扩展而非修改, 故应从新的功能入手进行 TDD, 而不是去修改老的测试和实现.) 这点是必须要注意的, 如果需求的更改导致测试和实现的修改, 这并非一个好现象.
1 实践指南概览
开始时, 所有人都会问这个问题: 在测试驱动开发的实践中, 应该对什么样的代码写测试? 答案是: 对所有的代码写测试. 原因很简单: 写实现代码的唯一驱动力是—测试.
但是在实践中, 这样的实践有时是非常困难的, 比如: 一个按钮的颜色和位置需要被测试吗? 视图的层级需要被测试吗? 当然不是, 因为它们和 app 的功能是不相关的, 即视图内容往往是在 UI 测试中进行的.
单元测试仅仅是针对控制器角色和模型角色上的测试.
另外, 不要在单元测试中进行集成测试, 日常开发过程中执行的单元测试应该是小且快的, 而集成测试通常是在每天晚上的集成环节去进行, 它们可以是比较慢的. 这样就需要知道如何在 Xcode 中针对当前需要来运行不同的测试.
2 完整例子(TODO-List APP)
在 TDD 实践的开始, 要把思想调整过来, 即: 在拿到一个需求并进行分析后, 下一步要想的不是如何去实现这个功能, 而是想如何对这个功能进行测试.
在测试中去明确这个功能应完成什么样的动作, 而不是去明确这个功能应该如何实现. 这样的话, 无论功能实现如何修改, 也不会影响到测试代码的书写, 最多只是影响到测试的执行结果. TDD 的思维过程必须经过长时间练习来掌握, 从而形成一种习惯.
余下的部分来完成一款 ToDo-List APP, 使用 TDD 开发方法. 这款 APP 包含如下内容:
- 一个任务列表视图, 显示所有的任务
- 一个任务详情视图, 显示单一任务的详情
- 一个任务输入视图, 用于新建任务
2.1 APP 描述
启动 APP 后, 用户首先看到的是任务列表, 即所有的任务集合. 在列表中的每一条任务都包含有: 任务标题, 任务描述(可选), 到期时间.
新的任务可以通过点击右上角的加号添加, 加号位于导航栏上.
用户可以选择任务标记为已完成, 这样任务就会自动放入已完成部分. 并且可以将已完成的任务再次标记为未完成. 用户还可以把任务全部删除.
另外点击一个任务后, 可以进入任务的详情页.
用户故事:(一次描述一件简单的要求)
- 作为一个用户, 我希望当启动 APP 后就看到一个任务列表
- 作为一个用户, 我希望在 APP 中能进行添加任务操作
- 作为一个用户, 我希望能够对任务进行已完成标记, 也能够对已标记的任务取消完成标记.
- 作为一个用户, 我希望标记完成的任务自动跑到列表的已完成部分.
- 作为一个用户, 我希望可以把所有的任务删除.
- 作为一个用户, 我希望当点击任务列表中的任务时, 可以看到它的详情
- 作为一个用户, 我希望可以在任务详情中也能将它标记为完成.
- 作为一个用户, 假设我点击任务列表上方的加号时, 我希望看到一个新建任务的表单, 并可以对表单进行填写.
- 作为一个用户, 假设我点击新建任务表单上的保存按钮后, 我希望看到新建的任务出现在任务列表中.
通过这个列表整理出需求, 然后提取出所需功能并进行实现.
2.2 APP 的结构设计
下图即该 APP 的结构设计:
APP 总体结构
其中 ToDoItemTableViewDataProvider
作为 TableView 的代理和数据源使用.
在开发过程中, 一般都是按功能模块来进行开发的, 不过这里为了便于描述, 这里按照角色定位来逐个实现.
2.3 APP 模型的实现
首先实现如下三个类型:
-
ToDoItem
结构体 -
Location
结构体 -
ItemManager
类
下面就来看如何用 TDD 来实现这三个类型.
首先是 ToDoItem
结构体, 很多人都想知道这个结构体有什么可以测试的?
在 Xcode 中, 每一个单元测试文件中的测试都是集成自一个 XCTestCase, 表明它是一个测试用例.
为了对实现进行驱动, 就要找到可以失败的单元测试. 要判断单元测试是否是必要的, 首要条件就是它能否驱动实现.
Xcode 中有一个分屏的小技巧, 按住 option 键然后点击一个文件, 那个文件就自动出现在分屏界面中了.
当进行测试时, 如果有 sut 对象(System Under Test 对象), 如果多个单元测试中都用到同样的对象, 则可以将它在 setup 中实例化, 然后在 tearDown 中销毁.
在修改了测试之后, 有一个小技巧来保证测试的效果不变, 即可以暂时性改变实现来达到目的.
2.4 APP 视图控制器的实现
iOS 中的 ViewController 只是作为视图层的内容, 不要让它作为 MVC 中的控制器来使用, 否则就会造成写出巨大的且不易维护不易理解的控制器.
但这里仍然是把控制器作为模型和视图的胶水, 只不过将一些业务从其中剥离, 独立到其他的类中去(比如 tableView 的数据源和代理).
而分离后, 视图控制器和其他类之间的交流可以通过接口进行.
这一节需要实现的类型有如下:
-
ItemListViewController
-
DataProvider
-
DetailViewController
-
InputViewController
2.4.1 TDD 实现列表视图控制器
用 TDD 方法来实现视图控制器时, 如果没有接触过 TDD 的话, 很难想象如何对视图控制器进行测试驱动开发.
首先想一下这个视图控制器的功能, 它的功能是将视图显示给用户. 在使用时, 需要保证控制器中存在一个 tableView, 然后在 viewDidLoad
之后, 它被正确设置. 故首先保证存在 tableView:
func test_TableViewIsNotNilAfterViewDidLoad() {
let sut = ItemListViewController()
// 这个方法会触发调用 ViewDidLoad, 千万不要手动调用 ViewDidLoad.
sut.loadViewIfNeeded()
XCTAssertNotNil(sut.tableView)
}
关于 UI 的构建: 如果是小型工程, 建议使用 SB, 大型工程建议代码编写 UI. 原因在于 SB 更加快速, 但灵活性不足, 代码更灵活, 但效率更低.
上面讨论 UI 的构建方式, 是因为在编写单元测试时, 如果用 SB 构建 UI 的话, 就需要使用 SB 来初始化 ViewController, 而不是直接调用 ViewController 的无参构造方法. 这个工程中使用 SB 来构建 UI, 故单元测试需要修改为如下:
func test_TableViewIsNotNilAfterViewDidLoad() {
let storyBoard = UIStoryboard(name: "Main", bundle: nil)
let viewController = storyBoard.instantiateViewController(withIdentifier: "ItemListViewController")
let sut = viewController as? ItemListViewController
// 这个会触发调用 ViewDidLoad
sut?.loadViewIfNeeded()
XCTAssertNotNil(sut?.tableView)
}
而想要把数据源和代理独立出去, 则写一个单元测试来驱动.
tableView 在查询过一次 numberOfSection 或者 numberOfRow 后, 都会把数值缓存起来使用, 如果没有调用 reloadData 的话, 即使数据源数据更新, tableView 中的数据也不会改变.
2.4.2 Fake Object
为了测试 cell 是否是从 cell 缓存队列中取出的, 需要首先了解单元测试中一个非常重要的概念: fake object.
fake object 用于分隔模块, 即替代被测模块的依赖. fake object 作为当前被测模块的依赖, 替代实际代码中的依赖, 这样就可以在测试代码中对 fake object 进行控制, 而无需去创建或获取外部依赖(有时这样的外部依赖在单元测试中是无法直接获取的).
在测试中建立 fake object, 控制它们的行为, 这样的话, 单元测试中始终关注的就都是当前被测单元.
主要有如下三种类型的 fake object:
-
Mocks: 类似记录器的作用. 比如实现代码中有一个类
A
, 它需要调用类型B
对象的方法b()
. 在A
的单元测试中, 先创建一个类型B
的 mock 类, 在其中实现b()
方法, 只需要在方法中设置 mock 的调用标志, 或者是设置一些其他值, 来判断该方法是否被调用了. 这样在单元测试中如果要判断b()
方法是否被调用了, 只需判断 mock 对象的标志位是否被设置. -
Stubs: 如果依赖方法有返回值的情况下使用 Stub. 一般在 Stub 中模拟外部依赖的某个方法的返回值, 这样的返回值一般都是固定的. 使用 Stub, 可以避免许多复杂的测试情况.
-
Fakes: Fake 用于替代 SUT 的交流对象. 使用它的主要目的是让编译通过, 但它并不一定参与 assertion. 经常是真实的对象创建起来比较困难的时候, 就使用 fake 代替, 有时候我们也使用 fake 对象来保证测试是独立于真实对象的实现的.
2.5 使用 Mock 对象
需要写单元测试保证 cell 是否是从重用队列中取出的. 重用操作通过 dequeue
方法完成. 而测试的重点就是看 dequeue 方法是否被调用了.
另外在 iOS 中配置 cell 的一个通用方式是在 cell 中实现一个自定义的 configCell(with:)
方法.
这里测试的是数据源对象, 故其他对象都可以通过 Mock 的方式模拟.
在 mock 对象中相应的方法中, 不只可以标记方法是否被调用, 另外还可以进行更多的验证.
在写单元测试时, 一个主要的技能训练点就是明确测试点是什么, 明确失败点是什么. 这样才能驱动实现.
待续...