为什么需要写测试代码?如何用 XCTest 写单元测试代码?

| Swift , 测试 , XCTest

 

内容概览

  • 前言
  • 为什么需要写测试代码?
  • 如何用 XCTest 写测试代码?
    • 为项目引入测试目标
    • 在哪里写测试代码?
    • 写测试代码的常见流程
    • 运行功能测试代码
    • 运行性能测试代码
    • 运行整个测试用例的代码
    • 查看测试结果
  • 实战演练:编写实用的测试代码
  • 优化架构:依赖注入、依赖反转
  • 总结

 

前言

 

每一位程序员都听说过 测试 这个名词,然而并不是每一位程序员都会写测试代码,更不是每一位程序员都会主动地去写测试代码。

为什么呢?

  1. 写测试代码 很难 吗?
  2. 写测试代码真的很 费时费力 吗?
  3. 是否有必要 写测试代码?
  4. 某些代码真的 可以测试吗 ?
  5. …

有谁可以回答这些灵魂拷问?

后文中的示例代码存储在这个 Github 仓库 中,仅供您参考。

 

为什么需要写测试代码?

 

恕我愚见,我们可以换个思路来考虑写测试代码的必要性。下面,请思考这些问题:

  1. 测试代码是否可以 保障业务逻辑代码的可靠性?
  2. 写测试代码是否可以帮助 改善代码的质量和架构设计?
  3. 是否可以 高效地写测试代码?

对于这几个问题,我相信您的回答一定都是肯定的!如果不是,那么就请您认真阅读本文并进行深入的思考。

 

如何用 XCTest 写测试代码?

 

Xcode 中最常见的测试有 3 种:单元测试、性能测试(基准测试)、UI 测试。

其中,最最常见的测试代码就是单元测试,因为单元测试往往是最先同时也是最常被构建的测试代码。很多开发人员都习惯在完成业务逻辑代码后再为之构建测试代码,也有很多开发人员崇尚 TDD(Test-Driven Development: 测试驱动开发),他们会在构建业务逻辑代码前先构建测试代码,然后依靠测试来推动业务逻辑代码的构建与重构。

无论您以何种方式构建测试代码,只要能够通过写测试代码来保证项目的质量,那就至少是可行的方式。

 

为项目引入测试目标

在开始写测试代码之前,您的项目需要具备写测试的基本条件。

如果您在打造新项目,请您在创建项目的时候勾选包含测试的选项:

如果您需要为现有的项目增加测试,请您这样操作:

点击+之后,您会看到:

然后根据您的实际需要来增加测试目标(单元测试、UI测试)即可。比如现在 Ficow 点击了增加单元测试目标的选项,Xcode 会弹出一个面板:

请注意上图中箭头指向的选项!现有的项目中可能有多个 project 和多个 target,所以请确保您选中了正确的选项。如果您的项目是在一个 Xcode workspace中,您还可以为项目中的多个 project 或多个 target 添加测试目标。

到这里,我们就已经为项目引入了测试目标。接下来就和 Ficow 一起写测试代码吧!

 

在哪里写测试代码?

在开始写测试代码之前,您需要知道在哪里写测试代码。您可以通过测试导航页面中的测试用例找到测试代码:

您也可以通过项目导航页面找到测试代码:

以下就是单元测试用例 XCTestDemoTests 的大致内容:

// 导入 XCTest 测试框架
import XCTest

// 导入需要测试的模块
@testable import XCTestDemo

// 一个测试用例,可能包含多个测试方法
class XCTestDemoTests: XCTestCase {

    // 在这个测试用例类开始执行测试方法前执行,并且只执行一次
    override class func setUp() {

    }

    // 在这个测试用例类中所有的测试方法执行结束后执行,并且只执行一次
    override class func tearDown() {

    }

    // 在每个测试方法启动前执行,用于配置每个测试用例
    override func setUpWithError() throws {

    }

    // 在每个测试方法执行结束后执行,用于清理
    override func tearDownWithError() throws {

    }

    // 一个功能测试方法(方法名必须以 test 开头)
    func testExample() throws {
        // 您可以使用 XCTAssert 相关方法来验证测试是否产生了正确的结果
        XCTAssert(true)
    }

    // 一个性能测试方法(方法名必须以 test 开头)
    func testPerformanceExample() throws {
        self.measure {
            // 把需要测试运行时间的代码添加到这里
        }
    }
}

您可以参考代码中的注释和后续的讲解来理解这些代码的作用:

import XCTest
因为您需要测试的代码往往都是业务逻辑代码,所以这些代码都必须能够完成正常的编译而且可以导入到测试目标中。

在 Xcode 中写单元测试需要用到 XCTest,所以我们必须导入这个框架。

@testable import XCTestDemo

@testable import XCTestDemo 就是将 XCTestDemo 这个项目中需要被测试的 XCTestDemo 目标导入,@testable 可以提升该目标中的代码的访问控制(access control)级别。internal 和 public 级别的类和类成员会被提升为 open,而其他类型则会从 internal 提升为 public 级别。

setUp(), tearDown()

如果您需要在整个测试用例类初始化或者销毁时执行某些代码,那么您就需要将这些代码放到 class func setUp() 或 class func tearDown() 中。

如果您需要在每一个测试方法执行前后去执行某些代码,那么您就需要将这些代码放到 func setUp() 或 func tearDown() 中。方法定义中的 throws 允许您在该方法中抛出异常,XCTest 测试框架最终会将抛出的异常汇报给您。

testExample()

这将会是您最常写的测试方法,您会在该方法中验证代码的功能是否符合预期。

testPerformanceExample()

通常您只会在关注性能的时候才会写这种测试方法。

 

在理解了上面的内容之后,您就可以开始写测试代码了。

 

写测试代码的常见流程

  1. 创建一个测试用例类(比如:XCTestDemoTests),这个类必须是 XCTestCase 的子类。
  2. 在 setUpWithError() 方法中添加这个测试用例中的多个测试方法启动前都需要运行的代码(比如:创建某个文件、数据库记录、必要的类型实例等),如果多个测试方法没有共用的逻辑,您可以跳过这一步。
  3. 在测试方法中运行业务逻辑代码并使用 XCTAssert 等方法验证运行结果。
  4. 在 tearDownWithError() 方法中添加多个测试方法运行结束后都需要运行的代码(删除测试数据、消除循环引用、释放内存空间等),如果多个测试方法没有共用的逻辑,您可以跳过这一步。
  5. 运行某个测试方法,根据测试失败的结果修正业务逻辑代码,直到这个测试方法通过测试。
  6. 如果这是一个性能测试方法,您需要为该测试设置一个基准时间,然后 XCTest 会在多次的性能测试中以该时间来度量运行结果,当性能测试结果满足您的要求时,这个测试方法也就通过了测试,否则就会失败。
  7. 运行整个测试用例的所有测试方法,根据测试失败的结果修正业务逻辑代码,直到整个测试用例通过为止。
  8. 运行所有测试用例,根据测试失败的结果修正业务逻辑代码,直到所有测试用例通过为止。

 

运行功能测试代码

您可以点击某个测试左边的菱形,当您把鼠标移到菱形上面时,它会变成一个播放按钮的样子:

点击该按钮,测试就会被启动。测试结束后,您会看到该按钮发生了变化:

绿色的勾表示测试已经通过。

红色的叉表示测试已经失败,而且您会看到红色的高亮行显示了测试失败的原因。只要您在用 XCTAssert 验证测试结果时传入了失败时显示的消息,Xcode 就会在测试失败时为您显示该消息。这可以让您快速弄清楚测试失败的原因,所以 Ficow 建议您传入一个有意义的失败消息,如果有必要您可以格式化这个失败消息,比如:传入某些具体的参数,以方便您分析和调试这段测试失败的代码。

 

运行性能测试代码

在第一次运行性能测试代码后,该测试就可以设置基准时间了。XCTest 会根据这个基准时间来告知您之后的性能测试结果是否优于这个基准。点击左侧的灰色菱形框,您可以看到一个性能结果面板,在这里您可以设置该性能测试的基准时间:

您可以多次调整这个基准时间,直到测试结果非常接近基准时间为止。

通过观察上面这个图,您会发现性能测试将被测试代码执行了 10 次,蓝色柱状图相对于黑色线的起伏程度展示了被测试代码的性能稳定性。黑色线上方的蓝色块表示测试执行时间高于平均时间(Average: 0.0467s),黑色线下方的蓝色块表示测试执行时间低于平均时间。由此可以看出,Ficow 添加的测试代码的性能是普遍好于平均时间的(其实只是输出整数到标准输出而已😹)。

 

运行整个测试用例的代码

当您完成了某个测试方法并让它单独通过测试之后,您就可以开始测试整个测试用例了。
多个测试方法可能会有共用代码(比如:setUp, tearDown 方法中的代码),调整某个测试方法时有可能影响到其他测试方法。所以,运行整个测试用例是非常有必要的。而且相比于运行所有测试用例,运行整个测试用例可以尽快地暴露出会失败的测试代码。

 

运行所有测试用例的代码

如果整个测试用例都通过了测试,您就可以运行所有测试用例了。您可以通过点击 Xcode 菜单栏或者直接按下 Command + U 快捷键来触发这个操作。

 

查看测试结果

您可以在测试导航页查看测试运行的大致结果。

如果您想查看详细的测试报告,可以通过右键菜单中的 Jump to Report 导航到报告页面。

您可以看到运行所有测试所耗费的总时长(图中的1)。如果有失败的测试,您可以通过点击 Failed (图中的2)来快速过滤。

至此,您已经学会了如何使用 XCTest 框架来构建测试代码。不过,前文中代码示例中的测试代码并不实用,我们一般不会这样去写测试代码。

接下来,Ficow 将通过构建一些实用的示例来演示如何编写实用的测试代码。

 

实战演练:编写实用的测试代码

 

首先,构建一个包含 Model, Service, ViewModel 的示例(演示代码可能不严谨,如有疑问请留言):

// 存储用户信息
struct UserInfo {
    let id: Int
    let name: String
}

// 负责发起网络请求
final class Service {

    enum ServiceError: Error, Equatable {
        case wrongAccountOrPassword
    }

    static let shared = Service()

    func login(account: String, password: String, completion: @escaping (Result<UserInfo, Service.ServiceError>) -> Void) {
        // 实际项目不会这样检查固定值,请勿模仿
        guard account == "ficow" && password == "1234" else {
            completion(.failure(ServiceError.wrongAccountOrPassword))
            return
        }
        // 模拟发起实际的网络请求
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            completion(.success(UserInfo(id: 1, name: "Ficow Shen")))
        }
    }
}

// 负责处理页面的业务逻辑
final class ViewModel {

    // 用于检查运行结果
    private(set) var userInfo: UserInfo?
    private(set) var loginError: Service.ServiceError?

    func login(account: String, password: String) {
        // 直接使用了 Service 单例
        Service.shared.login(account: account, password: password) { [weak self] result in
            switch result {
            case .success(let userInfo):
                self?.saveUserInfo(userInfo)
            case .failure(let error):
                self?.showLoginError(error)
            }
        }
    }

    private func saveUserInfo(_ userInfo: UserInfo) {
        self.userInfo = userInfo
    }

    private func showLoginError(_ error: Service.ServiceError) {
        self.loginError = error
    }
}

为了简化演示代码,saveUserInfo 和 showLoginError 方法并没有执行预期的操作而是存储了运行结果,这样可以方便后续的测试代码检查结果。

然后,为上面的演示代码构建测试用例:

import XCTest
@testable import XCTestDemo  // 请勿忘记导入待测试的模块,而且需要加上 @testable 以提升模块的访问控制级别

class ViewModelTests: XCTestCase {

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

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

    func testLoginFailure() {
        // 重复构建 ViewModel
        let viewModel = ViewModel()
        viewModel.login(account: "ficow", password: "1")

        // 登录失败时必须有错误
        XCTAssertEqual(viewModel.loginError, Service.ServiceError.wrongAccountOrPassword, "should login fail with wrongAccountOrPassword error")

    }

    func testLoginSuccess() {
        // 重复构建 ViewModel
        let viewModel = ViewModel()
        viewModel.login(account: "ficow", password: "1234")

        // 使用 expectation 完成异步测试
        let expect = expectation(description: "login")

        DispatchQueue.main.asyncAfter(deadline: .now() + 1.1) {
            // 登录成功时必须有用户信息
            XCTAssertEqual(viewModel.userInfo?.id, 1, "wrong user id")
            XCTAssertEqual(viewModel.userInfo?.name, "Ficow Shen", "wrong user name")

            // expectation 完成
            expect.fulfill()
        }

        // 等待异步测试的结果:完成或者超时
        waitForExpectations(timeout: 2, handler: nil)
    }
}

看完上面的演示代码还有测试用例,您是否发现了这些代码的可优化空间?

如果没有,请您务必仔细阅读后文并和 Ficow 一起深入地思考。

 

优化架构:依赖注入、依赖反转

 

上面的示例代码中最值得优化的就是“写死”的代码,ViewModel 中的 func login(account: String, password: String) 方法写死了 Service.shared 的调用。如果我们需要在测试时执行 ViewModel 中的方法,我们就不得不发起实际的网络请求。然而实际的网络请求有一定的概率使测试失败,比如:网络请求超时、失败等等。

另外,上面展示的只是一个 ViewModel 和一个 Service。往往实际的项目中有非常多的 ViewModel 和 Service,这时候就很容易发生滥用单例的情况。很多开发人员喜欢在项目的各个角落“写死”各种单例,然后又因为多线程数据竞争问题被迫给单例加上各种同步机制(锁、同步队列等)。

那我们该怎么优化这种代码呢?

 

依赖注入

我们可以选择将这个单例传入,而不是在请求方法中写死!

这个在网络请求方法中需要被用到的单例就是 依赖,传入参数叫做 注入,这就是大家耳熟能详的 依赖注入。简而言之就是不写死,传参!😹

改为依赖注入后,ViewModel 现在就变成了这样:

final class ViewModel2 {

    ...

    private let service: Service

    // 任何遵循 ServiceProvider 协议的实例都可以传入
    init(service: Service) {
        self.service = service
    }

    func login(account: String, password: String) {
        // 使用初始化时传入的依赖:service
        service.login(account: account, password: password) { [weak self] result in
            switch result {
            case .success(let userInfo):
                self?.saveUserInfo(userInfo)
            case .failure(let error):
                self?.showLoginError(error)
            }
        }
    }

    ...
}

这时候怎么测试呢?Service 其实是一个 final class,不允许继承!所以,我们无法通过继承来 Service 类中的实例方法。

那么,除非我们去掉 final 关键字,不然就没有办法在测试时伪造 Service 中的请求方法。所以,也就无法解决测试过程中会发起实际的网络请求这个问题。有没有办法不去掉 final 关键字,同时还可以解决发起实际请求的问题呢?

 

依赖反转

别慌!还有一个办法可以解决这个问题,那就是:依赖反转。

以下是采用了 依赖反转 之后的代码:

// 存储用户信息
struct UserInfo {
    let id: Int
    let name: String
}

protocol ServiceProvider {
    func login(account: String, password: String, completion: @escaping (Result<UserInfo, Service.ServiceError>) -> Void)
}

// 负责发起网络请求
final class Service: ServiceProvider {

    enum ServiceError: Error, Equatable {
        case wrongAccountOrPassword
    }

    static let shared = Service()

    func login(account: String, password: String, completion: @escaping (Result<UserInfo, Service.ServiceError>) -> Void) {
        // 实际项目不会这样检查固定值,请勿模仿
        guard account == "ficow" && password == "1234" else {
            completion(.failure(ServiceError.wrongAccountOrPassword))
            return
        }
        // 模拟发起实际的网络请求
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            completion(.success(UserInfo(id: 1, name: "Ficow Shen")))
        }
    }
}

// 负责处理页面的业务逻辑
final class ViewModel {

    // 用于检查运行结果
    private(set) var userInfo: UserInfo?
    private(set) var loginError: Service.ServiceError?

    private let serviceProvider: ServiceProvider

    // 任何遵循 ServiceProvider 协议的实例都可以传入,默认是 Service 单例
    init(serviceProvider: ServiceProvider = Service.shared) {
        self.serviceProvider = serviceProvider
    }

    func login(account: String, password: String) {
        // 使用初始化时传入的依赖:serviceProvider
        serviceProvider.login(account: account, password: password) { [weak self] result in
            switch result {
            case .success(let userInfo):
                self?.saveUserInfo(userInfo)
            case .failure(let error):
                self?.showLoginError(error)
            }
        }
    }

    private func saveUserInfo(_ userInfo: UserInfo) {
        self.userInfo = userInfo
    }

    private func showLoginError(_ error: Service.ServiceError) {
        self.loginError = error
    }
}

首先,让 Service 遵循 ServiceProvider 协议,然后在 ServiceProvider 协议中添加所有需要在测试过程中被伪造(mock)的网络请求方法。

然后,将 ViewModel 中所有的 Service 类型改为 ServiceProvider。在初始化时,默认传入 Service 单例。之后在测试的时候,我们就可以选择在初始化 ViewModel 实例时传入一个定制的 ServiceProvider 实例。

最后,这是更新 ViewModel 后的测试代码:

class ViewModelTests: XCTestCase {

    // 可以复用的逻辑可以抽取出来
    var viewModel: ViewModel!
    var mockServiceProvider: MockServiceProvider!

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
        mockServiceProvider = MockServiceProvider()
        viewModel = ViewModel(serviceProvider: mockServiceProvider)
    }

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

    func testLoginFailure() {
        mockServiceProvider.error = Service.ServiceError.wrongAccountOrPassword
        viewModel.login(account: "ficow", password: "1")

        // 登录失败时必须有错误
        XCTAssertEqual(viewModel.loginError, Service.ServiceError.wrongAccountOrPassword, "should login fail with wrongAccountOrPassword error")

    }

    func testLoginSuccess() {
        mockServiceProvider.result = UserInfo(id: 1, name: "Ficow Shen")
        viewModel.login(account: "ficow", password: "1234")

        // 不再需要异步测试

        XCTAssertEqual(viewModel.userInfo?.id, 1, "wrong user id")
        XCTAssertEqual(viewModel.userInfo?.name, "Ficow Shen", "wrong user name")
    }
}

class MockServiceProvider: ServiceProvider {

    var result: UserInfo?
    var error: Service.ServiceError?

    func login(account: String, password: String, completion: @escaping (Result<UserInfo, Service.ServiceError>) -> Void) {
        // ViewModel 的测试不在依赖于网络层
        if let result = result {
            completion(.success(result))
        } else if let error = error {
            completion(.failure(error))
        } else {
            fatalError("should set result or error for MockServiceProvider")
        }
    }
}

您是否注意到了以下这些变化:

  • 不再需要异步测试;
  • ViewModel 的测试不再依赖于网络层;
  • 不需要在每个测试方法中重复创建 ViewModel;

其实,这也是大家耳熟能详的 面向接口编程。ViewModel 需要的依赖不再由某个具体的类型提供而是可以由所有遵循了某个协议/接口的类型来提供。

由于提供依赖的类型必须去实现这个协议/接口,所以对于 ViewModel 来说这个依赖已经被反转到了提供依赖的类型上,这就是 依赖反转 一词的含义。

 

总结

 

我们需要关注代码的质量,而测试可以很好地帮助我们改善代码的质量。而且,在编写测试的时候,您就会逐步发现已有代码的不足,然后就会对它进行重构以改善代码的架构设计。

长此以往,项目的质量可以得到保障,项目的架构也会得到优化。这其实是一石N鸟的好方法,您怎么能错过呢?

说了这么多,希望 Ficow 这篇文章能够带给您些许启发。如果您对于本文相关的内容有任何想法,欢迎您给我留言~ 😸

 

参考内容:
Testing with Xcode
XCTest

 

觉得不错?点个赞呗~

本文链接:为什么需要写测试代码?如何用 XCTest 写单元测试代码?

转载声明:本站文章如无特别说明,皆为原创。转载请注明:Ficow Shen's Blog

评论区(期待你的留言)