开始之前
请允许先介绍在iOS开发测试中的一些基础框架和理论:
在iOS开发的过程中,我们常接触到的单元测试框架有 Qucik以及他的好朋友Nimble,前者是iOS编程开发中行为驱动开发框架,后者是对iOS平台XCTest结果预期处理的更简易化、人性化的封装。
iOS的UI自动化测试,则直接使用的是XCTest框架,一方面是很容易进行脚本的录制,另一方面可以通过WebDriverAgent等三方框架接入,结合Appium以及行为描述语言Cucumber等,实现多语言跨端的脚本化的自动化测试,此处按住不表。
再来说说测试替身(Test Double),为了避免争议,下面上Martin Fowler对于Test Double解释。
- Dummy objects are passed around but never actually used. Usually they are just used to fill parameter lists.
- Fake objects actually have working implementations, but usually take some shortcut which makes them not suitable for production (an InMemoryTestDatabase is a good example).
- Stubs provide canned answers to calls made during the test, usually not responding at all to anything outside what's programmed in for the test.
- Spies are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent.
- Mocks are pre-programmed with expectations which form a specification of the calls they are expected to receive. They can throw an exception if they receive a call they don't expect and are checked during verification to ensure they got all the calls they were expecting.
实战
有了以上的基础理论后,我们来逐条看这些方式在iOS编程中是如何实现的,先做工程架构假设:
该 iOS App Swift 语言开发,使用MVVM架构,
通过CocoaPods进行依赖管理,同时集成了以下三方组件:
测试组件:Quick和Nimble,
弹窗组件:Toast
网络基础组件:Alamofire
以及服务模拟组件:OHHTTPStubs/Swift
Dummy
场景诉求: 我有一个页面,布局了一个界面元素以及一个提交按钮, 为了验证该页面的元素是否在页面初始化后正常加载,我需要通过UI自动化测试来运行工程,并在App启动后,通过脚本录制进入到该页面,并进行页面元素的检查验证。(此处只做元素是否正常显示的验证)。
说明: 因为使用MVVM结构,在页面进行初始化的时候,需要进行ViewModel的初始化,很明显,在我们通过StoryBoard托拉拽期间,ViewModel是不参与逻辑的,但因为在初始化VC的时候,就需要将ViewModel绑定到VC,所以viewModel需要一个初始值来保证代码能够正常运行但是不参与逻辑模块。
代码片段:
// 初始化ViewModel
let dummyViewModel = ViewModel()
// 将其作为参数参与到ViewController的创建中
let viewController = ViewController(viewModel:dummyViewModel)
navgationController.push(viewController)
测试代码:
// UITest中对于button是否显示的判断
let app = XCUIApplication()
app.launch()
let tablesQuery = app.tables
tablesQuery.staticTexts["商家详情"].tap()
let trackLabel = app.staticTexts["提交"]
XCTAssertEqual(trackLabel.exists, true)
Fake
场景诉求:在真是的开发场景中,针对于前端一般都会有配套BFF服务,那么在开发的过程中,往往因为服务端开发与前端开发的进度不同步,会出现前端开发同学需要通过一种轻量级的实现来替代后端BFF,以满足其开发阶段模拟服务数据达到实现业务诉求的情况。
说明:在上一例子中,我们再页面里选择了几个checklist选项 ,并点击提交按钮,此时需要调用API服务发起订单提交请求,此时会有这样一个场景:提交成功。假设我们与后端开发已经进行了接口API约定,定义了正常处理的返回数据结构,则可以通过启用一个轻量级实现的MockServer,返回特定结果,帮助我们完成Service层的逻辑开发。
代码片段:
// Services 层代码:
var shoppingCart: Dictionary<Food, Int> = Dictionary()
func checkout(success: @escaping successCallback, fail: @escaping failCallback) {
service.checkoutService(shoppingCart) {
success()
} failure: { error in
fail(error)
}
}
测试代码:
// Test 部分代码:
let service = CheckoutService()
context("checkout") {
// 工序X fake BFF,实现service
it("should be callback success when call BFF success") {
stub(condition: isHost("127.0.0.0")) { _ in
// loading 成功的 json文件
let stubPath = OHPathForFile("checkoutSuccess.json", type(of: self))
// 在OHHTTPStubs中,返回http 200结果,并将成功的结果通过接口返回
return fixture(filePath: stubPath!, status: 200, headers: ["Content-Type": "application/json"])
}
waitUntil(timeout: .seconds(5)) { done in
// 在service中进行 checkout 服务调用,并等待5秒等待成功的返回结果。
service.checkout(Dictionary<Food, Int>()) {
done()
} failure: { error in
}
}
}
Mock
场景诉求:在业务场景中,我们经常需要根据某种操作的异常case,通过UI页面对用户进行Toast提示,比如,在进行业务的提交处理时,因为数据格式不正确,则需要通过本地校验后提示用户当前信息格式不正确,请修改后再提交的场景。
说明:在上一例子中,用户在页面对话框中,输入了手机号,但是位数少于11位,则需要通过Toast提示用户,手机号码位数不正确,请检查。此时,我们通过Mock一个6位的字符串,通过check方法进行校验和处理。
代码片段:
// viewModel 层代码:
func check(person:Person)->(Result)
Unit Test代码:
// Test 部分代码:
let mockPerson = Person(phone:"123456", name:"Lei")
let result = viewModel.check(mockPerson)
expect(result).to(equal(Result.lessThan))
顺便提一下,此场景也可以通过UI自动化测试来覆盖:
// UITest 部分代码:
func waitForElementToAppear(_ element: XCUIElement, timeout: TimeInterval = 5, file: String = #file, line: UInt = #line) {
let existsPredicate = NSPredicate(format: "exists == true")
expectation(for: existsPredicate,
evaluatedWith: element, handler: nil)
waitForExpectations(timeout: timeout) { (error) -> Void in
if (error != nil) {
let message = "Failed to find \(element) after \(timeout) seconds."
self.recordFailure(withDescription: message, inFile: file, atLine: Int(line), expected: true)
}
}
}
let tablesQuery = app.tables
tablesQuery.staticTexts["商家详情"].tap()
let textField = app.textFields["phoneNumber"]
textField.tap()
textField.clearText(andReplaceWith: "123456")
app.staticTexts["提交"].tap()
let element = app.staticTexts["手机号码位数不正确,请检查"]
waitForElementToAppear(element, timeout: 10)
Stub
场景诉求:在业务场景中,我们经常需要根据某种操作的异常,通过UI页面对用户进行Toast提示,比如,我们期望在进行业务的提交处理时,因为服务返回的特殊结果,需要通过UI层展示一个提示。
说明:这是一个异常处理,需要通过ViewModel层的开发来实现异常展现的逻辑,通常的开发方法是在调用Service进行业务逻辑处理时,通过BFF真是请求返回一个错误,才能进行异常流程的开发和调试。而我们通过对Service层的Stub,使其返回相应的异常结果,ViewModel层只需要捕获这些异常进行处理即可快速处理业务的分支逻辑。
代码片段:
// 首先对 Service进行 Protocol 抽象:
protocol ServiceProtocol {
typealias successCallback = () -> Void
typealias failureCallback = (_ error: Error) -> Void
func checkoutService(_ cart: Dictionary<Food, Int>, success: @escaping successCallback, failure: @escaping failureCallback)
}
Unit Test代码:
// 进行请求异常的Stub模拟,调用该实现时,即返回一个返回错误的Stub
class StubServiceFail: ServiceProtocol {
var error = ResponseError()
// stub fail status
func checkoutService(_ cart: Dictionary<Food, Int>, success: @escaping successCallback, failure: @escaping failureCallback) {
failure(error)
}
}
// 进行验证处理:
context("checkout") {
it("should be callback fail when call checkout service stub fail 9001") {
let stubService = StubServiceFail()
stubService.error = ResponseError(code: 9001, message: "no stock")
ViewModel.service = stubService
// 进行异常的验证
waitUntil(timeout: .seconds(3)) { done in
foodListViewModel.checkout {
} fail: { error in
done()
}
}
}
}
结束语
以上说明和代码片段,便是我对于测试替身在iOS编程开发中的一点点实践和整理,现在依然记得,早年在单元测试照猫画虎实践Mock和Stub方法,再到后来引入BDD概念和各种测试框架,测试覆盖率是上去了,质量也有可观的收益了,却并没有一个基础的理论明确告诉你为什么这么做,哪种场景下应该这么做。通过这次测试替身的实践,让我明白了测试替身的基本概念,也明白了在什么场景下使用哪种测试方法更合适,希望这边文章也能帮到迷惑的你。