要让 XCTest 用例单独运行和作为测试套件运行时都能通过,请为每个测试提供独立的可变夹具,并将其注入被测代码。当单例必须保持共享时,请在每次测试前重置它,并在整个测试生命周期内防止并发访问。

本指南围绕一项 Mac 开发任务展开:修复在 Xcode 中单独运行时通过、一起运行时失败的布局计算测试。示例只使用合成的整数宽度和本地算术。不需要应用窗口、服务模拟对象、文件存储或网络连接。

为什么测试单独运行时会通过,在测试套件中却会失败?

单独运行测试时,单例可能处于初始状态。在测试套件中,较早运行的测试可能已经修改了它。即使创建了对单例的新引用,指向的仍是同一个可变对象。

Swift 区分实例属性和类型属性:每个实例都有自己的实例属性,而类型属性只有一个共享副本。存储型类型属性会在首次访问时初始化。因此,将 SpacingCalculator.shared 赋给测试属性并不会创建一个新的计算器。参见 Swift 项目的属性文档。

假设有一个由宽度为 [10, 20, 30] 的项目组成的合成行。它的总宽度是各项目宽度之和,加上项目之间的间距。默认间距为 2 时,结果为 64;间距为 8 时,结果为 76。

假设一个测试在未配置计算器的情况下检查默认宽度,而另一个测试将共享间距改为 8。下表描述了顺序执行的结果,每行都从一个新建且间距为 2 的计算器开始。这些只是说明性计算,并非记录的 Xcode 测试结果。

执行顺序 未重置的共享计算器 每个测试使用新计算器
仅运行默认值测试 64:通过 64:通过
仅运行宽间距测试 76:通过 76:通过
先默认值,再宽间距 两者都通过 两者都通过
先宽间距,再默认值 默认值测试得到 76:失败 两者都通过

这提供了一个实用的诊断方法:检查哪个测试修改了失败断言所使用的状态。通过更改测试名称来获得理想的执行顺序,只会让这种依赖继续存在。

应由哪个 XCTest 生命周期方法管理夹具?

对于每个测试都需要的可变状态,请使用实例级 setup 方法。Apple 文档说明,实例级 setup 会在每个测试方法之前运行,而类级 setup 在测试类运行前只运行一次。因此,在类级 setup 中创建的可变夹具无法在各个方法之间恢复为全新的基准状态。参见在测试中设置和清理状态。

根据具体工作选择 setup 方法:同步且不会抛错的夹具构造适合 setUp();可能抛错的构造适合 setUpWithError();异步准备适合 setUp() async throws。使用 teardown 完成当前测试的清理。Apple 明确建议不要依赖 teardown 为后续测试做准备,因为崩溃可能导致 teardown 无法运行。同一份生命周期文档介绍了这些边界。

夹具的选择取决于所有权和执行范围:

情况 合适的做法 需要检查的条件
测试可以构造依赖项 为每个测试创建并注入一个实例 消费方必须使用注入的实例
测试必须使用实际单例 在每个测试前建立完整的基准状态 没有任何重叠运行的测试会修改该单例
共享配置不可变 重用该不可变值 被引用的对象也必须保持不变
测试启动异步工作 在测试结束前等待工作完成 测试结束后不得再有未完成的工作修改夹具

对于这个计算器,第一种做法最简单。生产代码可以保留共享默认实例,而测试可以显式选择彼此独立的实例。

构建由各 XCTest 测试独立持有的夹具

使用安装了 Xcode 的 Mac 和现有的 macOS 单元测试目标。将以下完整示例作为一个 Swift 文件添加到该测试目标中。它定义了自己的计算器、消费方和测试,因此不需要导入应用模块。使用 Swift 6 工具链,并将目标的默认 actor 隔离设为 nonisolated。

计算器是一个 actor,这样可以明确标示其可变状态的并发边界。Swift actor 会限制对其可变状态的访问,而从 actor 外部发出的调用会使用 await。Swift 项目的并发章节对此作了说明。即使使用 actor,各夹具仍须由各自独立的测试持有。

import XCTest

actor SpacingCalculator {
    static let shared = SpacingCalculator()

    private var gap = 2

    func setGap(_ value: Int) {
        precondition(value >= 0)
        gap = value
    }

    func width(of itemWidths: [Int]) -> Int {
        precondition(itemWidths.allSatisfy { $0 >= 0 })
        let gapCount = max(itemWidths.count - 1, 0)
        return itemWidths.reduce(0, +) + gapCount * gap
    }
}

struct RowLayout {
    let calculator: SpacingCalculator

    init(calculator: SpacingCalculator = .shared) {
        self.calculator = calculator
    }

    func width(of itemWidths: [Int]) async -> Int {
        await calculator.width(of: itemWidths)
    }
}

final class RowLayoutTests: XCTestCase {
    private var calculator: SpacingCalculator?

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

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

    func testDefaultGap() async throws {
        let fixture = try XCTUnwrap(calculator)
        let layout = RowLayout(calculator: fixture)

        let actual = await layout.width(of: [10, 20, 30])

        XCTAssertEqual(actual, 64)
    }

    func testWideGap() async throws {
        let fixture = try XCTUnwrap(calculator)
        await fixture.setGap(8)
        let layout = RowLayout(calculator: fixture)

        let actual = await layout.width(of: [10, 20, 30])

        XCTAssertEqual(actual, 76)
    }

    func testEmptyRow() async throws {
        let fixture = try XCTUnwrap(calculator)
        let layout = RowLayout(calculator: fixture)

        let actual = await layout.width(of: [])

        XCTAssertEqual(actual, 0)
    }

    func testSingleItem() async throws {
        let fixture = try XCTUnwrap(calculator)
        let layout = RowLayout(calculator: fixture)

        let actual = await layout.width(of: [10])

        XCTAssertEqual(actual, 10)
    }
}

关键在于这一行:calculator = SpacingCalculator()。每次 setup 都会创建一个间距为 2 的独立 actor。随后,每个测试都会将该 actor 传给 RowLayout。在这些测试中调用 RowLayout() 会选择 .shared,绕过夹具。

此示例特意允许直接构造。如果现有单例的初始化器是私有的,可以引入内部构造入口,或者将其可变计算状态提取到一个可单独构造的依赖项中。可以通过应用模块既有的可测试导入机制向测试目标开放内部初始化器;无需让单例引用可以被全局替换。

这些算术还定义了两个实用的边界情况:空行的宽度为 0,而单个项目之间没有间距。此示例接受非负且足够小、不致发生整数溢出的整数;负值违反其前置条件,会终止执行,而不是导致断言失败。

预期算术结果以及两个测试的先后顺序组合,已使用确定性的 Python 计算在本地核对。Swift 示例经过检查,但由于此研究环境没有 Swift 工具链,因此未编译或执行。请在 Xcode 中运行,以验证它们是否适用于你的目标并能正常编译和执行。

什么时候重置单例就足够了?

当测试确实需要共享实例,且其所有对测试可见的状态都能恢复时,重置可以作为过渡性方案。请在每次测试前建立基准状态,包括测试假定为默认值的那些值。若只重置当前失败测试所修改的属性,可能会遗漏其他依赖项。

对于这个计算器,可以在异步 setup 中选择 SpacingCalculator.shared,并在每个方法之前等待 setGap(2)。只有在断言执行前没有其他并发调用者更改间距时,这才能建立算术基准状态。如果之后夹具增加了缓存结果或其他配置,基准状态重置操作也必须涵盖这些值。

actor 并不会让某个测试在多次 await 调用期间始终拥有该对象。例如,测试 A 可以将共享间距设为 2,测试 B 可以将其设为 8,然后测试 A 再使用 8 计算宽度。每次 actor 操作都可能正确执行,但整体测试假设仍会失败。这符合 Swift 文档所述的actor 执行和挂起规则。

在选择基于重置的隔离方式之前,请找出所有可能访问共享对象的测试和后台任务。如果无法确保对象在准备、执行和清理期间都由当前测试独占,请改为注入独立状态。在测试结束后恢复快照,无法阻止其他调用者在测试期间观察到临时更改。

如何将这种夹具模式用于 Swift Testing?

Swift Testing 会为每个实例测试函数初始化一个独立的套件实例。因此,构造计算器的实例属性无需覆盖 XCTest setup 方法,也能实现相同的所有权模式。若属性从 .shared 初始化,它仍会引用单例。Apple 在使用套件类型组织测试函数中介绍了实例生命周期。

在同一测试目标中只需保留前文定义的 SpacingCalculator 和 RowLayout,下面就是一个完整的额外 Swift Testing 套件。它需要包含 Testing 模块的工具链,例如 Xcode 16 或更高版本。其默认 actor 隔离也应设为 nonisolated。

import Testing

struct RowLayoutSwiftTests {
    let calculator = SpacingCalculator()

    @Test func defaultGap() async {
        let layout = RowLayout(calculator: calculator)
        let actual = await layout.width(of: [10, 20, 30])
        #expect(actual == 64)
    }

    @Test func wideGap() async {
        await calculator.setGap(8)
        let layout = RowLayout(calculator: calculator)
        let actual = await layout.width(of: [10, 20, 30])
        #expect(actual == 76)
    }
}

对于更复杂的准备工作,Swift Testing 支持套件初始化器,包括异步或可能抛错的初始化器。Apple 的将 XCTest 测试迁移过来说明了如何用这些初始化器替代 XCTest setup 方法。此夹具只拥有本地计算状态,且不会启动后台工作,因此无需显式清理。

Swift Testing 默认会并行运行测试。对套件应用 .serialized 会使其中的测试和嵌套套件串行运行,但无法阻止无关测试与其同时运行。将此特征应用于普通的非参数化测试函数不会产生效果。串行或并行运行测试明确说明了这些限制。

因此,在两个依赖单例的套件上分别添加 .serialized,并不能确保它们彼此互斥。只要所有存在竞争的使用者都属于同一范围,一个共同的串行父套件就可以协调其子套件。对于这些计算测试,使用新建的独立夹具即可避免这种协调需求。

在 Xcode 中验证单独运行和套件运行的结果

在整个验证过程中使用相同的方案、测试计划和 Mac 目标设备。Apple 在运行测试并解读结果中介绍了如何运行单个方法、套件和当前测试计划。

  1. 使用编辑器行号栏中的播放控件,逐一运行四个 XCTest 方法。它们应分别得到 64、76、0 和 10。
  2. 使用 RowLayoutTests 类声明旁的播放控件运行整个类。四个方法应全部通过。
  3. 选择 Product > Test 运行当前测试计划。确认这些测试确实运行了,而非被跳过或排除。
  4. 如果添加了 Swift Testing 示例,也要分别运行其中两个函数并一起运行。

如果单独运行时测试通过,但完整测试计划失败,请追踪失败消费方使用的对象。检查是否有遗漏的依赖注入、调用 .shared 的辅助函数,或在启动它的测试结束后仍继续运行的异步工作。如果测试单独运行时就失败,请先检查其输入和预期计算,再判断是否与测试套件的执行顺序有关。

最终的夹具应在当前测试的构造路径中明确展示其初始状态。对于这些布局计算,每个测试都拥有一个计算器,明确将其提供给消费方,并等待计算完成。这样,预期结果就不再取决于相邻测试的运行顺序。

来源