在 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

诊断迁移后发生变化的弱引用检查

如果迁移后的所有权测试失败,请按以下顺序检查:

  1. 确认观察时机。 必须先释放预期对象,再进行弱引用断言。
  2. 检查 fixture 属性。 即使清除了另一个属性,仍有属性可能持有计算器。
  3. 检查局部别名和闭包捕获。 解包后的对象或强引用捕获可能会让对象一直存活到检查时。
  4. 检查辅助函数作用域。 辅助函数的延迟清理可能会在测试预期之前执行。
  5. 检查未完成的工作。 如果实际对象启动了异步工作,请先定义并等待其完成,再应用同步释放预期。

非 nil 的弱引用表示对象在观察时仍然存在。在更改断言或添加延迟之前,请先查找剩余的所有者。在这个合成示例中,没有需要等待的异步工作;诊断对照准确展示了额外所有者对结果的影响。

当计算和所有权断言表达出与基线相同的预期结果时,迁移就完成了:计算 12,移除 fixture 预期持有的所有权,并通过独立持有的弱引用验证对象已释放。测试演进时,应让这个顺序始终清晰可见。

资料来源