必威-必威-欢迎您

必威,必威官网企业自成立以来,以策略先行,经营致胜,管理为本的商,业推广理念,一步一个脚印发展成为同类企业中经营范围最广,在行业内颇具影响力的企业。

自定义参数匹配器必威:,通过测试可以让我们

2019-09-17 13:22 来源:未知

必威 1新建项目.png

MVP各层的单元测试框架的使用

Google官方的Android Architecture Blueprints这个项目除了是Google利用不同的流行的开源库组合来搭建MVP框架的实践,同时还有完整的测试用例,所以学习价值很高,所以如果没了解过,现在就去看看吧,必定受益匪浅

这是这个项目对于MVP模式的各层做测试的各种测试框架使用

必威 2

MVPTesting

  • View层:涉及到UI且需要再设备上运行,所以需要Espresso和AndroidJUnitRunner
  • Preseneter层:Preseneter层应该设计成纯JAVA层的,所以使用JUnit+Mockito
  • Model层:需要依赖Android环境
原文地址

作为一个好的开发者,你会尽全力测试全部的功能和你写的代码逻辑及其结果。但是很少会把所有的逻辑和结果都测试到。

随着应用体积增大和复杂度增加,十有八九手动的测试会让你忽视到越来越多东西。

自动测试,包括UI测试和后端APIs,会让你的工作更加自信,并且减少开发,重构,添加新功能,或者是更改已有功能的压力。

有了自动测试,你可以:

  • 减少bug: 没有办法可以完全去除代码中的bugs,但是自动测试可以极大减少bug的数量。
  • 改动更加有信心:添加新功能时避免出现bug,这意味着你可以更快的做出代码的调整,又不会痛苦。
  • 代码的文档化: 作为一个开发者,你有时候可能会害怕重构,尤其是重构一大堆代码的时候,单元测试(Unit tests)可以保证重构的代码可以和你预期的一样正常工作。

这篇文章教你如何构建并执行iOS平台上的自动测试。

Unit Tests vs. UI Tests

区分单元测试和UI测试很重要。

单元测试是在一个特定的上下文中对某个功能的测试。单元测试负责验证被测试的那部分代码(通常是一个单一的方法)能够按照目的正常工作,有大量关于单元测试的文章和博客,所以这里我们并不覆盖这个部分。

UI测试是用来测试交互界面的,例如,界面是否按预期刷新,或者在用户操控界面元素时候其指定方法是否被调用。

每个UI测试只测试一个单独明确的用户操作,自动测试能够,也应该,在单元测试和UI测试的层面上进行。

构建自动测试

由于Xcode支持现成的单元测试和UI测试,添加到你的工程中就很简单了,当创建新的工程时,只要勾选“Include Unit Tests” 和“Include UI Tests.”

当项目构建成功时,两个targets会加到你的项目目录中,名称分别是"XXX Tests" 或者 "XXX UITests"。

就这样,你就可以开始写你项目的自动测试了。

必威 3

如果想给现已有的项目添加UI和单元测试的话,你就要多做几个步骤,但是仍然很简单。

打开File -> New -> Target 然后选 iOS Unit Testing Bundle 或者 iOS UI Testing Bundle,按下一步,选择被测试的目标就可以了。

编写单元测试

在我们写单元测试前,我们必须理解他的结构。
当你将单元测试包含到你的项目中时,会创建一个示例的测试类,像这样:

import XCTest
@testable import TestingIOS

class TestingIOSTests: XCTestCase {

    override func setUp() {
        super.setUp()
// Put setup code here. This method is called before the invocation of each test method in the class.

    }

    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
        super.tearDown()
    }


    func testExample() {
        // This is an example of a functional test case.
        // Use XCTAssert and related functions to verify your tests produce the correct results.
    }


    func testPerformanceExample() {
        // This is an example of a performance test case.
        self.measure {
            // Put the code you want to measure the time of here.
        }
    }

}

最重要需要理解的方法是 setUp 和 tearDown。
setUp方法是在每个测试方法前被用的,tearDown相反。
如果我们运行这个示例中代码,他会按如下顺序执行:

setUp → testExample → tearDown setUp → testPerformanceExample → tearDown

Tips: 按cmd + U 运行测试

如果你只想运行一个指定的测试方法,点击方法左边的小方块,如图:

现在,如果你准备好了,你就可以写测试代码了。

添加一个负责用户注册的界面,用户会添加邮箱,密码,和确认密码,我们的示例类会负责检查输入的合法性,并尝试注册。
注意: 此例使用MVVM结构,用MVVM是用为它能使应用结构更清晰,更易于测试。

有了MVVVM,我们就更容易区分业务逻辑和展示逻辑,从而避免大体量视图控制器的问题。

MVVM的详细内容并不在此文范围,你可以从这里获取更多信息。

我们创建一个view-model类来负责用户的注册..

class RegisterationViewModel {
        var emailAddress: String? {
            didSet {
                enableRegistrationAttempt()
            }
        }
        var password: String? {
            didSet {
                enableRegistrationAttempt()
            }
        }
        var passwordConfirmation: String? {
            didSet {
                enableRegistrationAttempt()
            }
        }
        var registrationEnabled = Dynamic(false)
        var errorMessage = Dynamic("")
        var loginSuccessful = Dynamic(false    
        var networkService: NetworkService
        init(networkService: NetworkService) {
            self.networkService = networkService
        }
}

首先,我们添加了写属性,动态属性,和一个初始化方法。

不必担心Dynamic,他是MVVM中的一部分。

当一个Dyanmic<Bool>的值设为true时,被这个viewmodel绑定视图控制器会激活注册按钮,当loginSuccessful设为true时,他相连的视图也会被更新。
现在添加一些方法来检查密码和邮箱的合法性。

func enableRegistrationAttempt() {
    registrationEnabled.value = emailValid() && passwordValid()
}
func emailValid() -> Bool {
    let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"
    let emailTest = NSPredicate(format:"SELF MATCHES %@", emailRegEx)
    return emailTest.evaluate(with: emailAddress)
} 
func passwordValid() -> Bool {
    guard let password = password,
    let passwordConfirmation = passwordConfirmation else {
    return false
}
let isValid = (password == passwordConfirmation) &&
    password.characters.count >= 6
    return isValid
}

每次用户在邮箱或者密码输入框键入些什么时,enableRegistrationAttempt方法会被激发来检查其是否是正确的格式,或者通过registrationEnabled这个动态属性决定注册按钮是否可用。

为了保证本例简单,添加了两个方法-- 一个是检测邮箱是否可用,一个是尝试使用 用户填写的用户名和密码注册。

func checkEmailAvailability(email: String, withCallback callback: @escaping (Bool?)->(Void)) {
        networkService.checkEmailAvailability(email: email) { (available, error) in
            if let _ = error {
                self.errorMessage.value = "Our custom error message"
            } else if !available {
                self.errorMessage.value = "Sorry, provided email address is already taken"
                self.registrationEnabled.value = false
                callback(available)
            }
        }
}
func attemptUserRegistration() {
        guard registrationEnabled.value == true else { return }
        // 还是为了简单,此处密码未哈希
        guard let emailAddress = emailAddress,
        let passwordHash = password else { return }
        networkService.attemptRegistration(forUserEmail: emailAddress, withPasswordHash: passwordHash) {
            (success, error) in 
            if let _ = error {
                self.errorMessage.value = "Our custom error message"
            } else {
                self.loginSuccessful.value = true
            }
        }   
}   

API处理方法简便起见就写了个假的,NetworkService是一个协议,通过NetworkServiceImpl实现。

typealias RegistrationAttemptCallback = (_ success: Bool, _ error: NSError?) -> Void
typealias EmailAvailabilityCallback = (_ available: Bool, _ error: NSError?) -> Void

protocol NetworkService {
    func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String,
                             andCallback callback: @escaping RegistrationAttemptCallback)    
    func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback)
}
class NetworkServiceImpl: NetworkService {
    func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) 
{
        // Make it look like method needs some time to communicate with the server
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: {
            callback(true, nil)
        })
    }
func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) {
        // Make it look like method needs some time to communicate with the server
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: {
            callback(true, nil)
        })
    }
}

现在,一个完整的例子已经写好,我们可以写一个覆盖到这些类的单元测试。

1,为我们的viewmodel新建一个测试类,在TestingIOSTests文件夹中右击,然后 NewFile-> Unit Test Case Class,命名为RegistrationViewModelTests

2,把testExampletestPerformanceExample删了,因为不需要他们。

3,由于Swift使用的modules 和我们使用的不同,我们需要在import声明和类定义之间添加一个@testable, 不然,我们无法应用我们的方法或则类。

4,添加registrationViewModel变量。

整个应该看起来是这样:

import XCTest
@testable import TestingIOS
class RegistrationViewModelTests: XCTestCase{
    var registrationViewModel: RegisterationViewModel?
    override func setUp() {
        super.setUp()
    }
    override func tearDown() {
        super.tearDown()
    }
}

让我们试试写一个测试emailVaild的方法,取名testEmailValid。 在方法的前面添加一个test关键词很重要,否则,这个方法不会被识别为测试方法。

我们的测试方法就像这样:

func testEmailValid() {
    let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())
    registrationVM.emailAddress = "email.test.com"
    XCTAssertFalse(registrationVM.emailValid(), "(registrationVM.emailAddress) shouldn't be correct")   
    registrationVM.emailAddress = "email@test"
    XCTAssertFalse(registrationVM.emailValid(), "(registrationVM.emailAddress) shouldn't be correct")
    registrationVM.emailAddress = nil
    XCTAssertFalse(registrationVM.emailValid(), "(registrationVM.emailAddress) shouldn't be correct")
    registrationVM.emailAddress = "email@test.com"
    XCTAssert(registrationVM.emailValid(), "(registrationVM.emailAddress) should be correct")
}

我们的使用断言方法,用来检查每个情况的true或者false。
若是false,assert会报错(和整个方法一起),并且输出信息。
其他可以使用的断言方式还有: XCTAssertEqualObjects, XCTAssertGreaterThan, XCTAssertNil, XCTAssertTrue 或者 XCTAssertThrows。
如果你现在运行测试,方法会通过。你已经成功的创建了你的第一个测试方法,但是实际上还没有真正的准备好。这个方法由三个问题存在(一个大的,两个小的)

问题1:你直接使用了 NetworkService协议的实现方法

单元测试的主要准则之一就是,每一个测试都应该独立于外部变量或者是依赖,单元测试应该是自动的。

如果你在测试一个方法,比如测试一个依赖于后台的API方法,那么这个测试就会关联到你的网络代码和后台的实际情况,若后台在测试没有运行,你的测试就会失败。

在这种情况下,你测试RegistrationViewModel的方法,RegistrationViewModel依赖于NetworkServiceImpl类,即使我们要测试的方法emailValid并不是直接依赖于 NetworkServiceImpl
写单元测试的时候,所有的外部依赖需要被移除,但是关键是怎样移除NetworkService的依赖同事又不更改RegistrationViewModel的实现呢?

有个简单的解决方案,叫做 Object Mocking.
若果你仔细看RegistrationViewModel的时候,你会发现他遵守NetworkService协议,当RegistrationViewModel初始化时,NetworkService的实现就会被给到或者是注入到RegistrationViewModel对象中。

这个原则称为dependency injection via constructor,(还有其他更多种类的依赖注入)。

网上有很多的关于依赖注入的文章,看这里和这里

RegistrationViewModel实例化时,他会注入一个NetworkService 协议的实现
let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())

由于我们的viewmodel依赖于这个协议,所以可以创建一个自定义的(或者mocked(虚拟的))NetworkService实现类并且把这个mocked类注入到viewmodel对象中。

让我们开始创建虚拟的NetworkService协议实现类。

TestingIOSTests下新建一个叫NetworkServiceMock的swift文件。

里面写:

import Foundation
@testable import TestingIOS
class NetworkServiceMock: NetworkService {
    func attemptRegistration(forUserEmail email: String,
                             withPasswordHash passwordHash: String,
                             andCallback callback: @escaping RegistrationAttemptCallback) {
        // Make it look like method needs some time to communicate with the server
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: {
            callback(true, nil)
        })
    }
    func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) {
        // Make it look like method needs some time to communicate with the server
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: {
            callback(false, nil)
        })
    }
}

看起来和NetworkServiceImpl没什么区别,但是在实际的生产环境中,NetworkServiceImpl还会包含网络代码,网络返回的处理,类似的代码。

而这个虚拟的类什么也不做,这也是他的意义,因为他什么都不做的就可以在测试中排除他了。

所以解决这第一个问题,我们的测试方法应该这么写:

let registrationVM = RegisterationViewModel(networkService: NetworkServiceMock())

<font color=#41639B>问题2:viewmodel直接在测试方法体中被实例化</font>

setUp``tearDown的存在是有意义的。

这些方法是用来初始化或者配置在测试中需要的对象用的,你应该使用这些方法避免代码在各个方法里写好多次,当不使用这些方法也不是什么大问题,尤其是当你需要一些特殊的配置时。

RegistrationViewModel的初始化比较简单,就直接在setUp和tearDown中重构了。

class RegistrationViewModelTests: XCTestCase {
    var registrationVM: RegisterationViewModel!
    override func setUp() {
        super.setUp()
        registrationVM = RegisterationViewModel(networkService: NetworkServiceMock())
    }
    override func tearDown() {
        registrationVM = nil
        super.tearDown()
    }
    func testEmailValid() {
        registrationVM.emailAddress = "email.test.com"
        XCTAssertFalse(registrationVM.emailValid(), "(registrationVM.emailAddress) shouldn't be correct")
        ...
    }
}

<font color=#41639B>问题3在一个测试方法中存在多个断言:</font>

虽然这不是什么大问题,但是有些主张每个测试方法里只有一个断言。

这样做的主要原因是为了侦测错误。

当一个测试方法含有多个测试断言时,一个失败,整个方法就会被标记为错误,其他的断言就没有测试到。

这样的话你一次这能测一个错误,你也不能知道其他的断言有没有失败。

想我们这种情况,这测试邮件的合法性,所以代码可以保持原样。(.....)

一 Mockito 概念图


必威 4

概念图

单元测试能够使"微小的特性"分离.通常你需要模拟类—提供模仿对象来实现功能上的动作—使微小的特性脱离出来便于测试.Objective-C中有许多第三方框架来实现对象的模拟和销毁.但这些来源于不断实践和总结的库,还不能在Swift中使用.未来的某天,希望会能用! :]

进行单元测试的一个重要前提,就是我们要测试的代码是“可测试”(testable code)的,是指代码的测试要满足一些条件,包括:

什么是单元测试

在计算机编程中,单元测试(Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。但是什么叫"程序单元"呢?是一个模块、还是一个类、还是一个方法(函数)呢?不同的人、不同的语言,都有不同的理解。一般的定义,尤其是是在OOP领域,是一个类的一个方法。在此,我们也这样理解:单元测试,是为了测试某一个类的某一个方法能否正常工作,而写的测试代码。

单元测试的三个步骤:

  • setup:即新建出待测试的类、设置一些前提条件等
  • 执行动作:即调用被测类的被测方法,并获取返回结果
  • 验证结果:验证获取的结果跟预期的结果是一样的

编写UI测试

当所有的单元测试都搞定了,那么就是开始写一体化测试的时候了,而UI测试是必要的一部分。

开始UI测试前,需准备些UI元素和交互,让我们创建一个视图控制器。

  1. main.storyboard中拉一个viewcontroller,长成下面这样。

必威 5

邮箱的textfield设tag100, 密码textfield为101,密码确认102.

  1. 新建RegistrationViewController.swift关联到上面的vc,
import UIKit
class RegistrationViewController: UIViewController, UITextFieldDelegate {
    @IBOutlet weak var emailTextField: UITextField!
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var passwordConfirmationTextField: UITextField!
    @IBOutlet weak var registerButton: UIButton! 
    private struct TextFieldTags {
        static let emailTextField = 100
        static let passwordTextField = 101
        static let confirmPasswordTextField = 102
    }
    var viewModel: RegisterationViewModel?   
    override func viewDidLoad() {
        super.viewDidLoad()
        emailTextField.delegate = self
        passwordTextField.delegate = self
        passwordConfirmationTextField.delegate = self
        bindViewModel()
    }
}

将ViewModel中的动态属性‘绑定’到视图控制器中的输入框上,你可以这样写:

    fileprivate func bindViewModel() {
        viewModel?.registrationEnabled.bindAndFire {
            self.registerButton.isEnabled = $0
        }
    }

给输入框写代理:

 func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        guard let viewModel = viewModel else {
            return true
        }
        let newString = (textField.text! as NSString).replacingCharacters(in: range, with: string)
        switch textField.tag {
        case TextFieldTags.emailTextField: viewModel.emailAddress = newString
        case TextFieldTags.passwordTextField: viewModel.password = newString
        case TextFieldTags.confirmPasswordTextField: viewModel.passwordConfirmation = newString
        default:
            break
        }
        return true
    }

将viewmodel绑定到控制器中:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        initializeStartingView()
        return true
}
fileprivate func initializeStartingView() {
        if let rootViewController = window?.rootViewController as? RegistrationViewController {
            let networkService = NetworkServiceImpl()
            let viewModel = RegisterationViewModel(networkService: networkService)
            rootViewController.viewModel = viewModel
        }
}

storyboard和RegistrationViewController虽然简单,但是已经足够用来展示自动UI测试是怎么工作的了。

如果一起设置妥当,注册按钮在app启动时,应该是不能用的状态,仅在所有信息都填好的时候。

我们的UI测试是检测在邮箱,密码,确认密码都填好时,注册按钮有没有变成可用,步骤如下:

  1. 打开 TestingIOSUITests.swif,删除testExample() 方法,并添加testRegistrationButtonEnabled()
  2. 把光标放在testRegistrationButtonEnabled()方法中,点击红色的录制测试按钮,(在屏幕下方)

必威 6

  1. 之后应用将会启动,然后在邮箱输入框中输入邮箱,你会发现,代码自动就出现在方法体中了。

这是个简单的在输入框中输入的指引。

let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element
        emailTextField.tap()
        emailTextField.typeText("email@test.com")
  1. 在交互已经录完后,点击停止按钮。
  2. 现在可以在出现的代码中来进行测试。

必威 7

录制的指引可能并不是总是好读,可能还让人难于理解。幸运的是,你可以手动的输入UI说明。

手动添加一下UI说明:

  1. 用户点击了密码输入框
  2. 用户输入了密码

在storyboard的输入框中,给ui元素添加识别id(在属性检查器的accessibility下面的identifier),密码的识别id是passwordTextField
所以他的指引可以这么写:

let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"]
        passwordTextField.tap()
        passwordTextField.typeText("password")

还有一个用户输入确认密码的ui交互没有写,一样:

let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"]
        confirmPasswordTextField.tap()
        confirmPasswordTextField.typeText("password")

之后就是写断言了,和单元测试中一样,检查注册按的isEnabled是否改变:

let registerButton = XCUIApplication().buttons["REGISTER"]
XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled")

这个就像这样:

func testRegistrationButtonEnabled() {
        // Recorded by Xcode
        let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element
        emailTextField.tap()
        emailTextField.typeText("email@test.com")
        // Queried by accessibility identifier
        let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"]
        passwordTextField.tap()
        passwordTextField.typeText("password")
        // Queried by placeholder text
        let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"]
        confirmPasswordTextField.tap()
        confirmPasswordTextField.typeText("password")
        let registerButton = XCUIApplication().buttons["REGISTER"]
        XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled")
    }

运行测试,看断言是否起作用。

为了改进测试,可以测试注册按钮的isEnabled是否改为false:

func testRegistrationButtonEnabled() {
        let registerButton = XCUIApplication().buttons["REGISTER"]
        XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled")
        // Recorded by Xcode
        let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element
        emailTextField.tap()
        emailTextField.typeText("email@test.com")
        XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled")
        // Queried by accessibility identifier
        let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"]
        passwordTextField.tap()
        passwordTextField.typeText("password")
        XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled")
        // Queried by placeholder text
        let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"]
        confirmPasswordTextField.tap()
        confirmPasswordTextField.typeText("pass")
        XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled")
        confirmPasswordTextField.typeText("word") // the whole confirm password word will now be "password"
        XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled")
    }

二 前要知识


1、测试的类型

  • Unit test : 单元测试是关注单一的类,检查这个类中的代码是否按预期正确执行
  • Integration test:检查开发的模块和其它模块整合时是否正确执行
  • End-to-End test:将整个系统作为一个整体,然后从用户的角度去进行测试,看系统在实际应用中是否正确执行

** 2、单元测试概念**

  • 被测系统(System under test,SUT):被测系统是当前被测试的系统,目的是检查当前被测的系统是否被正确运行,根据测试系统的不同,SUT 所指代的内容也不同,可以是一个类也可以是整个系统。
  • 测试依赖组件(DOC):被测系统所依赖的组件,例如:进行 UserService 的单元测试时,UserService 会依赖 UserDao ,那么 UserDao 就是 DOC
  • 测试替身(Test Double):用一些功能简单的且其行为和实际对象类似的假对象来作为 SUT 的依赖对象
  • Test stub:为 SUT 提供假数据的对象
  • Fake object:实现了简单功能的一个假对象
  • Mock object:用来模拟实际的对象,并校验当前的 mock object 的方法是否符合预期
  • Dummy object:在测试中并不使用,但是为了测试代码正常的编译/运行而添加的对象(比如仅仅填充参数列表)
  • Test spy:包装一个真实的 Java 对象,并返回一个包装后的新对象,对这个新对象的所有方法进行调用时,都会委派为实际的对象。

你通过ABPeoplePickerNavigationController来添加人员到列表中.这个类使开发者无需明确地请求许可就能获得用户的通讯录.

方法一

使用XCTestExpectation,简单来说,就是允许我们在一个时间范围里,给Xcode设置一个“期望”,如果期望满足了就表示测试成功,如果超时了,就表示测试失败。

// 创建 expectlet expect = expectation(description: <#描述#>)// 在异步执行中条件满足的地方放置“满足”expect.fulfill()// 给“期望值”设置一个超时时间,在需要等待的地方调用waitForExpectations(timeout: <#需要等待的秒数#>, handler: nil)// 下面就使用 XCTAssert 去验证自己的测试

这样,只要在需要等待的秒数之内得到了返回值,测试就会成功,否则,就会失败。

通过Xcode expectation也只能部分解决我们的问题,对于测试异步执行的代码,这种方式仍有一些问题的。如果测试的是网络方法,我们无法去保证测试方法的等待秒数,而且有些网络方法有不一样的超时时间,这样这个需要等待的秒数就难以有一个统一的数字。

Mockito

Mockito的两个重要的功能是,验证Mock对象的方法的调用和可以指定mock对象的某些方法的行为。(对于不懂Mock概念的同学来说,第一次看到的确很可能很难理解)

<font color=#41639B>通过异步调用来测试方法</font>

无论应用有多简单,某个方法都有可能性在一个异步的线程中被调用,尤其是你一贯的在他自己的线程中进行UI。

单元测试中异步调用的主要的问题是需要时间来结束,但是单元测试不会等到他结束,就是异步的block还没执行,单元测试已经结束了,导致我们的测试结果都是一个样(无论你在block中写什么)。
下面是一个示例,

func testCheckEmailAvailability() {
        registrationVM.registrationEnabled.value = true
        registrationVM.checkEmailAvailability(email: "email@test.com") {
            available in
            XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled")
        }
    }

这里你想要测试在 我们的方法告诉你某个邮箱已经被占用时,registrationEnabled 是否会变成false。
结果肯定是通过测试,

但是如果改成这样:
XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled")

测试的结果依然是通过测试。
原因就是上述的,幸运的是,Xcode6 中添加了XCTestExpectation类,其工作流程是:

1, 在测试的开始部分设置你的测试预期(expectation)--- 就是一句话描述你想要在测试中测试什么。
2, 在一个异步的block中,你实现这个预期(expectation)。
3, 在测试的结尾你需要设置waitForExpectationWithTimerblock,这个代码块会在预期(expectation)完成时或者是计时器跑完
4, 现在,单元测试除非在预期完成或者计时器到时,否则不会结束。

像下面这样写:

func testCheckEmailAvailability() {
        // 1. Setting the expectation
        let exp = expectation(description: "Check email availability")
        registrationVM.registrationEnabled.value = true
        registrationVM.checkEmailAvailability(email: "email@test.com") {
            available in
            XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled")
            // 2. Fulfilling the expectation
            exp.fulfill()
        }
        // 3. Waiting for expectation to fulfill
        waitForExpectations(timeout: 3.0) {
            error in
            if let _ = error {
                XCTAssert(false, "Timeout while checking email availability")
            }
        }
    }

现在再跑一遍测试,就会发现和我们预期的一样没有通过测试。

三 Mockito


1、获取

        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-all</artifactId>
            <version>1.8.5</version>
            <scope>test</scope>
        </dependency>

2、Mockito 相关 API

  • 验证行为
import static org.mockito.Mockito.*;

//创建mock
List mockedList = mock(List.class);

//使用 mock 对象,mock 对象调用过的方法都会被记录下来
mockedList.add("one");
mockedList.clear();

//验证,mock 对象调用了 add("one") 和 clear()
verify(mockedList).add("one");
verify(mockedList).clear();
  • stubbing
//可以mock具体的类,而不仅仅是接口
List list = mock(list.class);

//存根(stubbing),当调用 mock 对象的  get(0) 时会返回 first,
              当调用 get(1) 时,会抛出一个异常
when(list.get(0)).thenReturn("first");
when(list.get(1)).thenThrow(new RuntimeException());

// 验证存根的调用
verify(list).get(0);
  • 参数匹配器
//anyInt() 表示参数是任意的 int
when(mockedList.get(anyInt())).thenReturn("element");

//做验证
verify(mockedList).get(anyInt());

更多的参数匹配器

自定义参数匹配器

  • 验证调用次数
//使用mock
mockedList.add("once");

mockedList.add("twice");
mockedList.add("twice");

mockedList.add("three times");
mockedList.add("three times");
mockedList.add("three times");

//下面两个验证是等同的 - 默认使用times(1)
verify(mockedList).add("once");
verify(mockedList, times(1)).add("once");

//验证精确调用次数
verify(mockedList, times(2)).add("twice");
verify(mockedList, times(3)).add("three times");

//使用using never()来验证. never()相当于 times(0),表示 add 没有被调用过
verify(mockedList, never()).add("never happened");

//使用 atLeast()(至少)/atMost()(至多)来验证
verify(mockedList, atLeastOnce()).add("three times");
verify(mockedList, atLeast(2)).add("five times");
verify(mockedList, atMost(5)).add("three times");
  • 使用 Exception 做 void 的存跟
doThrow(new RuntimeException()).when(mockedList).clear();

//下面会抛出 RuntimeException:
mockedList.clear();
  • 确保交互从未在mock对象上发生
//使用mock - 仅有mockOne有交互
mockOne.add("one");

//普通验证
verify(mockOne).add("one");

//验证方法从未在mock对象上调用
verify(mockOne, never()).add("two");

//验证其他mock没有交互
verifyZeroInteractions(mockTwo, mockThree);
  • @mock注解
public class MockTest {

   @Mock private ArticleCalculator calculator;

重要的是: 需要在基类或者test runner中的加入:

    MockitoAnnotations.initMocks(testClass);

PeopleListDataProvider从Core Data中获得数据填充到table view中.

使用上面的套路,我们就可以使用Swift的Protocol方便快捷的做出一个用于测试的假对象了

Espresso

Espresso提供大量的方法用来进行UI测试,这些API可以让你写的简洁和可靠的自动化UI测试,站在用户的角度测试,所有逻辑是黑盒

Espresso的三个重要功能:

  • 灵活的可扩展的视图(特别是AdapterView)匹配APIs. 详情 View matching.
  • 大量的UI交互操作APIs. 详情 Action APIs.
  • UI线程同步,提高测试可靠性, 详情 UI thread synchronization.

使用流程

  • 元素定位
public static ViewInteraction onView(final Matcher<View> viewMatcher) {}

Espresso.onView方法接收一个Matcher<View>类型的入参,并返回一个ViewInteraction对象。ViewMatchers对象提供了大量的withXXX方法用来定位元素,常用的有withIdwithText和一系列的isXXX方法用来判断元素的状态。如果单一的匹配条件无法精确地匹配出来唯一的控件,我们可能还需要额外的匹配条件,此时可以用AllOf#allOf()方法来进行复合匹配条件的构造(下面的AdapterView节有使用到):

onView(allOf(withId(id), withText(text)))
  • 操作元素

当定位到元素后,返回一个ViewInteraction对象,其perform方法可以接收一系列ViewAction用来进行模拟用户操作,ViewActions类提供了大量的操作实现,常用的有typeTextclick

public ViewInteraction perform(final ViewAction... viewActions) {}
  • 验证结果

最后为了验证操作是否符合预期,我们还是需要定位到元素,获得一个ViewInteraction对象,其check方法接收了一个ViewAssertion的入参,该入参的作用就是检查结果是否符合我们的预期。

public ViewInteraction check(final ViewAssertion viewAssert) {}

ViewAssertion提供如下方法,这个方法接收了一个匹配规则,然后根据这个规则为我们生成了一个ViewAssertion对象,这个Matcher<View>的如参和定位元素的时候是一个用法的

public static ViewAssertion matches(final Matcher<? super View> viewMatcher) {}

下面是测试主页部分UI测试的代码

@RunWith(AndroidJUnit4.class)
@LargeTest
public class MainActivityUITest {
    /**
     * {@link ActivityTestRule} is a JUnit {@link Rule @Rule} to launch your activity under test.
     * <p/>
     * Rules are interceptors which are executed for each test method and are important building
     * blocks of Junit tests.
     */
     @Rule
     public ActivityTestRule<MainActivity> mActivityRule = new ActivityTestRule<MainActivity>(MainActivity.class, true, false);

     //默认在测试之前启动该Activity
 //    public ActivityTestRule<MainActivity> mActivityRule = new ActivityTestRule<MainActivity>(MainActivity.class);


    @Test
    public void testNavigateToAdvanceFragment() {
        Intent intent = new Intent();
        //携带信息的方式启动Activity
        intent.putExtra("EXTRA", "Test");
        mActivityRule.launchActivity(intent);
        // Open Drawer to click on navigation.
        Espresso.onView(ViewMatchers.withId(R.id.drawer_layout))
                .check(ViewAssertions.matches(DrawerMatchers.isClosed(Gravity.LEFT)))
                .perform(DrawerActions.open()); // Open Drawer
        // navigate to advance fragment.
        Espresso.onView(ViewMatchers.withText(R.string.advanced_settings_group_title_advanced_features))
                .perform(ViewActions.click());
        // Left Drawer should be closed.
        Espresso.onView(ViewMatchers.withId(R.id.drawer_layout))
                .check(ViewAssertions.matches(DrawerMatchers.isClosed(Gravity.LEFT)));
        Espresso.onView(ViewMatchers.withText(R.string.fingerprint_settings_title)).check(ViewAssertions.matches(ViewMatchers.isDisplayed()));
    }

}

@Rule+ActivityTestRule用来标记需要测试的Activity,测试框架会再运行@Test标注的测试用例之前会启动这个指定的Activity(类似还有IntentsTestRuleServiceTestRule分别用于测试Intent和Service),有些情况我们测试的Actvity需要再Intent那里携带一些信息,这里也是可以通过ActivityTestRule的不同构造函数+ActivityTestRule#launchActivity方法来完成

它的作用是找到R.id.drawer_layout的View,这是一个DrawerLayoutDrawerMatchers提供了用于判断DrawerLayout的状态,DrawerActions类提供了用于操作DrawerLayout的操作。首先是判断DrawerLayout是隐藏的,然后打开抽屉,并找到R.string.advanced_settings_group_title_advanced_features标记的Item,进行一次点击操作,最后验证当前显示的是高级界面

通过写良好的测试称为一个更好开发者

从我的经验看,尝试些测试会让你成为一个更好的开发者。你会尝试更好的组织你的代码。

有条理的,模块化的代码是一个成功的,抗压的测试的前提。

当想到应用的结构时,你会发现,使用MVVM,MVP,VIPER或者其他的模式,你的代码会更加健壮,易于测试。

  • 有个Person.swift和PersonInfo.swift文件.Person类是个NSManagedObject来包含每个人的一些基本信息.PersonInfo结构体包含相同的信息,但能够从地址薄中被实例化.
  • PeopleList文件夹有三个文件:一个view controller,一个data provider和一个data provider protocol.

方法二

通过上边的mock串行化异步执行的代码:

  1. 将异步回调闭包中的属性都设成MockXxx的成员属性
  2. 在需要调用回调闭包的地方,不等待,直接调用
  3. 在单元测试中,在执行有异步回调闭包的方法之前,自己模拟给闭包中的属性设置预期值

小结:该方法就是直接跳过异步等待的时间,自己制造假的回调来使不稳定的异步执行方法变成“可测试的代码”。

使用

用不带回调的闭包测试方法

我们的示例工程中的方法attemptUserRegistration使用的NetworkService.attemptRegistration方法中包含了异步的代码: 尝试用后台的API注册一个用户。

在例子里,这个方法等待1秒来模拟网络请求,并完成注册过程。若注册成功,则loginSuccessful被该成true,让我们做一下这个过程的测试。

func testAttemptRegistration() {
        registrationVM.emailAddress = "email@test.com"
        registrationVM.password = "123456"
        registrationVM.attemptUserRegistration()
        XCTAssert(registrationVM.loginSuccessful.value, "Login must be successful")
}

测试结果是失败的,因为networkService.attemptRegistration没有执行, loginSuccessful也就没有被设为true。

我们在NetworkServiceImpl写过一个attemptRegistration方法,他是等了1秒再返回成功的回调,你可以在这里使用GCD,利用asyncAfter方法来1秒后执行断言方法,代码像这样:

func testAttemptRegistration() {
        registrationVM.emailAddress = "email@test.com"
        registrationVM.password = "123456"
        registrationVM.passwordConfirmation = "123456"
        registrationVM.attemptUserRegistration()   
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
            XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful")
        }
    }

然并软,这个还是不行,失败的原因和我们上面说的一样,所以,我们就使用XCTestException类:

 func testAttemptRegistration() {
        let exp = expectation(description: "Check registration attempt")
        registrationVM.emailAddress = "email@test.com"
        registrationVM.password = "123456"
        registrationVM.passwordConfirmation = "123456"
        registrationVM.attemptUserRegistration()
        DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
            XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful")
            exp.fulfill()
        }
        waitForExpectations(timeout: 4.0) {
            error in
            if let _ = error {
                XCTAssert(false, "Timeout while attempting a registration")
            }
        }
    }

现在由于单元测试已经覆盖了整个RegistrationViewModel, 我们就可以放心的对这个类进行修改。

重要提示:
如果方法已经改变,但是单元测试没有及时更新,单元测试就没有意义了。

不要把单元测试推迟到最后写,开发的同时就应该开始写了。这样你才能对哪里需要测试和哪里是边际条件有更好的理解。

  1. More code: 大量的测试很容易使测试代码量超过功能性的代码量.
  2. More to maintain: 更多的代码意味着需要更大精力的维护成本.
  3. No silver bullet: 单元测试不能消除你所有的bug.
  4. Takes longer: 写测试花费更多时间(而这些时间你本可以在raywenderlicn.com学更多新奇的知识).
  1. 定义protocol(XxxProtocol)来模拟一个原先的类型,定义出原先类型中需要测试的方法
  2. 使用extension来使原先的类型遵守上面的protocol(XxxProtocol)
  3. 在程序中需要测试的位置中原先的类型就可以改成遵守了上述protocol的类型(XxxProtocol)
  4. 创建一个Mock的类,遵守上述的protocol(XxxProtocol),实现里面需要测试的方法

Local Unit Tests的优点

  • 不依赖Android的API,运行速度快,所以更快地得到结果反馈
  • 引导更好的代码设计(单一职责、依赖注入),如果一个类不好测,往往是因为这个类的设计是有问题

当开始测试之前,一个坏消息和一个好消息.坏消息是单元测试有许多坏处,比如下面的:

TAG标签:
版权声明:本文由必威发布于必威-编程,转载请注明出处:自定义参数匹配器必威:,通过测试可以让我们