第2章 XCTest基础:测试Bundle的创建与配置、测试类的生命周期、测试方法的命名规范

好,咱们正式开始动手写测试了。

这一章,我会带你走一遍 XCTest 的基础流程。说白了,就是搞清楚三件事:测试文件放哪测试类怎么跑起来测试方法怎么取名

这三件事搞明白了,后面写测试就是水到渠成的事。

2.1 测试Bundle的创建与配置

先说说测试 Bundle。你想想看,你的 App 代码是一个 Target,测试代码是另一个独立的 Target。这个独立的 Target,在 Xcode 里就叫「测试 Bundle」。

2.1.1 创建测试 Target

创建方式很简单。我一般用两种方法:

  • 新建项目时勾选:创建项目时,勾上「Include Tests」。Xcode 会自动生成 Unit Tests 和 UI Tests 两个 Target。
  • 手动添加:项目已经建好了?没问题。点 File → New → Target → iOS Unit Testing Bundle。起个名字,比如 YourAppTests

我个人习惯把测试 Target 的名字写成 模块名 + Tests。比如网络层叫 NetworkTests,工具类叫 UtilsTests。这样找起来一目了然。

小提示:一个项目可以有多个测试 Bundle。我见过有些团队把单元测试和集成测试分开,各建一个 Target。但初期阶段,一个就够了。

2.1.2 配置测试 Bundle

创建完之后,有几个配置项需要留意:

配置项 说明 建议值
Host Application 测试是否依赖 App 运行环境 单元测试一般不勾,UI 测试必须勾
Bundle Identifier 测试 Target 的唯一标识 通常自动生成,如 com.yourapp.tests
Info.plist 测试 Bundle 的配置文件 一般不需要手动修改

这里有个坑,我曾经踩过:单元测试如果勾选了 Host Application,测试速度会变慢。因为每次运行都要启动整个 App。所以,纯逻辑测试千万别勾。

注意:如果你的测试需要访问 App 的 UIApplicationUserDefaults,那就必须勾选 Host Application。否则会 crash。

2.2 测试类的生命周期

每个测试类都继承自 XCTestCase。它有一套固定的生命周期方法。理解这个,你才能控制好测试的「准备」和「清理」。

2.2.1 四个关键方法

class MyTests: XCTestCase {

    // 1. 类级别:所有测试开始前调用一次
    override class func setUp() {
        super.setUp()
        // 这里放一次性的初始化,比如加载数据库
    }

    // 2. 类级别:所有测试结束后调用一次
    override class func tearDown() {
        // 这里放一次性的清理
        super.tearDown()
    }

    // 3. 方法级别:每个测试方法开始前调用
    override func setUp() {
        super.setUp()
        // 这里放每个测试都需要的数据,比如创建 mock 对象
    }

    // 4. 方法级别:每个测试方法结束后调用
    override func tearDown() {
        // 这里放每个测试的清理工作,比如重置状态
        super.tearDown()
    }
}

嗯,这里要注意:setUptearDown 是成对出现的。每个测试方法都会走一遍「setUp → 测试方法 → tearDown」这个流程。

2.2.2 实际场景怎么用?

我举个例子。假设你要测试一个网络请求类:

class NetworkServiceTests: XCTestCase {

    var sut: NetworkService!
    var mockSession: URLSession!

    override func setUp() {
        super.setUp()
        // 每次测试前,创建一个全新的实例
        mockSession = MockURLSession()
        sut = NetworkService(session: mockSession)
    }

    override func tearDown() {
        // 每次测试后,清空引用
        sut = nil
        mockSession = nil
        super.tearDown()
    }

    func testFetchData_WithValidURL_ReturnsData() {
        // 这里直接用 setUp 里准备好的 sut
        let result = sut.fetchData(from: "https://example.com")
        XCTAssertNotNil(result)
    }
}

为什么要这么写?因为每个测试方法应该是独立的。如果测试 A 改了某个属性,测试 B 再用就会出问题。所以每次 setUp 都重新创建,每次 tearDown 都清理干净。

核心原则:测试之间不能有状态依赖。setUp 和 tearDown 就是帮你做到这一点的。

2.3 测试方法的命名规范

命名这件事,看起来小,但影响很大。我在项目中遇到过,测试方法名写得乱七八糟,跑失败了都不知道测的是啥。

2.3.1 推荐命名格式

我建议用这个格式:

func test[被测方法]_[场景]_[预期结果]()

举个例子:

  • testFetchData_WithValidURL_ReturnsData()
  • testFetchData_WithInvalidURL_ThrowsError()
  • testLogin_WithCorrectCredentials_ReturnsSuccess()

你看,名字本身就说明了「测什么、什么条件、什么结果」。跑测试的时候,只看名字就知道问题出在哪。

2.3.2 命名注意事项

规则 说明 示例
必须以 test 开头 XCTest 只识别 test 开头的方法 testSomething()
使用驼峰命名 单词首字母大写,其余小写 testFetchData_WithEmptyString()
描述要具体 不要用 test1、test2 这种 testSaveData_WhenDiskFull_ReturnsError()
不要包含逻辑 名字只描述,不写实现细节 避免 testIfArrayIsNotEmpty()
避坑指南:我曾经见过有人写 test() 这样的方法名。Xcode 不会报错,但测试根本不会执行。因为 XCTest 要求方法名必须以 test 开头,且不能有参数。

2.3.3 一个完整的测试类示例

把上面这些串起来,一个完整的测试类长这样:

import XCTest
@testable import MyApp

class CalculatorTests: XCTestCase {

    var calculator: Calculator!

    override func setUp() {
        super.setUp()
        calculator = Calculator()
    }

    override func tearDown() {
        calculator = nil
        super.tearDown()
    }

    func testAdd_TwoPositiveNumbers_ReturnsSum() {
        let result = calculator.add(3, 5)
        XCTAssertEqual(result, 8)
    }

    func testAdd_NegativeAndPositive_ReturnsCorrectResult() {
        let result = calculator.add(-2, 5)
        XCTAssertEqual(result, 3)
    }

    func testDivide_ByZero_ThrowsError() {
        XCTAssertThrowsError(try calculator.divide(10, 0))
    }
}

你看,每个方法都独立、清晰、可读。跑测试的时候,哪个方法失败,一眼就能定位。

2.4 本章小结

这一章的内容其实不多,但都是基本功:

  • 测试 Bundle:独立的 Target,注意 Host Application 的勾选
  • 生命周期:setUp 和 tearDown 成对使用,保证测试独立
  • 命名规范:test[方法]_[场景]_[预期],让名字自己说话

下一章,咱们会深入 XCTest 的断言体系。说白了,就是怎么用代码「验证」你的代码是对的。到时候见。