网络通信在每个iOSAPP中都是一个重要的部分。
在iOS开发中,我们如何对网络层进行单元测试也是我们要关心的一个重点,那我们今天来探讨一下
原文链接:How to unit test your network layer in iOS
当用户点击app中的UI元素,通常都会有1个或多个网络请求发送到服务器,去请求更新的数据,返回结果给用户。
让我们来看看一个经典的新闻App的一个简单的网络请求用例:
- 请求用户授权
- 请求新闻来源列表: Engadget, Mashable, Medium等等
- 当用户点击一个新闻源的时候,请求最近更新的文章
- 当用户点击其中一个文章的时候,请求详情
- 当用户点击交互按钮后,将所有文章标记为已读
网络请求无所不在,我们不仅应该清楚的知道网络请求,而且应该更好的去实现它。
今天我们就来学习如何创建一个从RESTful API服务器中获取数据的网络层,它包括设计,编程,也包括单元测试。
以下是详细列表:
- 1.设计协议
- 2.实现协议
- NSURLSession
- AFNetworking
- Alamofire
- 3.使用协议
- 4.为协议编写单元测试
- 写什么?
- 如何写?
- 准备测试
- 开始写测试用例
- 第一步:设置单元测试的项目
- 第二步:创建一个新的spec文件
- 第三步:实现一个真实网络请求的网络请求
- 第四步:创建一个响应文件
- 第五步:请求网络
- 第六步:实现正确解析json文件的协议
- 第七步:测试错误状态
使用什么样的RESTful API?
让我们来使用一个简单的吧
实现一个获取Github用户信息的API
语法
GET https://api.github.com/users/:username
示例
GET https://api.github.com/users/mistdon
GET https://api.github.com/users/orta
GET https://api.github.com/users/chriseidhof
如果你在浏览器中点击这个链接https://api.github.com/users/mistdon , 他将会以json格式返回结果
很简单是不是?让我们继续实现我们的app
1.设计协议
在你的Target中创建一个新的文件,命名为GitHubApiClient.swift
添加一个名为GitHubApiClient的协议1
2protocol GitHubApiClient {
}
你可以看到,API我们上传一个用户名(username)作为参数,我们在协议中实现:1
2
3protocol GitHubApiClient {
static func requestUserWithUsername(username: String)
}
现在我们需要处理请求结束后的回调. 如果请求成功,我们希望以一个合适的格式获取用户数据1
2
3static func requestUserWithUsername(username: String,
onSuccess: (GitHubUserData) -> Void)
GitHubUserData是我们创建的存储返回json数据的结构体
1
2
3
4
5
6
7
8 struct GitHubUserData {
var name: String
var bio: String
var email: String
var numberOfFollowers: Int
var numberOfFollowing: Int
// ...
}
当请求失败时,我们希望获取失败信息1
2
3static func requestUserWithUsername(username: String,
onSuccess: (GitHubUserData) -> Void,
onError: (NSError) -> Void)
让我们为两个回调设置的更易阅读1
2typealias GitHubGetUserCallback = (GitHubUserData) -> Void
typealias ErrorCallback = (NSError) -> Void
现在,我们的协议像这样1
2
3
4
5
6
7
8typealias GitHubGetUserCallback = (GitHubUserData) -> Void
typealias ErrorCallback = (NSError) -> Void
protocol GitHubApiClient {
static func requestUserWithUsername(username: String,
onSuccess: GitHubGetUserCallback,
onError: ErrorCallback)
}
有时候我们并不想都实现两个回调,让我们把它改成可选(optionals)1
2
3
4
5protocol GitHubApiClient {
static func requestUserWithUsername(username: String,
onSuccess: GitHubGetUserCallback?,
onError: ErrorCallback?)
}
现在我们已经实现了协议设计,去实现编程部分吧。
2.实现协议
在iOS中有许多实现网络请求方法,其中3个最常使用的是NSURLSession, AFNetworking和Alamofire.
NSURLSession
支持iOS7及以上
1 | import Foundation |
AFNetworking
记住在你的Podfile中添加pod 'AFNetworking'
1 | import Alamofire |
Alamofire
记住在你的Podfile中添加pod 'Alamofire'
1 | import Alamofire |
3.使用协议
这个相对简单,直接引入即可
- 用户名(username)
- 成功回调的闭包onSucess
- 失败回调的闭包onError
1 | NativeApiClient.requestUserWithUsername("hoang-tran", onSuccess: { userData in |
或者我们可以移除我们不关心的回调1
2
3NativeApiClient.requestUserWithUsername("hoang-tran", onSuccess: { userData in
print(userData)
})
或者1
2
3NativeApiClient.requestUserWithUsername("hoang-tran", onError: { error in
print(error.localizedDescription)
})
或者1
NativeApiClient.requestUserWithUsername("hoang-tran")
4.添加单元测试
测试什么?
当我们讨论网络请求的时候,有以下几点需要我们考虑:
- 请求的路径是否正确?
- 当请求成功时,它是否正确的解析了数据并返回正确的模型
- 当它失败时,它是否返回了正确的错误信息
如何测试?
我们将使用一个HTTP网络请求框架:Mockingjay
它允许你以下方式的声明
Stub request with url A and return json file B.
或者
Stub request with url A and return error.
Stub还能做什么?
这是一个stub的语法
Stub event A and do custom action B
它意味着:
当A请求发生时,不再执行原方法,而替代执行自定义的B方法
所以:
Stub request with url A and return json file B.
也就翻译成
当有一个请求连接A时,不把它发送到服务器,而是用我们项目中本地B文件作为响应返回
举例:Stub request with url https://api.github.com/users/hoang-tran
and return the file GetUserSuccess.json
可以理解为:
当有一个请求https://api.github.com/users/hoang-tran,不要将它发送到服务器,而是用GetUserSuccess.json作为响应返回
所以为一个网络请求编写单元测试时,我们希望按照以下顺序执行:
- 截取网络请求,返回我们自定义的json文件
- 利用协议执行网络请求
- 使用onSuccess和onError来描述返回的结果
准备测试
在我们开始之前,确保你熟悉以下知识:
开始编写单元测试
第一步:设置原单测试项目
打开Podfile,添加以下Pods到你的Target中
- Quick:一个Swift编写的行为驱动框架(BDD)
- Nimble: 一个易读的断言库
- Mockingjay: 网络请求库
1 | platform :ios, '9.0' |
在你的Terminal中安装这3个库
1 | pod install |
打开.xcworkspace文件
1 | open TestNetworkLayer.xcworkspace |
第二步:创建新的spec文件
在你的Target中创建一个名为NativeApiClientSpec.swift的文件1
2
3
4
5
6
7
8
9
10
11
12import Quick
import Nimble
class NativeApiClientSpec : QuickSpec {
override func spec() {
super.spec()
describe("first test") {
it("should pass") {
expect(1).to(equal(1))
}
}
}
}
执行Cmd+U确定测试通过
第三步:编写真实的网络请求单元测试
首先是成功的测试1
2
3
4
5
6
7
8
9override func spec() {
super.spec()
describe("requestUserWithName") {
context("success") {
it("returns GitHubUserData") {
}
}
}
}
在它的闭包中实现网络请求1
2
3
4it("returns GitHubUserData") {
NativeApiClient.requestUserWithName("hoang-tran", onSuccess: { userData in
})
}
请求成功后,存储用户数据1
2
3
4
5
6it("returns GitHubUserData") {
var returnedUserData: GitHubUserData?
NativeApiClient.requestUserWithName("hoang-tran", onSuccess: { userData in
returnedUserData = userData
})
}
我们希望返回的用户数据有有效的值1
2
3
4
5
6
7it("returns GitHubUserData") {
var returnedUserData: GitHubUserData?
NativeApiClient.requestUserWithName("hoang-tran", onSuccess: { userData in
returnedUserData = userData
})
expect(returnedUserData).toEventuallyNot(beNil())
}
toEventusllyNot意味它会持续的检查用户数据,查看它是否有值(不为空)
几秒后,用户数据有值,测试通过,否则失败
然而,当你执行测试(cmd+U)时,它失败了
这是以为真实的网络请求需要花费时间,toEventually不会等太长的时间,去看返回的数据是否有值,这就导致了失败
默认等待时间是1秒,如果我们设的更长一点,测试会通过
//...
expect(returnedUserData).toEventuallyNot(beNil(), timeout: 20)
但是我们并不想那样做,我们的目标是完全不去请求网络;
第四步:创建响应文件
打开Terminal,去TestNetworkLayerTests文件夹(假设你的文件名为TestNetworkLayer)
cd TestNetworkLayerTests
创建一个名为Fixtures的文件夹,然后进入它
mkdir Fixtures
cd Fixtures
请注意:Fixtures仅是一个存放共同响应文件的名字,你可以任意命名
创建一个新的json文件,命名为 GetUserSuccess.json,然后编辑它1
2
3touch GetUserSuccess.json
open GetUserSuccess.json
在你的浏览器总打开这个链接https://api.github.com/users/hoang-tran
将json内容粘贴在GetUserSuccess.json文件中
{
"login": "hoang-tran",
"id": 6714157,
"avatar_url": "https://avatars.githubusercontent.com/u/6714157?v=3",
"gravatar_id": "",
"url": "https://api.github.com/users/hoang-tran",
"html_url": "https://github.com/hoang-tran",
"followers_url": "https://api.github.com/users/hoang-tran/followers",
"following_url": "https://api.github.com/users/hoang-tran/following{/other_user}",
"gists_url": "https://api.github.com/users/hoang-tran/gists{/gist_id}",
"starred_url": "https://api.github.com/users/hoang-tran/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/hoang-tran/subscriptions",
"organizations_url": "https://api.github.com/users/hoang-tran/orgs",
"repos_url": "https://api.github.com/users/hoang-tran/repos",
"events_url": "https://api.github.com/users/hoang-tran/events{/privacy}",
"received_events_url": "https://api.github.com/users/hoang-tran/received_events",
"type": "User",
"site_admin": false,
"name": "Hoang Tran",
"company": "East Agile Vietnam",
"blog": "http://hoangtran.me/",
"location": "Ho Chi Minh city, VietNam",
"email": "hoangtx.master@gmail.com",
"hireable": null,
"bio": "Hi, I'm Hoang. I'm a Swift lover. I blog weekly about iOS development/testing/deployment at http://hoangtran.me/\r\nAnd I happen to love GitHub so much 😜 ",
"public_repos": 15,
"public_gists": 23,
"followers": 16,
"following": 93,
"created_at": "2014-02-18T09:42:07Z",
"updated_at": "2016-08-30T03:04:04Z"
}
在Finder中打开当前文件夹
1 | open . |
将Fixtures拖入到你的测试Target文件中
Xcode会向你确定链接信息,确保你将文件夹拖入到Test Target而不是main Target中
点击完成(Finish)
第五步:模拟网络请求
在NativeApiClientSpec.swift中,我们将模拟请求,使用我们的GetUserSuccess.json文件作为响应文件,而不是真的去请求网络1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17it("returns GitHubUserData") {
var returnedUserData: GitHubUserData?
// 1.
let path = NSBundle(forClass: self.dynamicType).pathForResource("GetUserSuccess", ofType: "json")!
// 2.
let data = NSData(contentsOfFile: path)!
// 3.
self.stub(uri("https://api.github.com/users/hoang-tran"), builder: jsonData(data))
NativeApiClient.requestUserWithName("hoang-tran", onSuccess: { userData in
returnedUserData = userData
})
expect(returnedUserData).toEventuallyNot(beNil())
}
我们实现了以下步骤
- 获取GetUserSuccess.json 的路径
- 将json文件转换成NSData
- 截取请求,这样每次请求https://api.github.com/users/hoang-tran时,它会返回一个假的json文件,而不是真的网络请求
一切就绪,执行Cmd+U测试.
这次,它应该是通过的
为了确定真的没有去请求网络,关闭你电脑的网络重新测试,它应该还是通过的
第六步:执行协议解析数据
我们应该为返回的数据(returnedUserData )编写一些断言,来确定json文件被正确解析道GitHubUserData结构体中了1
2
3
4
5
6
7//...
expect(returnedUserData).toEventuallyNot(beNil())
expect(returnedUserData?.name) == "Hoang Tran"
expect(returnedUserData?.email) == "hoangtx.master@gmail.com"
expect(returnedUserData?.numberOfFollowers) == 120
expect(returnedUserData?.numberOfFollowing) == 133
第七步:测试错误
首先声明错误内容1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17override func spec() {
super.spec()
describe("requestUserWithName") {
context("success") {
it("returns GitHubUserData") {
//...
}
}
context("error") {
it("returns error") {
}
}
}
}
拦截请求返回错误信息1
2
3let error = NSError(domain: "Describe your error here", code: 404, userInfo: nil)
self.stub(uri("https://api.github.com/users/hoang-tran"), builder: failure(error))
完整的测试用例1
2
3
4
5
6
7
8
9
10
11
12
13it("returns error") {
var returnedError: NSError?
let error = NSError(domain: "Describe your error here", code: 404, userInfo: nil)
self.stub(uri("https://api.github.com/users/hoang-tran"), builder: failure(error))
NativeApiClient.requestUserWithName("hoang-tran", onError: { error in
returnedError = error
})
expect(returnedError).toEventuallyNot(beNil())
}
重新运行,以确保测试通过
结语
我没想到会写这么长的一篇文章,让我们结束吧
你可以在这里找到完整的代码https://github.com/hoang-tran/TestNetworkLayer