iOS 单元测试简单入门

摘要

为了减少线上bug概率,提高交付质量,目前我们已经加入了质检以及代码互看环节,但是这只对业务逻辑以及代码质量方面的检查,如果还想更加了提高交付质量,那么单元测试也是少不了的。于是,接来了一个月,主要任务还是把单元测试添加到项目中。

单元测试对于我目前来说主要测试两个方面

  • 测试一些重要方法的输入输出是否符合预期以及出错时的相应处理是否合理。
  • 测试接口返回,保证每一个接口处理都正常。

本文中,主要讲述了Qiuck框架的单元测试编写以及Moya框架自带接口测试功能的使用。

一、单元测试

首先,为了选择是原生的框架还是网络上现有的轮子,在网上与查阅了大量资料,对比后,排除了使用原生的,因为有一些第三方良好的对XCTest进行了一层封装。目前比较流行的开发测试框架中,在github上搜索:BDD(行为驱动开发)或TDD(测试驱动开发),因为我们单元测试关心的是行为,于是搜索倾向BDD,发现star最多的是wikispecta,但是这两个都是oc,由于我们项目目前使用的纯swift开发,后来,找到了另两个,分别为:sleipnirQuick,对比一看,后者的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]一些插件。回调的位置在发送请求后,接受服务器返回之前
    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数据,按照接口文档编写即可。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343