iOS单元测试教程(入门)[渣译]
这里是英文教程原文iOS Unit Testing and UI Testing Tutorial
顺便把工程给下载了Download Materials,教程资源要用到
有能力的请看原文就行了.
这篇文章涉及 Storyboard(UI) ,同步异步 , URLSession(网络请求), UserDefaults(数据持久化) ,缺少基础知识的请去补充,否则看不懂.
教程包含内容:
- 使用Xcode中的Test navigator 对app 的model 以及异步方法进行测试
- 使用stubs 和 mocks 伪造(假装)与 library 或 system 对象的交互
- 进行UI与性能测试
- 使用代码覆盖工具
明确你要测试什么
如果说你要扩展你现有的app,你首先要给那些你打算改变的部分写测试.
通常测试应该包含以下内容:
- 核心功能: Model 层的类,方法,以及它与controller的交互
- 常见的UI工作流
- 边界条件
- 修复bug
最佳实践
高效的单元测试: FIRST
- Fast : 测试运行要快
- Independent/Isolated : 测试是独立的,不要共享状态
- Repeatable: 测试要可复现, 相同的测试运行多少次结果都相同. 使用外部数据或者并发问题都会导致不确定的失败.
- Self-validating: 自确认. 测试是自动化的. 输出结果就应该告知是否通过,而不是程序员根据输出自己去解释判断
- Timely: 及时的. 代码最好在实际使用前就把测试给做了.(测试驱动开发)
开始
从原网址去下载工程文件,Download Materials 点这个按钮去下载. 里面有两个工程 BullsEye 和 HalfTunes
- BullsEye 是个游戏(大致内容: 根据进度条猜数字/根据给出数字拖进度条)
- HalfTunes 主要功能是网络请求,解析json
- 实际功能不重要,跟着教程学测试就行了
Xcode 中进行单元测试(Unit Test)
创建一个Unit Test Target
这部分使用 BullsEye 工程,打开它. 转到测试导航栏(快捷键: Command + 6)
新建一个单元测试Target(参考下图)
全部默认,创建. 在测试导航栏点一下你新建的BullsEyeTests类,就能看到了
导入了XCTest, 定义了 BullsEyeTests
(继承XCTestCase
),里面还有setUp
,tearDown()
,以及测试方法
- 测试方法就是你自己写要测试的东西,必须以test开头
setUp
是在testXXX()
执行前会执行的方法,用来创造环境(?)tearDown
是在testXXX()
执行完成后执行的方法,用来清空环境,以免污染下一个测试
运行测试的三种方法:
- 运行全部测试类: Product ▸ Test ,或者
Command + U
- 运行单个测试类: 在左边的Test导航栏中点击一个类右边的箭头,下一条也适用
- 运行单个测试方法: 点击代码行数左边,方法前面的菱形◇
你现在点的话,所有测试都能很快运行完,而且测试全部通过(因为你测试里面啥也没写)
测试通过的话,◇会变成绿色,如果是性能测试(performance),去点self.measure
的◇还会出现测试结果:
当前教程里testExample()
和testPerformanceExample()
两个测试方法都用不到,删了吧
使用XCTAssert去测试模型
Assert: 断言, 自己百度
首先,我们测试 BullsEye’s 的核心功能model:BullsEyeGame
是否计算出正确的分数
在当前的测试代码文件 BullsEyeTests.swift中, 再import
一个东西
import BullsEye |
这样单元测试才能访问到BullsEye内部的东西
在BullsEyeTests
类里面加一个属性:
var sut: BullsEyeGame! |
SUT : System Under Test (被测系统)
接下来,去写setUp()
super.setUp() |
给sut实例化,然后调用startNewGame()
初始化targetValue
(这个存分数的变量是主要的测试对象)
再写tearDown()
sut = nil |
实现你的第一个测试方法
在BullsEyeTests
类里面写个方法:
func testScoreIsComputed(){ |
一个好的测试模板应该是这样的,given,when,then:
Given: 设置你需要的值
在这个例子中,你创建了一个
guess
作为测试用的值,sut.targetValue
就是你要猜的数真实的值,你要用到guess
去匹配和targetValue
相差多少When: 在这里执行你要测试的代码:
check(guess:)
(在测试里,设我们猜的数比
targetValue
多5那么当
sut.check(guess:)
去核实你猜测的数时,一定会偏离5,即得分一定是95这是这个游戏的计分规则,完全猜对是100,偏差越多扣分越多
最后使用Equal相等断言,若
sut.scoreRound
与95相等,通过测试否则测试失败,输出第三个参数中的message)Then: 在这里你去判断测试结果是否为你期望的结果
运行一下,测试通过,◇变绿,弹出Test Succeeded
Debug 测试
再写一个测试,刚才是猜大了,现在猜小了试试
func testScoreIsComputedWhenGuessLTTarget() { |
加一个测试失败时的断点,当断言失败时程序暂停
运行一下,Test Failure,那肯定是代码写错了,现在有断点,看看控制台的情况
guess
是targetValue - 5
没毛病,但是scoreRound
是105,不是95. 那就是when 中的 sut.check(guess:)
没算对.这回去check(guess:)
里面打断点/单步调试看看哪出错了
算分的时候没用绝对值,直接做差了,改掉.
再跑一下测试,这次应该通过了
用XCTestExpectation去测试异步操作
换工程了,打开HalfTunes工程,这主要就是用URLSession
去请求API
异步的代码有延时,不是执行到这里立马就完成并return,用XCTestExpectation就是让你的测试去等待一会,等待异步操作完成
异步测试一般来说都挺慢的,最好把它和那些运行快的单元测试分开测
还是前面的流程,new unit test target,起名HalfTunesSlowTests. 默认,创建.打开HalfTunesSlowTests
类,声明:
import HalfTunes |
声明sut,这回是URLSession
,在setUp()
中创建,tearDown()
里面释放
var sut: URLSession! |
写个异步测试:
// Asynchronous test: success fast, failure slow |
这部分测试内容: URLSession
向iTunes发送一条请求,然后收到200状态码
- given: 给定要访问的URL
- when: 声明网络请求
dataTask(with:_,_,_:)
,执行dataTask.resume()
- then: 网络请求完成的回调部分,如果有
error
就是测试失败,没有说明请求成功,再看看响应的状态码是不是200
- expectation(description:): 得到一个
XCTestExpectation
,存到变量promise
里面,description
是描述你期望发生什么 - promise.fulfill(): 在你满足期望的地方调用它,表明测试达成
- wait(for:timeout:): 保持本测试运行,直到所有的期望都达成,或者超时
运行这个测试,用模拟机时电脑记得联网
测试失败时快速结束
首先我们需要创造失败条件, 把URL改错,比如iTunes少个s
let url = |
这次再运行测试,测试等到超时才结束. 这是因为你一开始就假定请求总是会成功,然后在请求成功的地方去调用 promise.fulfill()
. 然而这次失败了,该test等到超时之前都没有达成期望,所以等到了超时才结束.
我们改进一下这种情况,改变假定: 不再期望请求成功,只要收到响应的回调就认定期望达成. 然后我们再去断言回调得到的内容(error 和 statusCode),如果是成功,那么error
是nil
,statusCode
与200
相等,否则认为失败
下面给出新的test
func testCallToiTunesCompletes() { |
运行这个测试,它会很快结束,而不是等到超时结束.
伪造对象和交互
现在这个app的异步测试通过了,你还想测试一下用代码解析json是否正确. 不仅仅是URLSession,这些数据也可以来自数据库或者云上.
大多数app都会与系统或者库对象有交互, 但是这些对象都不是你程序员控制的, 直接测试的话不仅慢而且无法复现,违反了FIRST原则中的两条. 因此我们通过从stubs
获得输入信息或者更新mock
对象来模拟这些交互,实现测试.
当你的代码依赖与系统或者库时,使用伪造来进行测试. 你可以创建一个伪造的对象来参与这部分,或者说把你的伪造对象注入到代码中. 依赖注入的几种方法
从Stub中获得伪造的输入
在这次的测试中, 你需要检查app的 updateSearchResults(_:)
是否正确解析了通过session下载得到的数据, 我们通过检查 searchResults.count
的值是否正确. 这次的SUT是viewcontroller, 你需要伪造session以及下载得到的数据
新建一个单元测试Target. 起名为HalfTunesFakeTests. 打开 HalfTunesFakeTests.swift 并且再次导入app模块
import HalfTunes |
还是老样子,设置sut,改setUp和tearDown
var sut: SearchViewController! |
Note: 这里SUT设置成VC,是因为代码写的恶心了,没做好分层,别把所有的东西都写到VC里.如何优化可以查查MVC,MVVM等涉及模式
接下来我们需要得到伪造用的响应JSON数据,还有伪造session. 我们只要一点点数据,所以
https://itunes.apple.com/search?media=music&entity=song&term=abba&limit=3 |
复制这个URL到浏览器里面,有三条数据就行了. 下载的文件就是我们需要的数据, 给他改个名abbaData.json
把这个文件拖到HalfTunesFakeTestsgroup里(注意看Xcode,一个测试target是一个group,真正的app工程代码在另一个group里面)
在 HalfTunes 的project里面有一个支持文件 DHURLSessionMock.swift. 里面定义了一个简单的协议DHURLSession
, 里面有通过URL
或者URLRequest
去创建请求任务的方法. 这个文件还定义了URLSessionMock
, 它遵循了上面的协议,可以让你用你自己定义的(data,response,erro)去创建一个mockURLSession
.
然后去 HalfTunesFakeTests.swift 里面写代码吧
sut的设置:把伪造数据注入
let testBundle = Bundle(for: type(of: self)) |
现在你就能测试updateSearchResults(_:)
去解析伪造数据到底解析成功了没.
func test_UpdateSearchResults_ParsesData() { |
网络请求是异步操作,还得按照异步操作去写.
运行测试,通过测试,而且很快结束,因为没有真的去网络请求,那是你伪造的
mock 对象的假更新
上面那个测试用例用了stub从伪造对象里提供输入,接下来这部分你会用mock去测试,你的代码是否正确更新了UserDefaults
打开BullsEye 工程. 前面也说过这游戏有两个模式. 两个模式的转换时通过一个UISegmentedControl来控制的,并且把当前模式存到了UserDefaults里面.
我们这次检查app是不是正确地保存了gameStyle
还是切到Test导航栏,新建一个单元测试,起名BullsEyeMockTests.这次不仅要导入app模块,还要自己定义一个类
import BullsEye |
MockUserDefaults
重写了 set(_:forKey:)
方法,用来增加gameStyleChanged
这个标志. 也许你觉得用bool
更熟悉,但是用Int
更灵活:还能检查你调用了多少次这个方法.
声明SUT:
var sut: ViewController! |
写setUp()
和tearDown()
override func setUp() { |
建好了sut,并且把伪造数据注入其中
开始写测试方法:
func testGameStyleCanBeChanged() { |
最开始的时候, gameStyleChanged
是0,这里调用 sendActions(for:)
,他就触发了ViewController.chooseGameStyle(_:)
,这个方法里面改了userDefaults,让我们定义的flag加了1
UI测试(略)
目前没用到
性能测试
Apple文档里说道: 性能测试中,把你要测试的代码写入那个block里面,它跑10次,然后收集平均运行时间以及标准差. 这些单独测量的平均值形成测试运行的值, 用来与基准比较评估是成功还是失败
至于那个block(在swift里面是closure),就是 measure()
.
用HalfTunes工程,在HalfTunesFakeTests.swift里面加一条测试方法:
func test_StartDownload_Performance() { |
运行,完成后点击measure
边上的◇标志,去查看统计数据
setBaseLine就是设置基准,基线的设置要基于当前设备的配置.
代码覆盖
代码覆盖工具,检查你app的代码有多少是在你的测试中运行过的,这样你就能知道哪些代码还没有测试过.
往上看,在工具栏里面点你的app(旁边就是运行的设备选择), Edit scheme
,去Test里面把Code Coverage勾上:
这次我们执行全部测试用例(Command+U),执行完成后打开报告栏(Command+9),看到你最近一次的执行,选择 Coverage:
你还能点进去各个代码文件里面,红的就是没有覆盖到的代码,绿的就是执行过的代码
是否要达成100%覆盖率,现在还有争议
看完了记得去英文原始网站给原作者打个分