近期遇到了一个问题, 那就是由于自己的疏忽, 导致了线上事故, 有错就要立正挨打. 其实这种问题, 我们开发过程中是经常性会遇到. 不管是由于业务任务繁多导致的疏忽, 还是由于其他紧急任务导致的连锁影响, 都是会造成先前一些业务的异常case.

作为一个业务开发人员, 其实很少会自己去写关于业务的单元测试. 原因主要有:

  • 业务任务过于繁重, 很多开发同学疲于任务开发, 能按时提测就已经很不错了.

  • 单元测试的介入实际上是有一定的入侵性的, 需要对一些业务逻辑结构进行改造, 这无疑是加重了工作强度.

  • 国内很多测试同学对于前端测试实际上是以黑盒测试为主, 任务也比较繁重.

基于上述的种种原因, 所以iOS的单元测试在国内不太流行(猜测), 网上的资料比较少, 很多的都是说单元测试该如何入门/如何使用, 很少有人对单元测试应该如何去实践.

同时写这篇博客之前, 我对于 "如何减少单元测试的入侵性" 想了近两天的时间, 依然是没有任何的结果, 直接就是黔驴技穷了.... 想要实现无感知的添加单元测试基本是不太可能的. 如果有那些大神可以给出意见, 骚栋感激不尽了.

不过我还是想就 "Swift的单元测试" 来谈谈我的一些看法和经验.


Swift单元测试环境搭建


首先就是Swift单元测试的环境搭建, 我们一共有两种方式, 下面我们就分别来聊聊这两种方式.

  • 在创建的项目的时候添加测试模块.

    这种形式一共会创建两个测试Taget. 一个是单元测试UnitTests, 另外一个是UI测试UITests, 这里我们只关注单元测试UnitTests即可.

  • 另外一种方式则是通过后期添加单元测试UnitTests的Target即可.

    然后, 搜索关键字Test, 添加 Unit Testing Bundle.

添加单元测试UnitTests之后, 我们调整我们的开发者账号和最低支持版本. 整体如下图所示.

这时候我们就可以写两个最简单的测试用例了, 然后 command + U 运行测试用例.

import XCTest

final class SwiftTestUnitDemoTests: XCTestCase {
    override func setUpWithError() throws {
    }

    override func tearDownWithError() throws {
    }

    func testExample() throws {
        XCTAssert(true, "测试成功")
    }
    
    func test2Example() throws {
        XCTAssert(false, "测试失败")
    }

    func testPerformanceExample() throws {
        measure {
        }
    }
}

然后就是如下结果.

到这里, 我们的单元测试已经正常运行了, 但是这只是我们的Debug环境, 如果我们切换到Release环境, 我们的运行一般会异常停止.

这是因为如果你访问了某一些类的私有方法和私有属性, 很有可以就会造成异常, 但是为什么在Debug模式下没有问题呢? 这是因为我们的项目配置 Enable Testability 导致的. 如下图设置即可.

Enable Testability 解释如下所示.

在iOS开发中,ENABLE_TESTABILITY(启用测试能力)是一种编译标记或构建设置,可以用于增强对应用程序的单元测试能力。通过启用测试能力,开发人员可以在单元测试中访问应用程序内部的私有符号和属性,从而更好地进行测试。

启用测试能力有两种常见的方法:

编译标记(Compiler Flag):在项目的Build Settings中,将ENABLE_TESTABILITY设置为YES或true。这将在应用程序的构建过程中启用测试能力,并使得应用程序的内部符号对于单元测试可见。

Xcode构建设置(Xcode Build Setting):在项目的Build Settings中,找到Enable Testability(是否启用测试能力)选项,并将其设置为YES或true。

启用测试能力后,编写的单元测试代码就可以直接访问应用程序的私有类、方法和属性,而不需要使用额外的框架或技巧来绕过访问限制。这对于测试一些内部逻辑和边界情况非常有用,可以更全面地覆盖应用程序的功能。

需要注意的是,启用测试能力可能会增加应用程序的可测试性,但也会增加应用程序的复杂性和潜在的安全风险。因此,在使用ENABLE_TESTABILITY之前,需评估测试能力的必要性,并根据具体情况进行权衡。

基本上, 到此刻我们的单元测试环境就搭建好了.


单元测试的覆盖率


单元测试覆盖率是我们单元测试一个非常重要的衡量指标.

像YYModel的单元测试覆盖率竟然达到了恐怖的 99%.

20230823135935-2023-08-23-13-59-35

那么该如何查看我们单元测试的覆盖率呢?

首先我们需要通过 Edit Scheme 进入 Test 配置模块.

然后点击 进入Test的配置界面.

然后 ConfiguartionsCode Coverage 设置为 On 即可.

然后, 我们每一次运行完我们的单元测试, 我们都可以通过如图步骤查看我们测试用例的覆盖率.


单元测试系统方法说明


由于我们测试单元是另外一个Target, 所以我们如果想要测试主Target的类与方法, 我们需要使用以下代码导入主Target.

@testable import SwiftTestUnitDemo


接下来, 我们需要看一下一个单元测试类都包含了哪些方法.

setUpWithError 主要是用来初始化一些变量使用的, 比如某个单元测试需要共享某个参数, 我们就可以在这里面进行初始化.

override func setUpWithError() throws {
}

tearDownWithError 主要的作用是用来释放某些变量等等.

override func tearDownWithError() throws {
}

testExample 是单元测试的示例, 我们写的每一个单元测试方法必须以 test 作为开头. 不以 test 开头的方法是不会加入自动化测试Plan.

func testExample() throws {
}

testPerformanceExample 这个示例主要用来测试性能的, 我们可以把一些耗时操作放到 measure 块中进行测试.

func testPerformanceExample() throws {
    measure {
        
    }
}

上面写的都是我们该如何写我们的测试用例, 但是我们该如何判断我们的业务逻辑是否正常呢? 难道是通过 Log日志 的形式吗? 显然, Log日志 并不是一种很直观的形式. 这里我们就要用到 断言 XCTAssert 来判断测试用例是否正确通过. 断言 这个不单单是 iOS 独有的, 基本上所有的高级语言都有 断言. 书归正传, 我们来聊聊 XCTest断言 XCTAssert 都有哪些形式.

断言作用
XCTFail(format…)生成一个失败的测试断言
XCTAssertNil(a1, format...)为空判断,a1为空时通过,反之不通过;
XCTAssertNotNil(a1, format…)不为空判断,a1不为空时通过,反之不通过;
XCTAssert(expression, format...)当expression求值为TRUE时通过;
XCTAssertTrue(expression, format...)当expression求值为TRUE时通过;
XCTAssertFalse(expression, format...)当expression求值为False时通过;
XCTAssertEqualObjects(a1, a2, format...)判断相等,[a1 isEqual:a2]值为TRUE时通过,其中一个不为空时,不通过;
XCTAssertNotEqualObjects(a1, a2, format...)判断不等,[a1 isEqual:a2]值为False时通过;
XCTAssertEqual(a1, a2, format...)判断相等(当a1和a2是 C语言标量、结构体或联合体时使用,实际测试发现NSString也可以);
XCTAssertNotEqual(a1, a2, format...)判断不等(当a1和a2是 C语言标量、结构体或联合体时使用);
XCTAssertEqualWithAccuracy(a1, a2, accuracy, format...)判断相等,(double或float类型)提供一个误差范围,当在误差范围(+/-accuracy)以内相等时通过测试;
XCTAssertNotEqualWithAccuracy(a1, a2, accuracy, format...)判断不等,(double或float类型)提供一个误差范围,当在误差范围以内不等时通过测试
XCTAssertThrows(expression, format...)异常测试,当expression发生异常时通过;反之不通过;(很变态)
XCTAssertThrowsSpecific(expression, specificException, format...)异常测试,当expression发生specificException异常时通过;反之发生其他异常或不发生异常均不通过;
XCTAssertThrowsSpecificNamed(expression, specificException, exception_name, format...)异常测试,当expression发生具体异常、具体异常名称的异常时通过测试,反之不通过;
XCTAssertNoThrow(expression, format…)异常测试,当expression没有发生异常时通过测试;
XCTAssertNoThrowSpecific(expression, specificException, format...)异常测试,当expression没有发生具体异常、具体异常名称的异常时通过测试,反之不通过;
XCTAssertNoThrowSpecificNamed(expression, specificException, exception_name, format...)异常测试,当expression没有发生具体异常、具体异常名称的异常时通过测试,反之不通过

特别注意下 XCTAssertEqualObjectsXCTAssertEqual

XCTAssertEqualObjects(a1, a2, format...) 的判断条件是[a1 isEqual:a2]是否返回一个YES。

XCTAssertEqual(a1, a2, format...) 的判断条件是a1 == a2是否返回一个YES。


普通的单元测试流程


首先, 我们说明一下什么叫做普通流程, 就是一个非异步流程可以提供最终判断结果, 供测试单元作为判别测试用例是否成功的依据.

比如这样的业务场景, 一个带有密码登录和验证码登录的登录界面, 当我们点击切换时候, 验证码框会做出展示/隐藏的响应. 这时候, 我们就可以使用单元测试来测试这一流程.

对于上述的场景, 我们看到有既定的输入, 必然会有既定的输出. 如果输出不符合我们的预期, 那么这个测试用例就是异常的.

也就是说, 如果我们切换至验证码登录状态时, 如果验证码框没有显示, 那就是说明相关业务逻辑就是异常的, 我们直接抛出断言即可.

下面写一个伪测试代码, 如果某一个断言不通过就说明 loadCodeLoginloadPasswordLogin 的相关业务流程是异常的.

func testLoadCodeLogin() {
    vc.loadCodeLogin()
    XCTAssert(vc.codeTextField?.isHidden == false, "验证码状态切换失败")
}
    
func testPasswordLogin() {
    vc.loadPasswordLogin()
    XCTAssert(vc.codeTextField?.isHidden == true, "密码登录状态切换失败")
}

如果一个业务流程没有暴露的判别状态, 我们该怎么办? 这时候, 我们只能改造相关的业务代码, 通过返回值返回形式或者其他形式也好, 需要返回一个相关的判别条件, 这实际上就是我这段时间一直考虑的单元测试对原始业务逻辑代码的入侵性问题.


异步请求的单元测试流程


其实更多时候, 我们需要测试我们异步网络请求的相关逻辑. 因为涉及端与端的联调, 业务链路变长, 业务复杂度上升的同时, 也会降低整个业务系统的稳定性. 所以, 异步网络请求的单元测试是非常有必要的.

异步网络请求的单元测试主要有以下几大步: 新建期望等待期望被履行履行期望

XCTestExpectation:测试期望,可以由测试类持有,也可以自己持有,自己持有测试期望时灵活性更好一些,你可以选择等待哪些期望。

wait(for:, timeout:) :等待异步的期望代码执行,根据初始化方式不同,等待的方法不同. 可以设定超时时间.

fulfill() :履行期望,并且适当加入XCTAssertTrue等断言,来验证测试结果。

具体示例如下所示.

func testHttpRequestUnitTest() throws {
    // 初始化期望
    let expectation = XCTestExpectation(description: "异步网络请求预期")
    Network.getRequest(url: httpRequest) { result in
        // 这里可以配合着断言XCTAssert使用
        XCTAssert(result["code"] == 0, "网络请求异常")
        // 执行期望
        expectation.fulfill()
    }
    // 等待期望
    wait(for: [expectation], timeout: 15.0)
}

这里也举一个相关的业务 🌰, 比如密码登录接口, 我们给定了账号和密码, 我们认定必然是会登录成功的, 如果登录不成功, 那么说明登录的业务逻辑中必然有异常.

假设我们的登录业务逻辑伪代码是如下所示的.

func doLogin(userName:String, password:String) {
    let parameters: Parameters = ["userName": userName, "password": password]
    Network.postRequest(url: userLogin, parameters: parameters, headers: Network.headers) { result in
        switch result {
        case .success(let json):
            if let jsonDict = json as? [String: Any],
                let dataField = jsonDict["data"] {
                do {
                    let jsonData = try JSONSerialization.data(withJSONObject: dataField, options: [])
                    let decoder = JSONDecoder()
                    let userModel = try decoder.decode(UserModel.self, from: jsonData)
                    print(userModel)
                } catch {
                    print("Error")
                }
            } 
            break
        case .failure(let error):
            print("Network request failed: \(error)")
            break
        }
    }
}

这时候我们发现 doLogin 登录逻辑方法既没有返回值,也没有别的好的手段抽离出一个判别依据, 难道这时候我们需要把主业务中登录逻辑写一遍吗? 如下所示.

func testDoLogin() throws {
    let parameters: Parameters = ["userName": "CoderDong", "password": "123456"]
    Network.postRequest(url: userLogin, parameters: parameters, headers: Network.headers) { result in
        switch result {
        case .success(let json):
            if let jsonDict = json as? [String: Any],
                let dataField = jsonDict["data"] {
                do {
                    let jsonData = try JSONSerialization.data(withJSONObject: dataField, options: [])
                    let decoder = JSONDecoder()
                    let userModel = try decoder.decode(UserModel.self, from: jsonData)
                    print(userModel)
                    XCTAssert(userModel != nil, "userModel  should not be nil")
                } catch {
                    print("Error")
                    XCTFail("请求异常")
                }
            } else {
                XCTFail("请求异常")
            }
            break
        case .failure(let error):
            XCTFail("网络异常")
            print("Network request failed: \(error)")
            break
        }
    }
}

上述的单元测试是否可行, 答案是当然可行. 但是这个单元测试覆盖率太低了, 我们这么写的单元测试只能覆盖到 登录业务接口是否正常, 并不能覆盖到 整个登录流程, 因为我们把数据解析组装都写到测试用例中来了.

说的简单一点, 如果我们在项目的主Target中, 错误的调整了解析组装逻辑. 这时候再次运行我们的测试用例, 我们发现我们的测试用例是正常的, 但实际上主Target的业务逻辑确实异常的. 这也就是我们写的单元测试覆盖率较低的表现.

哔哔这么多, 总要想一个方案处理吧, 我对此就是添加了一个网络请求单元测试的闭包回调. 回调中有一个负载 payload, 我们只需要把单元测试的判断依据回传即可.

typealias UnitTestCallBack = (_ payload: Any) -> Void

下面看一下, 我是怎么改造 doLogin 方法的. 为了减少入侵性, 我们在参数列表的尾部添加了一个 UnitTestCallBack 类型的回调, 并且给它附上初始值, 让它成为一个可选参数. 这样主Target调用 doLogin 的逻辑部分是不用发生改动. 极大的降低了外部感知.

func doLogin(userName:String, password:String, unitTestCallBack: UnitTestCallBack? = nil)

然后在 doLogin 内部做判别依据的回调工作.

func doLogin(userName:String, password:String, unitTestCallBack: UnitTestCallBack? = nil)
    let parameters: Parameters = ["userName": userName, "password": password]
    Network.postRequest(url: userLogin, parameters: parameters, headers: Network.headers) { result in
        switch result {
        case .success(let json):
            if let jsonDict = json as? [String: Any],
                let dataField = jsonDict["data"] {
                do {
                    let jsonData = try JSONSerialization.data(withJSONObject: dataField, options: [])
                    let decoder = JSONDecoder()
                    let userModel = try decoder.decode(UserModel.self, from: jsonData)
                    print(userModel)
                    unitTestCallBack(true)
                } catch {
                    print("Error")
                    unitTestCallBack(false)
                }
            } else {
                unitTestCallBack(false)
            }
            break
        case .failure(let error):
            print("Network request failed: \(error)")
            unitTestCallBack(false)
            break
        }
    }
}

然后在单元测试模块, 我们如下编写我们的测试用例即可.

func testDoLoginSuccess() throws {
    let userName = "CoderDong"
    let password = "123456"
    let expectation = XCTestExpectation(description: "异步网络请求预期")
    loginVC?.doLogin(userName: userName, password: password, unitTestCallBack: { payload in
        XCTAssertEqual(payload as? Bool, true)
        // 完成预期
        expectation.fulfill()
    })
    // 等待预期达成,设置一个等待时间
    wait(for: [expectation], timeout: 15.0)
}

func testDoLoginError() throws {
    let userName = "CoderDong"
    let password = "999999"
    let expectation = XCTestExpectation(description: "异步网络请求预期")
    loginVC?.doLogin(userName: userName, password: password, unitTestCallBack: { payload in
        XCTAssertEqual(payload as? Bool, true)
        // 完成预期
        expectation.fulfill()
    })
    // 等待预期达成,设置一个等待时间
    wait(for: [expectation], timeout: 15.0)
}

如上的过程, 我们的测试用例覆盖率就会大大提高了, 这虽然是我们想要看到的结果. 但是我们的测试单元对原始业务逻辑代码做了一定层度上的入侵.


闲侃大山


单元测试的基本核心思想就是纯函数, 也就说 给定一个已知输入, 必然有一个已知的固定输出结果.

也就说, 我们不管中间业务逻辑过程是如何操作的, 我们可以把中间的业务逻辑看做一个黑盒. 我们要测试中间的业务逻辑, 只需要给它一个已知结果的输入, 如果输出结果不符合我们的预期, 那么我们认定中间的黑盒出现了问题.

从上面的两种形式的单元测试, 我们不难发现, 编写测试用例很容易造成代码入侵性的问题.

这也就是为什么我在博客的开始说, 我一直在考虑代码入侵性的问题. 这时候就需要我们自己衡量其中的利弊, 毕竟既没有入侵性, 又有较高的覆盖率的情况基本上是不存在的. 所以, 我们自己应该在 入侵深度覆盖率 两者间做权衡.

同时, 单元测试的业务单元也是要自己来把控的, 如果颗粒太大, 虽然覆盖率很高, 但是由于链路的过长导致整体不稳定性大大提高. 如果颗粒过小, 则单元测试的工作量会大大提高. 所以对于单元测试的颗粒度大小 需要自己通过 入侵深度覆盖率业务单元 三方面共同考虑来权衡拆分.


结束


关于Swift的单元测试就写到这里, 对于性能的单元测试这里就不过叙述了. 如果有任何疑问, 欢迎随时评论. 欢迎各位大佬提出意见.



IT界无底坑洞栋主 欢迎加Q骚扰:676758285