如何在iOS总单元测试网络层

网络通信在每个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
2
protocol GitHubApiClient {
}

你可以看到,API我们上传一个用户名(username)作为参数,我们在协议中实现:
1
2
3
protocol GitHubApiClient {
static func requestUserWithUsername(username: String)
}

现在我们需要处理请求结束后的回调. 如果请求成功,我们希望以一个合适的格式获取用户数据
1
2
3
static 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
3
static func requestUserWithUsername(username: String,
onSuccess: (GitHubUserData) -> Void,
onError: (NSError) -> Void)

让我们为两个回调设置的更易阅读
1
2
typealias GitHubGetUserCallback = (GitHubUserData) -> Void   
typealias ErrorCallback = (NSError) -> Void

现在,我们的协议像这样
1
2
3
4
5
6
7
8
typealias GitHubGetUserCallback = (GitHubUserData) -> Void
typealias ErrorCallback = (NSError) -> Void

protocol GitHubApiClient {
static func requestUserWithUsername(username: String,
onSuccess: GitHubGetUserCallback,
onError: ErrorCallback)
}

有时候我们并不想都实现两个回调,让我们把它改成可选(optionals)
1
2
3
4
5
protocol GitHubApiClient {
static func requestUserWithUsername(username: String,
onSuccess: GitHubGetUserCallback?,
onError: ErrorCallback?)
}

现在我们已经实现了协议设计,去实现编程部分吧。

2.实现协议

在iOS中有许多实现网络请求方法,其中3个最常使用的是NSURLSession, AFNetworkingAlamofire.
NSURLSession
支持iOS7及以上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import Foundation
import SwiftyJSON

class NativeApiClient: GitHubApiClient {

static func requestUserWithUsername(username: String,
onSuccess: GitHubGetUserCallback? = nil,
onError: ErrorCallback? = nil) {
let urlString = "https://api.github.com/users/\(username)"
let url = NSURL(string: urlString)!

let defaultSession = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration())
let dataTask = defaultSession.dataTaskWithURL(url) { data, response, error in
if let error = error {
onError?(error)
} else if let data = data {
let json = JSON(data: data)
onSuccess?(GitHubUserData(json: json))
}
}
dataTask.resume()
}
}

AFNetworking
记住在你的Podfile中添加pod 'AFNetworking'

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import Alamofire
import SwiftyJSON

class AlamofireApiClient: GitHubApiClient {

static func requestUserWithUsername(username: String,
onSuccess: GitHubGetUserCallback? = nil,
onError: ErrorCallback? = nil) {
let urlString = "https://api.github.com/users/\(username)"

Alamofire.request(.GET, urlString)
.validate()
.responseJSON { response in
switch response.result {
case .Success:
if let data = response.result.value {
let json = JSON(data)
onSuccess?(GitHubUserData(json: json))
}
case .Failure(let error):
onError?(error)
}
}
}
}

Alamofire

记住在你的Podfile中添加pod 'Alamofire'

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import Alamofire
import SwiftyJSON

class AlamofireApiClient: GitHubApiClient {
static func requestUserWithUsername(username: String,
onSuccess: GitHubGetUserCallback? = nil,
onError: ErrorCallback? = nil) {
let urlString = "https://api.github.com/users/\(username)"
Alamofire.request(.GET, urlString)
.validate()
.responseJSON { response in
switch response.result {
case .Success:
if let data = response.result.value {
let json = JSON(data)
onSuccess?(GitHubUserData(json: json))
}
case .Failure(let error):
onError?(error)
}
}
}
}

3.使用协议

这个相对简单,直接引入即可

  • 用户名(username)
  • 成功回调的闭包onSucess
  • 失败回调的闭包onError
1
2
3
4
5
6
7
8
9
NativeApiClient.requestUserWithUsername("hoang-tran", onSuccess: { userData in

print(userData)

}, onError: { error in

print(error.localizedDescription)

})

或者我们可以移除我们不关心的回调

1
2
3
NativeApiClient.requestUserWithUsername("hoang-tran", onSuccess: { userData in
print(userData)
})

或者
1
2
3
NativeApiClient.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作为响应返回

所以为一个网络请求编写单元测试时,我们希望按照以下顺序执行:

  1. 截取网络请求,返回我们自定义的json文件
  2. 利用协议执行网络请求
  3. 使用onSuccessonError来描述返回的结果

准备测试

在我们开始之前,确保你熟悉以下知识:

开始编写单元测试

第一步:设置原单测试项目

打开Podfile,添加以下Pods到你的Target中

  • Quick:一个Swift编写的行为驱动框架(BDD)
  • Nimble: 一个易读的断言库
  • Mockingjay: 网络请求库
1
2
3
4
5
6
7
8
9
10
11
12
13
14
platform :ios, '9.0'

target 'TestNetworkLayer' do
use_frameworks!

#...

target 'TestNetworkLayerTests' do
inherit! :search_paths
pod 'Quick'
pod 'Nimble'
pod 'Mockingjay'
end
end

在你的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
12
import 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
9
override func spec() {
super.spec()
describe("requestUserWithName") {
context("success") {
it("returns GitHubUserData") {
}
}
}
}

在它的闭包中实现网络请求
1
2
3
4
it("returns GitHubUserData") {
NativeApiClient.requestUserWithName("hoang-tran", onSuccess: { userData in
})
}

请求成功后,存储用户数据
1
2
3
4
5
6
it("returns GitHubUserData") {
var returnedUserData: GitHubUserData?
NativeApiClient.requestUserWithName("hoang-tran", onSuccess: { userData in
returnedUserData = userData
})
}

我们希望返回的用户数据有有效的值
1
2
3
4
5
6
7
it("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
3
touch 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
17
it("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())
}

我们实现了以下步骤

  1. 获取GetUserSuccess.json 的路径
  2. 将json文件转换成NSData
  3. 截取请求,这样每次请求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
17
override func spec() {
super.spec()

describe("requestUserWithName") {
context("success") {
it("returns GitHubUserData") {
//...
}
}

context("error") {
it("returns error") {

}
}
}
}

拦截请求返回错误信息
1
2
3
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))

完整的测试用例
1
2
3
4
5
6
7
8
9
10
11
12
13
it("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