原文地址
作为一个好的开发者,你会尽全力测试全部的功能和你写的代码逻辑及其结果。但是很少会把所有的逻辑和结果都测试到。
随着应用体积增大和复杂度增加,十有八九手动的测试会让你忽视到越来越多东西。
自动测试,包括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"。
就这样,你就可以开始写你项目的自动测试了。
如果想给现已有的项目添加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,把testExample
和 testPerformanceExample
删了,因为不需要他们。
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>
虽然这不是什么大问题,但是有些主张每个测试方法里只有一个断言。
这样做的主要原因是为了侦测错误。
当一个测试方法含有多个测试断言时,一个失败,整个方法就会被标记为错误,其他的断言就没有测试到。
这样的话你一次这能测一个错误,你也不能知道其他的断言有没有失败。
想我们这种情况,这测试邮件的合法性,所以代码可以保持原样。(.....)
<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, 在测试的结尾你需要设置waitForExpectationWithTimer
block,这个代码块会在预期(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")
}
}
}
现在再跑一遍测试,就会发现和我们预期的一样没有通过测试。
用不带回调的闭包测试方法
我们的示例工程中的方法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
, 我们就可以放心的对这个类进行修改。
重要提示:
如果方法已经改变,但是单元测试没有及时更新,单元测试就没有意义了。
不要把单元测试推迟到最后写,开发的同时就应该开始写了。这样你才能对哪里需要测试和哪里是边际条件有更好的理解。
编写UI测试
当所有的单元测试都搞定了,那么就是开始写一体化测试的时候了,而UI测试是必要的一部分。
开始UI测试前,需准备些UI元素和交互,让我们创建一个视图控制器。
- 在
main.storyboard
中拉一个viewcontroller,长成下面这样。
邮箱的textfield设tag100, 密码textfield为101,密码确认102.
- 新建
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测试是检测在邮箱,密码,确认密码都填好时,注册按钮有没有变成可用,步骤如下:
- 打开
TestingIOSUITests.swif
,删除testExample()
方法,并添加testRegistrationButtonEnabled()
- 把光标放在
testRegistrationButtonEnabled()
方法中,点击红色的录制测试按钮,(在屏幕下方)
- 之后应用将会启动,然后在邮箱输入框中输入邮箱,你会发现,代码自动就出现在方法体中了。
这是个简单的在输入框中输入的指引。
let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element
emailTextField.tap()
emailTextField.typeText("email@test.com")
- 在交互已经录完后,点击停止按钮。
- 现在可以在出现的代码中来进行测试。
录制的指引可能并不是总是好读,可能还让人难于理解。幸运的是,你可以手动的输入UI说明。
手动添加一下UI说明:
- 用户点击了密码输入框
- 用户输入了密码
在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")
}
通过写良好的测试称为一个更好开发者
从我的经验看,尝试些测试会让你成为一个更好的开发者。你会尝试更好的组织你的代码。
有条理的,模块化的代码是一个成功的,抗压的测试的前提。
当想到应用的结构时,你会发现,使用MVVM,MVP,VIPER或者其他的模式,你的代码会更加健壮,易于测试。