在 Mac 上迁移 XCTest 单元测试时,应保留所有权顺序:完成工作、释放预期的强引用,然后检查弱引用。对于同步清理,在测试本地使用 defer 可以明确体现这一顺序。
本指南重点介绍如何在 Xcode 中转换一个对所有权敏感的单元测试。示例会计算一个合成网格中的单元格数量,然后检查其计算器是否已释放。示例仅使用本地 Swift 对象和整数运算。
迁移必须保留哪些生命周期行为?
XCTest 提供由框架管理的执行顺序。测试方法结束后,它会按注册顺序的逆序运行已注册的清理代码块,然后运行实例清理方法。即使测试断言失败,已注册的清理代码仍会运行;但进程终止可能会阻止其运行。如果一个代码块释放对象,另一个代码块检查该对象的弱引用,那么这些细节就很重要。在测试中设置和清理状态
例如,检查 nil 的清理代码块必须在移除最后一个预期所有者的代码块之后执行。由于 XCTest 按注册顺序的逆序执行,因此先注册检查代码块,再注册释放代码块。颠倒注册顺序会改变测试观察到的结果。
Swift Testing 的迁移指南将 setup 映射为类或 actor 测试套件的初始化器,将 teardown 映射为析构器。这种映射适用于与测试套件生命周期相关的清理。所有权断言还需要做出额外决定:断言运行前,具体要移除哪个引用?将 XCTest 测试迁移过来
更改测试语法之前,请先参考这张决策表:
| 现有职责 | 迁移选择 | 原因 |
|---|---|---|
| 为每个实例测试构造新的 fixture 状态 | 存储属性初始化或 init() |
在测试运行前建立 fixture |
| 释放对象并立即检查弱引用 | 显式释放后跟随 #expect,通常放在本地 defer 中 |
清楚显示观察时机 |
| 执行与引用类型测试套件生命周期相关的同步清理 | 使用 deinit 的类或 actor 测试套件 |
遵循文档所述的生命周期映射 |
| 等待清理完成或报告清理错误 | 在异步函数中等待清理,或使用适当的自定义作用域 | 需要支持挂起和错误处理的执行路径 |
此表是根据文档所述的生命周期和语言规则整理出的迁移决策参考,并不意味着每个 XCTest 钩子在 Swift Testing 中都有可互换的方法。
为什么将弱引用检查移入 deinit 可能改变测试结果?
Swift 会在释放类实例之前立即调用其析构器。析构器仍可访问该实例的存储属性。因此,进入测试套件的 deinit 并不能证明其中某个属性持有的对象已经释放。析构过程
假设测试套件有一个强引用的 layout 属性。在测试套件的析构器执行期间,指向该布局对象的弱引用仍可能不是 nil。测试必须先移除这个强属性引用,再检查布局对象是否已释放。
还有另一种失败情况:引用循环可能会保留测试套件,导致其析构器无法运行。此时,仅放在析构器中的断言便没有机会报告这种保留问题。这符合 ARC 的所有权规则:强引用会让实例保持存活,而弱引用不会阻止对象释放,并会在被引用实例释放时变为 nil。自动引用计数
对于所有权测试,应将观察者放在要测试其释放情况的对象之外。为测试指定明确的释放操作,并通过测试的常规控制流执行断言。这样,测试的契约清晰可查,也不依赖测试套件自身的释放过程。
在 Xcode 中建立 XCTest 基线
在 Xcode 16 或更高版本中使用现有的单元测试 target。Apple 说明,XCTest 和 Swift Testing 可以共存于同一个 target 和 bundle 中,因此迁移不需要另建单元测试 bundle。若要添加测试文件,请选择 File > New > New File From Template,然后根据需要选择 XCTest Unit Test 或 Swift Testing Unit Test。向 Xcode 项目添加测试
以下是完整的 XCTest 测试文件。其计算器返回 3 * 4,预期结果为 12。这两个清理代码块的注册顺序特意展示了所需的执行顺序。
import XCTest
private final class GridCalculator {
func cellCount(columns: Int, rows: Int) -> Int {
columns * rows
}
}
final class GridCalculatorXCTests: XCTestCase {
private var calculator: GridCalculator?
override func setUp() {
super.setUp()
calculator = GridCalculator()
}
func testCellCountAndRelease() {
weak var observed = calculator
// Registered first, so this executes second.
addTeardownBlock { [weak observed] in
XCTAssertNil(observed)
}
// Registered second, so this executes first.
addTeardownBlock { [self] in
calculator = nil
}
XCTAssertEqual(
calculator?.cellCount(columns: 3, rows: 4),
12
)
}
}
释放代码块会保留测试用例,以便更新 fixture 属性。观察代码块会弱引用捕获计算器,避免检查代码本身增加一个强所有者。Apple 将清理代码块描述为与关联的 setup 一同注册、用于执行测试专属清理的地方。在测试中设置和清理状态
此基线的契约范围很明确:测试主体完成且 fixture 属性被清空后,计算器必须不再存在。它不测量内存用量,也不证明应用创建的每个对象都已释放。
将所有权顺序迁移到本地作用域中
对于这个同步场景,请使用 struct 测试套件,并将释放和验证放在同一个 defer 代码块中。当控制流离开其所属作用域时,Swift 会执行延迟代码,包括提前返回或抛出错误的情况。多个延迟代码块会按注册顺序的逆序运行;在这个示例中,使用单个代码块更容易看清先释放再检查的顺序。控制流
以下是完整、独立的 Swift Testing 文件。可以用它替换基线文件,也可以将示例放在不同文件中,使各自的私有辅助类型彼此分开。第二个测试是诊断对照:它展示了额外的强所有者会让弱引用保持非空,直到该所有者被清除。
import Testing
private final class GridCalculator {
func cellCount(columns: Int, rows: Int) -> Int {
columns * rows
}
}
private final class GridFixture {
var calculator: GridCalculator? = GridCalculator()
var additionalOwner: GridCalculator?
}
struct GridCalculatorTests {
@Test
func cellCountAndRelease() throws {
let fixture = GridFixture()
weak var observed = fixture.calculator
defer {
fixture.calculator = nil
#expect(observed == nil)
}
let result = try #require(
fixture.calculator?.cellCount(columns: 3, rows: 4)
)
#expect(result == 12)
}
@Test
func weakProbeDetectsAnAdditionalOwner() {
let fixture = GridFixture()
weak var observed = fixture.calculator
fixture.additionalOwner = fixture.calculator
fixture.calculator = nil
#expect(observed != nil)
fixture.additionalOwner = nil
#expect(observed == nil)
}
}
在第一个测试中,fixture 持有计算器,而 observed 观察它但不拥有它。计算结果是一个可选整数,#require 会将其解包。没有将解包后的计算器存入另一个局部变量。退出作用域时,延迟代码块会清除 fixture 的引用并检查观察者。
#expect 失败时会记录问题并继续执行。#require 失败时会抛出错误,从而停止当前执行路径。由于 defer 在 require 之前就已注册,因此即使 require 抛出错误,清理和弱引用检查仍会执行。期望与确认、错误处理
诊断对照明确了两种不同的预期状态:清除两个所有者中的一个后,观察者仍不是 nil;清除剩余所有者后,观察者变为 nil。这些是用于说明的所有权检查,并非测得的应用结果。研究环境中没有可用的 Swift 可执行文件,因此这些 Swift 示例经过检查,但未能编译。
检查辅助函数边界、并发和异步清理
本地 defer 属于其外围作用域。若将延迟代码块移到工厂辅助函数中,它会在该辅助函数返回时执行,可能早于测试使用返回对象的时机。请将代码块保留在测试中,或设计一个能在辅助函数作用域内执行整个操作的辅助函数。外围作用域是所有权契约的一部分。控制流
还要检查 fixture 的位置。Swift Testing 会为每个实例测试方法创建单独的测试套件实例,并且默认并行运行同一测试套件中的测试。实例局部或测试局部状态适用于这种模式;全局 fixture 或静态集合则会引入跨测试共享的所有权。使用测试套件类型组织测试函数
对于需要主 actor 的 Mac 应用代码,请用 @MainActor 显式隔离 Swift Testing 测试。迁移指南指出,Swift Testing 不会像 XCTest 那样,默认将同步测试方法放在主 actor 上。上面的合成计算器没有此要求。将 XCTest 测试迁移过来
同步延迟代码块不能替代等待式清理。Swift 的延迟操作无法将错误抛出代码块之外。如果清理可能失败,请明确处理并报告该失败,而不是将其丢弃。应将等待式清理放在异步执行路径中,并确保正常执行和抛错的主体路径都能到达该清理操作。错误处理
对于需要由框架管理的可复用 setup 和 teardown,当前的 Swift Testing 提供了 TestScoping,可在测试或测试套件周围提供自定义执行作用域。Apple 列出的可用版本包括 Swift 6.1 和 Xcode 16.3。对于多个测试需要共用作用域的情况,这是受支持的方案,但它需要自定义 trait 和作用域提供程序,而不只是重命名 addTeardownBlock。TestScoping
诊断迁移后发生变化的弱引用检查
如果迁移后的所有权测试失败,请按以下顺序检查:
- 确认观察时机。 必须先释放预期对象,再进行弱引用断言。
- 检查 fixture 属性。 即使清除了另一个属性,仍有属性可能持有计算器。
- 检查局部别名和闭包捕获。 解包后的对象或强引用捕获可能会让对象一直存活到检查时。
- 检查辅助函数作用域。 辅助函数的延迟清理可能会在测试预期之前执行。
- 检查未完成的工作。 如果实际对象启动了异步工作,请先定义并等待其完成,再应用同步释放预期。
非 nil 的弱引用表示对象在观察时仍然存在。在更改断言或添加延迟之前,请先查找剩余的所有者。在这个合成示例中,没有需要等待的异步工作;诊断对照准确展示了额外所有者对结果的影响。
当计算和所有权断言表达出与基线相同的预期结果时,迁移就完成了:计算 12,移除 fixture 预期持有的所有权,并通过独立持有的弱引用验证对象已释放。测试演进时,应让这个顺序始终清晰可见。
资料来源
在测试中设置和清理状态 — Apple 开发者文档。
将 XCTest 测试迁移过来 — Swift 项目。
析构过程 — Swift 项目,《Swift 编程语言》。
自动引用计数 — Swift 项目,《Swift 编程语言》。
向 Xcode 项目添加测试 — Apple 开发者文档。
控制流 — Swift 项目,《Swift 编程语言》。
期望与确认 — Swift 项目。
错误处理 — Swift 项目,《Swift 编程语言》。
使用测试套件类型组织测试函数 — Swift 项目。
TestScoping — Apple 开发者文档。