iOS 单元测试简单入门
摘要
为了减少线上bug概率,提高交付质量,目前我们已经加入了质检以及代码互看环节,但是这只对业务逻辑以及代码质量方面的检查,如果还想更加了提高交付质量,那么单元测试也是少不了的。于是,接来了一个月,主要任务还是把单元测试添加到项目中。
单元测试对于我目前来说主要测试两个方面
- 测试一些重要方法的输入输出是否符合预期以及出错时的相应处理是否合理。
- 测试接口返回,保证每一个接口处理都正常。
本文中,主要讲述了Qiuck框架的单元测试编写以及Moya框架自带接口测试功能的使用。
一、单元测试
首先,为了选择是原生的框架还是网络上现有的轮子,在网上与查阅了大量资料,对比后,排除了使用原生的,因为有一些第三方良好的对XCTest进行了一层封装。目前比较流行的开发测试框架中,在github上搜索:BDD(行为驱动开发
)或TDD(测试驱动开发
),因为我们单元测试关心的是行为,于是搜索倾向BDD,发现star最多的是wiki与specta,但是这两个都是oc,由于我们项目目前使用的纯swift开发,后来,找到了另两个,分别为:sleipnir和Quick,对比一看,后者的star高达6000多,不难选择,我们使用Quick进行尝试。
我们写单元测试代码时,需要弄清楚哪些地方要写测试,哪些不要,这里就稍带一下:
- 不要测试私有方法:
私有方法意味着私有,如果你感到有必要测试一个私有方法,那么那个私有方法一定含有概念性错误,通常是作为私有方法,它做的太多了, 从而违背了单一职责原则。
- 不要 Stub 私有方法:
Stub 私有方法和测试私有方法具有相同的危害,更重要的是,Stub 私有方法将会使程序难以调试。通常来说,用于Stub的库会依赖于一些不寻常的技巧来完成工作,这使得发现一个测试为什么会失败变的困难。
- 不要测试构造函数:
构造函数定义的是实现细节,你不应该测试构造函数,这是因为我们认同测试应该与实现细节解耦这一观点。
- 不要 Stub 外部库:
第三方代码不应该在你的测试中直接出现。
1.1 Quick介绍
Quick is a behavior-driven development framework for Swift and Objective-C. Inspired by RSpec, Specta, and Ginkgo.
Quick的中文介绍
1.1 使用方法
- 集成Quick与Nimble
在podfile文件中添加语句: target 'MyDemoTests' do inherit! :search_paths # Pods for testing pod 'Quick','1.1.0' pod 'Nimble','7.0.1' end target 'MyDemoUITests' do inherit! :search_paths # Pods for testing pod 'Quick','1.1.0' pod 'Nimble','7.0.1' end
- 创建xcode文件模板。参照链接
- Nimble用来判断结果与预期的是否一致,断言处理。常用的一些语句:
// Nimble常用判言
expect(1 + 1).to(equal(2))
expect(1.2).to(beCloseTo(1.1, within: 0.1))
expect(3) > 2
expect("seahorse").to(contain("sea"))
expect(["Atlantic", "Pacific"]).toNot(contain("Mississippi"))
expect(ocean.isClean).toEventually(beTruthy())
1.2 开始编写测试代码
-
创建测试文件,使用上面安装的文件模板创建
quick.png - 引入要测试的Target
// 这会把所有 public 和 internal (默认访问修饰符) 修饰符暴露给测试代码。但 private 修饰符仍旧保持私有。
@testable import MyDemo
- 编写
部分使用说明
// describe描述需要测试的对象内容,就是Given..when..then中的given
describe("测试我的控制器") {
// context描述测试上下文,也就是这个测试在when来进行,一个describe可以包含多个context来描述类在不同情景下的行为
context("测试客户经理", {
beforeEach({
})
})
context("测试团队经理", {
print("打印---团队经理")
})
context("测试点击事件", {
print("打印---点击事件")
})
}
// MARK: 暂时禁用
/// 方法前面添加一个 x 表示禁用些测试,方法名会打印出来 但里面是不会执行的。
xdescribe("its click") {
// ...none of the code in this closure will be run.
it("禁的方法", closure: {
print("禁止的方法,是否打印了出来")
})
}
// MARK: 临时运行测试用例中的一个子集(在it context describe前面添加f即可)
// 它可以使得我们更加专注一个或几个例子;运行一个或两个例子比全部运行更快。使用fit函数你可以只运行一个或者两个
fit("is loud") {
print("是不是就只打印了这一个方法呢~~~~fit")
}
- 实战开发举例
import Quick
import Nimble
import RxSwift
import Moya
@testable import MyDemo
class XYJLoginTest: QuickSpec {
override func spec() {
var loginvm: LoginViewModel?
var parameters = [String: Any]()
var disposeBag: DisposeBag?
var phoneNumbers: [String]?
var pwds: [String]?
describe("测试登录模块") {
// 测试之前
beforeEach {
loginvm = LoginViewModel()
disposeBag = DisposeBag()
phoneNumbers = ["188****0393","158****1915"]
pwds = ["111123","112123"]
}
// 测试完之后
afterEach {
loginvm = nil
parameters.removeAll()
disposeBag = nil
phoneNumbers = nil
pwds = nil
}
it("验证登录表单", closure: {
for (i, phoneNumber) in phoneNumbers!.enumerated() {
let formResult = loginvm?.validateForm(phoneNumber, pwds![i])
expect(formResult).notTo(beNil())
switch formResult! {
case .InValid(let msg):
fail(msg)
case .Valid(let dic):
expect(dic).notTo(beNil())
}
}
})
it("登录请求", closure: {
for (i, phoneNumber) in phoneNumbers!.enumerated() {
print("手机号码",phoneNumber,"密码",pwds![i])
parameters["mobile"] = phoneNumber
parameters["login_pwd"] = pwds![i].md5()
loginvm?.login(parameters).debug().subscribe(onNext: { (result) in
expect(result.data).notTo(beNil())
self.notify() // 取消暂停等待,下面会单独说明此方法用处
}, onError: { (error) in
expect(self.moyaError(error: error as! MoyaError)).to(beTrue())
self.notify() // 取消暂停等待,下面会单独说明此方法用处
}).disposed(by: disposeBag!)
self.wait() // 等待,直到接口有数据返回
}
})
}
}
}
- 碰到问题分析
如上述实战例子中,本来是没有写过self.wait()与self.notify()语句在里面的,但是,点击开始测试时,app启动,立马就自动结束了app应用,然后提示测试成功,根本没有时间停留等待接口数据返回,有违背我们测试的初忠。于是,查阅资料,添加等待即可实现。代码如下:
// MARK: 等待
extension QuickSpec {
override func wait() {
repeat {
expectation(forNotification: NSNotification.Name(rawValue: "QuickSpecTest").rawValue, object: nil, handler: nil)
waitForExpectations(timeout: 30, handler: nil)
} while(false)
}
}
// MARK: 继续执行
extension QuickSpec {
override func notify() {
NotificationCenter.default.post(name: NSNotification.Name(rawValue: "QuickSpecTest"), object: nil)
}
}
二、Moya网络框架,接口测试代码编写
2.1 使用sampleData模拟接口返回数据。
- 请求过程
moya1.png
使用Moya的第一步就是定义一个Target:通常是指一些符合TargetType protocol的enum.然,你请求的其余部分都只根据这个Target而来.这个枚举用来定义你的网络请求API的行为action,进入文件里我们可以看到,它由:
/// The protocol used to define the specifications necessary for a `MoyaProvider`.
public protocol TargetType {
/// The target's base `URL`.
var baseURL: URL { get }
/// The path to be appended to `baseURL` to form the full `URL`.
var path: String { get }
/// The HTTP method used in the request.
var method: Moya.Method { get }
/// The parameters to be incoded in the request.
var parameters: [String: Any]? { get }
/// The method used for parameter encoding.
var parameterEncoding: ParameterEncoding { get }
/// Provides stub data for use in testing.
var sampleData: Data { get }
/// The type of HTTP task to be performed.
var task: Task { get }
/// Whether or not to perform Alamofire validation. Defaults to `false`.
var validate: Bool { get }
}
其中,baseUrl为请求基址,path为剩下部分的请求路径。method:请求方式,parameters请求参数,parameterEncoding为请求参数的编码方式,sampleData为自己模拟返回的二进字,本接下来主要讲这个如何在单元测试中使用。
- RxMoyaProvider初始化,参数分析
- endpointClosure 可以对请求参数做进一步的修改,如可以修改
endpointByAddingParameters
endpointByAddingHTTPHeaderFields
等 - RequestClosure 你可以在发送请求前,做点手脚. 如修改超时时间,打印一些数据等等
- StubClosure可以设置请求的延迟时间,可以当做模拟慢速网络,还可以模拟接口数据返回
- Manager 请求网络请求的方式。默认是Alamofire
- [PluginType]一些插件。回调的位置在发送请求后,接受服务器返回之前
- endpointClosure 可以对请求参数做进一步的修改,如可以修改
override public init(endpointClosure: @escaping EndpointClosure = MoyaProvider.defaultEndpointMapping,
requestClosure: @escaping RequestClosure = MoyaProvider.defaultRequestMapping,
stubClosure: @escaping StubClosure = MoyaProvider.neverStub,
manager: Manager = RxMoyaProvider<Target>.defaultAlamofireManager(),
plugins: [PluginType] = [],
trackInflights: Bool = false) {
super.init(endpointClosure: endpointClosure, requestClosure: requestClosure, stubClosure: stubClosure, manager: manager, plugins: plugins, trackInflights: trackInflights)
}
- 编写StubClosure参数
查看它的类型,它是一个枚举,包含三个值,分别为:
public enum StubBehavior {
case Never //不使用Stub返回数据,即使用网络请求的真实数据
case Immediate //立即使用Stub返回数据 立即使用sampleData中的数据
case Delayed(seconds: NSTimeInterval) //一段时间间隔后使用Stub返回的数据 一段时间后使用sampleData中的数据
}
于是,我们可以定义一个全局变量isSampleTest来控制这个参数的值即可实现请求是用模拟的数据还是真实网络请求的数据。
/// 单元测试代码
let stubClosure: (_ type: T) -> Moya.StubBehavior = { type1 in
if isSampleTest {
return StubBehavior.immediate
} else {
return StubBehavior.never
}
}
RxMoyaProvider请求写法如下:其中,stubClosure参数,就用上面所写的闭包即可。
public class XYJMoyaHttp<T:TargetType> {
func sendRequest() -> RxMoyaProvider<T> {
return RxMoyaProvider<T>.init(endpointClosure: endpointClosure ,requestClosure: requestClosure,stubClosure: stubClosure,plugins: [NetworkLoggerPlugin.init(verbose: true,responseDataFormatter: {JSONResponseDataFormatter($0)}),spinerPlugin,XYJMoyaResponseNetPlugin()])
}
}
路由中(遵循TargetType协议的类),我们在sampleData中,写好自己想要返回的json,示例如下
import Foundation
import Moya
// MARK: 消息路由
public enum XYJMessageRouter {
// 获取个人消息 msg_type=1
case getPersonMsg(parameters: [String : Any])
// 获取公告消息 msg_type=2
case getNotice(parameters: [String : Any])
}
extension XYJMessageRouter: TargetType, XYJTargetType {
public var baseURL: URL { return URL(string: baseHostString)! }
public var path: String {
switch self {
case .getPersonMsg:
return "yd/app/user/getNoticeDetail"
case .getNotice:
return "yd/app/user/getNoticeDetail"
}
}
public var method: Moya.Method {
switch self {
case .getPersonMsg:
return .post
case .getNotice:
return .post
}
}
/// The parameters to be incoded in the request.
public var parameters: [String: Any]? {
switch self {
case .getPersonMsg(parameters: let dic):
return dic
case .getNotice(parameters: let dic):
return dic
}
}
public var parameterEncoding: ParameterEncoding {
return JSONEncoding.default
}
/// The type of HTTP task to be performed.
public var task: Task {
return .request
}
/// Whether or not to perform Alamofire validation. Defaults to `false`.
public var validate: Bool {
return false
}
/// Provides stub data for use in testing.
public var sampleData: Data {
switch self {
case .getPersonMsg:
return "{\"message\":\"成功\",\"data\":{\"result\":[{\"msg_title\":\"淘宝发布了\",\"msg_detail\":\"淘宝真的发布了\",\"msg_time\":\"2017-10-28 11:22:17\",\"msg_read\":false,\"msg_id\":\"22\",\"url\":\"https://www.taobao.com\"},{\"msg_title\":\"淘宝发布了\",\"msg_detail\":\"淘宝真的发布了淘宝真的发布了淘宝真的发布了淘宝真的发布了\",\"msg_time\":\"2017-10-28 11:22:17\",\"msg_read\":false,\"msg_id\":\"22\",\"url\":\"https://www.taobao.com\"}],\"pages\":\"3\",\"page_index\":\"1\",\"page_size\":\"10\",\"total\":\"24\",\"has_next_page\":true},\"code\":\"0\"}".data(using: String.Encoding.utf8)!
case .getNotice:
return "{\"message\":\"成功\",\"data\":{\"result\":[{\"msg_title\":\"淘宝发布了\",\"msg_detail\":\"淘宝真的发布了\",\"msg_time\":\"2017-10-28 11:22:17\",\"msg_read\":false,\"msg_id\":\"22\",\"url\":\"https://www.taobao.com\"},{\"msg_title\":\"淘宝发布了\",\"msg_detail\":\"淘宝真的发布了淘宝真的发布了淘宝真的发布了淘宝真的发布了淘宝真的发布了淘宝真的发布了淘宝真的发布了\",\"msg_time\":\"2017-10-28 11:22:17\",\"msg_read\":false,\"msg_id\":\"22\",\"url\":\"https://www.taobao.com\"}],\"pages\":\"3\",\"page_index\":\"1\",\"page_size\":\"10\",\"total\":\"24\",\"has_next_page\":true},\"code\":\"0\"}".data(using: String.Encoding.utf8)!
}
}
上文中,samleData中的json数据,按照接口文档编写即可。