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 点这个按钮去下载. 里面有两个工程 BullsEyeHalfTunes

  • BullsEye 是个游戏(大致内容: 根据进度条猜数字/根据给出数字拖进度条)
  • HalfTunes 主要功能是网络请求,解析json
  • 实际功能不重要,跟着教程学测试就行了

Xcode 中进行单元测试(Unit Test)

创建一个Unit Test Target

这部分使用 BullsEye 工程,打开它. 转到测试导航栏(快捷键: Command + 6)

新建一个单元测试Target(参考下图)

TestNavigator1

全部默认,创建. 在测试导航栏点一下你新建的BullsEyeTests类,就能看到了

TestNavigator2

导入了XCTest, 定义了 BullsEyeTests(继承XCTestCase),里面还有setUp,tearDown(),以及测试方法

  1. 测试方法就是你自己写要测试的东西,必须以test开头
  2. setUp是在testXXX()执行前会执行的方法,用来创造环境(?)
  3. tearDown是在testXXX()执行完成后执行的方法,用来清空环境,以免污染下一个测试

运行测试的三种方法:

  1. 运行全部测试类: ProductTest ,或者 Command + U
  2. 运行单个测试类: 在左边的Test导航栏中点击一个类右边的箭头,下一条也适用
  3. 运行单个测试方法: 点击代码行数左边,方法前面的菱形◇

你现在点的话,所有测试都能很快运行完,而且测试全部通过(因为你测试里面啥也没写)

测试通过的话,◇会变成绿色,如果是性能测试(performance),去点self.measure的◇还会出现测试结果:

TestNavigator3

当前教程里testExample()testPerformanceExample()两个测试方法都用不到,删了吧

使用XCTAssert去测试模型

Assert: 断言, 自己百度

首先,我们测试 BullsEye’s 的核心功能model:BullsEyeGame是否计算出正确的分数

在当前的测试代码文件 BullsEyeTests.swift中, 再import一个东西

@testable import BullsEye

这样单元测试才能访问到BullsEye内部的东西

BullsEyeTests类里面加一个属性:

var sut: BullsEyeGame!

SUT : System Under Test (被测系统)

接下来,去写setUp()

super.setUp()
sut = BullsEyeGame()
sut.startNewGame()

给sut实例化,然后调用startNewGame()初始化targetValue(这个存分数的变量是主要的测试对象)

再写tearDown()

sut = nil
super.tearDown()

实现你的第一个测试方法

BullsEyeTests类里面写个方法:

func testScoreIsComputed(){
// 1. given
let guess = sut.targetValue + 5
// 2. when
sut.check(guess: guess)
// 3. then
XCAssertEqual(sut.scoreRound,95,"Score computed from guess is wrong")
}

一个好的测试模板应该是这样的,given,when,then:

  1. Given: 设置你需要的值

    在这个例子中,你创建了一个guess作为测试用的值,sut.targetValue就是你要猜的数真实的值,你要用到guess去匹配和targetValue相差多少

  2. When: 在这里执行你要测试的代码: check(guess:)

    (在测试里,设我们猜的数比targetValue多5

    那么当sut.check(guess:)去核实你猜测的数时,一定会偏离5,即得分一定是95

    这是这个游戏的计分规则,完全猜对是100,偏差越多扣分越多

    最后使用Equal相等断言,若sut.scoreRound与95相等,通过测试否则测试失败,输出第三个参数中的message)

  3. Then: 在这里你去判断测试结果是否为你期望的结果

运行一下,测试通过,◇变绿,弹出Test Succeeded
succeeded

Debug 测试

再写一个测试,刚才是猜大了,现在猜小了试试

func testScoreIsComputedWhenGuessLTTarget() {
// 1. given
let guess = sut.targetValue - 5

// 2. when
sut.check(guess: guess)

// 3. then
XCTAssertEqual(sut.scoreRound, 95, "Score computed from guess is wrong")
}

加一个测试失败时的断点,当断言失败时程序暂停

addTestFalureBreakpoint

运行一下,Test Failure,那肯定是代码写错了,现在有断点,看看控制台的情况

TestFailure

guesstargetValue - 5没毛病,但是scoreRound是105,不是95. 那就是when 中的 sut.check(guess:)没算对.这回去check(guess:)里面打断点/单步调试看看哪出错了

算分的时候没用绝对值,直接做差了,改掉.

再跑一下测试,这次应该通过了

用XCTestExpectation去测试异步操作

换工程了,打开HalfTunes工程,这主要就是用URLSession去请求API

异步的代码有延时,不是执行到这里立马就完成并return,用XCTestExpectation就是让你的测试去等待一会,等待异步操作完成

异步测试一般来说都挺慢的,最好把它和那些运行快的单元测试分开测

还是前面的流程,new unit test target,起名HalfTunesSlowTests. 默认,创建.打开HalfTunesSlowTests类,声明:

@testable import HalfTunes

声明sut,这回是URLSession,在setUp()中创建,tearDown()里面释放

var sut: URLSession!

override func setUp() {
super.setUp()
sut = URLSession(configuration: .default)
}

override func tearDown() {
sut = nil
super.tearDown()
}

写个异步测试:

// Asynchronous test: success fast, failure slow
func testValidCallToiTunesGetsHTTPStatusCode200() {
// given
let url =
URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
// 1
let promise = expectation(description: "Status code: 200")

// when
let dataTask = sut.dataTask(with: url!) { data, response, error in
// then
if let error = error {
XCTFail("Error: \(error.localizedDescription)")
return
} else if let statusCode = (response as? HTTPURLResponse)?.statusCode {
if statusCode == 200 {
// 2
promise.fulfill()
} else {
XCTFail("Status code: \(statusCode)")
}
}
}
dataTask.resume()
// 3
wait(for: [promise], timeout: 5)
}

这部分测试内容: URLSession向iTunes发送一条请求,然后收到200状态码

  • given: 给定要访问的URL
  • when: 声明网络请求 dataTask(with:_,_,_:),执行dataTask.resume()
  • then: 网络请求完成的回调部分,如果有error就是测试失败,没有说明请求成功,再看看响应的状态码是不是200
  1. expectation(description:): 得到一个XCTestExpectation,存到变量promise里面,description是描述你期望发生什么
  2. promise.fulfill(): 在你满足期望的地方调用它,表明测试达成
  3. wait(for:timeout:): 保持本测试运行,直到所有的期望都达成,或者超时

运行这个测试,用模拟机时电脑记得联网

测试失败时快速结束

首先我们需要创造失败条件, 把URL改错,比如iTunes少个s

let url = 
URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba")

这次再运行测试,测试等到超时才结束. 这是因为你一开始就假定请求总是会成功,然后在请求成功的地方去调用 promise.fulfill(). 然而这次失败了,该test等到超时之前都没有达成期望,所以等到了超时才结束.

我们改进一下这种情况,改变假定: 不再期望请求成功,只要收到响应的回调就认定期望达成. 然后我们再去断言回调得到的内容(error 和 statusCode),如果是成功,那么errornil,statusCode200相等,否则认为失败

下面给出新的test

func testCallToiTunesCompletes() {
// given
let url =
URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba")
let promise = expectation(description: "Completion handler invoked")
var statusCode: Int?
var responseError: Error?

// when
let dataTask = sut.dataTask(with: url!) { data, response, error in
statusCode = (response as? HTTPURLResponse)?.statusCode
responseError = error
promise.fulfill()
}
dataTask.resume()
wait(for: [promise], timeout: 5)

// then
XCTAssertNil(responseError)
XCTAssertEqual(statusCode, 200)
}

运行这个测试,它会很快结束,而不是等到超时结束.

伪造对象和交互

现在这个app的异步测试通过了,你还想测试一下用代码解析json是否正确. 不仅仅是URLSession,这些数据也可以来自数据库或者云上.

大多数app都会与系统或者库对象有交互, 但是这些对象都不是你程序员控制的, 直接测试的话不仅慢而且无法复现,违反了FIRST原则中的两条. 因此我们通过从stubs获得输入信息或者更新mock对象来模拟这些交互,实现测试.

当你的代码依赖与系统或者库时,使用伪造来进行测试. 你可以创建一个伪造的对象来参与这部分,或者说把你的伪造对象注入到代码中. 依赖注入的几种方法

从Stub中获得伪造的输入

在这次的测试中, 你需要检查app的 updateSearchResults(_:) 是否正确解析了通过session下载得到的数据, 我们通过检查 searchResults.count 的值是否正确. 这次的SUT是viewcontroller, 你需要伪造session以及下载得到的数据

新建一个单元测试Target. 起名为HalfTunesFakeTests. 打开 HalfTunesFakeTests.swift 并且再次导入app模块

@testable import HalfTunes

还是老样子,设置sut,改setUp和tearDown

var sut: SearchViewController!

override func setUp() {
super.setUp()
// 这里是用了storyboard,所以这样获取VC,纯代码的话直接生成就好了
sut = UIStoryboard(name: "Main", bundle: nil)
.instantiateInitialViewController() as? SearchViewController
}

override func tearDown() {
sut = nil
super.tearDown()
}

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))
let path = testBundle.path(forResource: "abbaData", ofType: "json")
let data = try? Data(contentsOf: URL(fileURLWithPath: path!), options: .alwaysMapped)

let url =
URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
let urlResponse = HTTPURLResponse(
url: url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil)

let sessionMock = URLSessionMock(data: data, response: urlResponse, error: nil)
sut.defaultSession = sessionMock

现在你就能测试updateSearchResults(_:)去解析伪造数据到底解析成功了没.

func test_UpdateSearchResults_ParsesData() {
// given
let promise = expectation(description: "Status code: 200")

// when
// 开始时就应该是0,没有results
XCTAssertEqual(sut.searchResults.count, 0, "searchResults should be empty before the data task runs")
let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")

let dataTask = sut.defaultSession.dataTask(with: url!) {
data, response, error in
// if HTTP request is successful, call updateSearchResults(_:)
// which parses the response data into Tracks
if let error = error {
print(error.localizedDescription)
} else if let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
self.sut.updateSearchResults(data)
}
promise.fulfill()
}
dataTask.resume()
wait(for: [promise], timeout: 5)

// then
// 网络请求后,应该从伪造的数据中解析到了3条数据
XCTAssertEqual(sut.searchResults.count, 3, "Didn't parse 3 items from fake response")
}

网络请求是异步操作,还得按照异步操作去写.

运行测试,通过测试,而且很快结束,因为没有真的去网络请求,那是你伪造的

mock 对象的假更新

上面那个测试用例用了stub从伪造对象里提供输入,接下来这部分你会用mock去测试,你的代码是否正确更新了UserDefaults

打开BullsEye 工程. 前面也说过这游戏有两个模式. 两个模式的转换时通过一个UISegmentedControl来控制的,并且把当前模式存到了UserDefaults里面.

我们这次检查app是不是正确地保存了gameStyle

还是切到Test导航栏,新建一个单元测试,起名BullsEyeMockTests.这次不仅要导入app模块,还要自己定义一个类

@testable import BullsEye

class MockUserDefaults: UserDefaults {
var gameStyleChanged = 0
override func set(_ value: Int, forKey defaultName: String) {
if defaultName == "gameStyle" {
gameStyleChanged += 1
}
}
}

MockUserDefaults 重写了 set(_:forKey:)方法,用来增加gameStyleChanged这个标志. 也许你觉得用bool更熟悉,但是用Int更灵活:还能检查你调用了多少次这个方法.

声明SUT:

var sut: ViewController!
var mockUserDefaults: MockUserDefaults!

setUp()tearDown()

override func setUp() {
super.setUp()

sut = UIStoryboard(name: "Main", bundle: nil)
.instantiateInitialViewController() as? ViewController
mockUserDefaults = MockUserDefaults(suiteName: "testing")
sut.defaults = mockUserDefaults
}

override func tearDown() {
sut = nil
mockUserDefaults = nil
super.tearDown()
}

建好了sut,并且把伪造数据注入其中

开始写测试方法:

func testGameStyleCanBeChanged() {
// given
let segmentedControl = UISegmentedControl()

// when
XCTAssertEqual(mockUserDefaults.gameStyleChanged, 0,"gameStyleChanged should be 0 before sendActions")
segmentedControl.addTarget(sut,
action: #selector(ViewController.chooseGameStyle(_:)), for: .valueChanged)
segmentedControl.sendActions(for: .valueChanged)

// then
XCTAssertEqual(
mockUserDefaults.gameStyleChanged,
1,
"gameStyle user default wasn't changed")
}

最开始的时候, gameStyleChanged 是0,这里调用 sendActions(for:) ,他就触发了ViewController.chooseGameStyle(_:),这个方法里面改了userDefaults,让我们定义的flag加了1

UI测试(略)

目前没用到

性能测试

Apple文档里说道: 性能测试中,把你要测试的代码写入那个block里面,它跑10次,然后收集平均运行时间以及标准差. 这些单独测量的平均值形成测试运行的值, 用来与基准比较评估是成功还是失败

至于那个block(在swift里面是closure),就是 measure().

用HalfTunes工程,在HalfTunesFakeTests.swift里面加一条测试方法:

func test_StartDownload_Performance() {
let track = Track(
name: "Waterloo",
artist: "ABBA",
previewUrl:
"http://a821.phobos.apple.com/us/r30/Music/d7/ba/ce/mzm.vsyjlsff.aac.p.m4a")

measure {
self.sut.startDownload(track)
}
}

运行,完成后点击measure边上的◇标志,去查看统计数据

PerformanceResult

setBaseLine就是设置基准,基线的设置要基于当前设备的配置.

代码覆盖

代码覆盖工具,检查你app的代码有多少是在你的测试中运行过的,这样你就能知道哪些代码还没有测试过.

往上看,在工具栏里面点你的app(旁边就是运行的设备选择), Edit scheme,去Test里面把Code Coverage勾上:

这次我们执行全部测试用例(Command+U),执行完成后打开报告栏(Command+9),看到你最近一次的执行,选择 Coverage:

你还能点进去各个代码文件里面,红的就是没有覆盖到的代码,绿的就是执行过的代码

是否要达成100%覆盖率,现在还有争议


看完了记得去英文原始网站给原作者打个分

0%