要在 Mac 上重放随机 Swift 测试输入,请为每个测试用例向一个新生成器传入显式种子。保持生成器、输入映射和抽取顺序固定,并在每次失败时记录种子。

本指南将构建一个小型本地 Swift 包,用于检查一个合成算术计算。它提供完整的 Swift Testing 示例和一个 XCTest 替代方案。所有测试数据都来自本地计算;此包没有外部依赖。

种子究竟能让什么变得可重复?

变化来源有两个:测试生成的值,以及测试运行器安排测试的顺序。只有当每个生成值都来自相应生成器时,应用程序定义的种子才能控制前者。Apple 文档指出,普通随机操作默认使用自动设种子的 SystemRandomNumberGenerator。其文档所述的初始化器不接受种子。SystemRandomNumberGenerator

Xcode 还提供随机执行顺序,以便发现测试之间的依赖关系。其测试计划文档介绍了按字母顺序或随机顺序执行,但没有说明该设置支持用户提供的重放种子。因此,此处采用的受支持方式是在相互独立的测试中使用显式设种子的输入生成器。这并不保证重现运行器的调度顺序。Improving code assessment by organizing tests into test plans

在更改不稳定的测试之前,请参考以下判断表:

每次运行之间有什么变化? 应控制什么
生成的数值输入 种子、生成器实现、输入映射和抽取顺序
另一个测试运行后的结果 可变状态和每个测试的初始化
仅在并发执行期间出现的结果 共享可变资源和重叠任务
工具链更新后的输入 输入映射实现和记录下来的具体输入

Swift 明确支持通过 RandomNumberGenerator 使用自定义生成器;它要求实现一个可变的 next() 操作并返回 UInt64。Swift 项目的源代码文档指出,测试需要可重复的序列,因此可以提供可设种子的生成器。Random.swift

创建一个小型本地测试包

使用装有 Xcode 且具备 Swift 6 或更高版本工具链的 Mac。在终端中检查已选中的命令行工具链,然后创建目录并初始化一个库包:

swift --version
mkdir SeedReplay
cd SeedReplay
swift package init --type library

Swift 项目的集成测试通过 swift package init --type library 来验证库包的创建。Swift 6 直接包含 Swift Testing,因此此示例无需为 import Testing 添加包依赖。Swift Package Manager 会运行其中包含的 Swift Testing 测试和 XCTest 测试。Swift package initialization integration test、Announcing Swift 6

保留生成的包清单和库源文件。将生成的示例测试文件替换为下方的测试文件,并全部放在 Tests/SeedReplayTests 中。为使演示自成一体,算术函数放在测试支持文件中。在现有项目中,请改用实际的被测函数。

定义种子和输入生成约定

创建 Tests/SeedReplayTests/ReplaySupport.swift,并写入以下完整内容:

import Foundation

// Local synthetic test-data generator.
// Mutable value state: give each test case its own instance.
// Its output sequence is part of this example's replay contract.
struct ReplayGenerator: RandomNumberGenerator {
    private var state: UInt64

    init(seed: UInt64) {
        state = seed
    }

    mutating func next() -> UInt64 {
        state &+= 0x9E3779B97F4A7C15
        var value = state
        value = (value ^ (value >> 30)) &* 0xBF58476D1CE4E5B9
        value = (value ^ (value >> 27)) &* 0x94D049BB133111EB
        return value ^ (value >> 31)
    }
}

enum ReplayConfigurationError: Error {
    case invalidSeed(String)
}

func replaySeeds() throws -> [UInt64] {
    guard let text = ProcessInfo.processInfo.environment["TEST_SEED"] else {
        return [0, 42, 2026]
    }

    guard !text.isEmpty,
          text.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }),
          let seed = UInt64(text) else {
        throw ReplayConfigurationError.invalidSeed(text)
    }

    return [seed]
}

func generatedInputs(seed: UInt64) -> [Int] {
    var generator = ReplayGenerator(seed: seed)
    return (0..<256).map { _ in
        // Exactly one draw per input; retain the upper ten bits.
        Int(generator.next() >> 54)
    }
}

func triangularNumber(_ n: Int) -> Int {
    precondition((0...1023).contains(n))
    return n * (n + 1) / 2
}

func referenceTotal(_ n: Int) -> Int {
    precondition((0...1023).contains(n))
    return (0...n).reduce(0, +)
}

此配置接受十进制 UInt64,包括零。未设置 TEST_SEED 时会选择三个显式种子;若值为空、为负数、不是数字或超出范围,则会抛出错误。因此,种子输入错误不会悄悄选用另一组运行结果。

生成器使用显式的环绕算术。每个生成的输入取一个输出的高十位,因此取值范围为 0 到 1023。此映射在源代码中有意保持固定。算术计算的规模很小:最大预期结果为 523776。这是一种有界的合成输入方案,并不声称具有统计测试覆盖率。

为什么这里要避免使用 Int.random(in:using:)?该 API 支持传入生成器,在固定工具链下重放时很有用。不过,Apple 明确警告,即使生成器产生相同的底层序列,其值生成算法也可能随 Swift 版本变化。自行实现这个示例的小型映射,可以去除这种特定依赖。Int.random(in:using:)

运行并重放 Swift Testing 版本

创建 Tests/SeedReplayTests/SeedReplayTests.swift:

import Testing

@Test(arguments: try replaySeeds())
func triangularTotals(seed: UInt64) {
    print("triangularTotals seed=\(seed) recipe=upper10-v1 count=256")

    for (index, n) in generatedInputs(seed: seed).enumerated() {
        let actual = triangularNumber(n)
        let expected = referenceTotal(n)

        #expect(
            actual == expected,
            "seed=\(seed) case=\(index) n=\(n) actual=\(actual) expected=\(expected)"
        )

        if actual != expected {
            return
        }
    }
}

@Test
func replayRecipeIsStable() {
    var generator = ReplayGenerator(seed: 0)
    let firstWord = generator.next()
    #expect(firstWord == 0xE220A8397B1DCDAF)

    let firstInputs = Array(generatedInputs(seed: 0).prefix(3))
    #expect(firstInputs == [904, 441, 27])
}

参数化测试会将每个种子作为单独的测试用例。Apple 文档说明,arguments: 会将集合元素传给测试调用,并允许使用会抛出错误的表达式。这样,种子选择就会清晰地显示在测试模型中。显式循环索引则可以标识该种子下的单个生成输入。Implementing parameterized tests

先运行默认种子集合,再用同一个种子重放两次:

swift test
TEST_SEED=42 swift test
TEST_SEED=42 swift test

在源代码不变的情况下,这两次设种子运行应生成相同的数值输入。对于种子 42,前三个输入为 759、163 和 285。该测试会为此种子检查 256 个输入。单独的稳定性测试始终使用种子零:无论所选的算术测试种子是什么,它都会检查生成器和映射。

对于所提供的实现,预期结果是所有断言都通过。研究环境中没有 Swift,因此我们检查了 Swift 文件,但没有编译它们。另行使用 Python 计算,核对了三个默认种子的 768 次算术比较以及所示的初始值;这项检查不能替代编译 Swift 包。

尝试重放一个故意引入的计算错误

要查看诊断流程,请临时将 triangularNumber(_:) 中的返回表达式替换为以下带标签的代码片段:

// Temporary replacement inside triangularNumber(_:).
return n * n / 2

再次运行 TEST_SEED=42 swift test。第一个生成的输入是 759。修改后的表达式得出 288040;参考总和是 288420。断言消息会指出种子 42、用例索引 0 和输入 759。提前返回会在记录第一个不匹配项后停止该次调用,使诊断聚焦于该问题。

恢复 return n * (n + 1) / 2,并重新运行相同命令。修复期间请保持种子不变。生成的输入一旦暴露出有价值的缺陷,也请将该具体输入保留在一个具名回归测试中;即使之后更改生成方案,这个输入仍然容易理解。

将相同的生命周期用于 XCTest

对于基于 XCTest 的目标,保留 ReplaySupport.swift,并使用以下完整测试文件替代 Swift Testing 文件。将其命名为 Tests/SeedReplayTests/SeedReplayXCTests.swift:

import XCTest

final class SeedReplayXCTests: XCTestCase {
    func testTriangularTotals() throws {
        for seed in try replaySeeds() {
            print("testTriangularTotals seed=\(seed) recipe=upper10-v1 count=256")

            for (index, n) in generatedInputs(seed: seed).enumerated() {
                let actual = triangularNumber(n)
                let expected = referenceTotal(n)

                XCTAssertEqual(
                    actual,
                    expected,
                    "seed=\(seed) case=\(index) n=\(n)"
                )

                if actual != expected {
                    return
                }
            }
        }
    }

    func testReplayRecipeIsStable() {
        var generator = ReplayGenerator(seed: 0)
        XCTAssertEqual(generator.next(), 0xE220A8397B1DCDAF)
        XCTAssertEqual(
            Array(generatedInputs(seed: 0).prefix(3)),
            [904, 441, 27]
        )
    }
}

终端命令相同。每次调用 generatedInputs(seed:) 都会构造一个新的生成器,因此没有测试会消耗另一个测试的随机数流。测试结束后也无需清理生成器状态。

如果现有 XCTest 测试装置将生成器存储为属性,请在每个测试方法运行前重置它。Apple 文档说明了用于每个测试初始化的实例设置方法,以及用于类中共享状态的类设置方法。如果测试必须独立重放,可变随机数流应在每个测试的初始化过程中设置。Set Up and Tear Down State in Your Tests

将 Xcode 调度与输入重放分开处理

要从 Xcode 运行此包,请打开其中的 Package.swift。对于使用测试计划的项目,选择 Product > Scheme > Edit Test Plan,选中相关配置,然后在 Environment Variables 下添加 TEST_SEED,并将其十进制值设为 42。使用 Product > Test 运行,并在测试输出中确认打印出的种子。Apple 文档介绍了测试计划的环境配置,以及运行测试前如何选择活动计划。Improving code assessment by organizing tests into test plans

在终端和 Xcode 之间切换时,请确认打印出的种子。赋给单条终端命令的变量不会成为 Xcode 测试计划设置。日志行会告诉你测试进程实际读取了哪个配置。

Swift Testing 默认会并行运行测试。其 .serialized 特征可以串行执行参数化测试中的用例,或测试套件中的测试,但不会控制无关测试。Apple 还记录了用于全局禁用并行化的 swift test --no-parallel。这两种控制方式都不会提供输入种子。此示例无需串行化,因为每个用例都拥有自己的生成器和数值输入。Running tests serially or in parallel

如果某个测试单独运行时通过,但在其他测试同时运行时失败,即使生成的输入完全相同,也请检查共享状态。串行执行有助于调查重叠任务,但种子无法重置其他测试的可变对象,也无法重现整个并发调度过程。

种子导致测试失败时应记录什么?

保留足够的信息,以便重建输入序列:

  • 测试名称和十进制种子。
  • 生成器和映射版本,本例中为 upper10-v1。
  • 断言消息中的用例索引和具体输入。
  • 源代码修订版本、Swift 版本和所选的 Xcode 版本。
  • 输入数量,以及随机数抽取方式的任何变化。

在此示例中,如果在循环之前增加一次生成器抽取,之后的每个输入都会偏移。改变移位量会改变映射。增加输入数量只有在生成器和映射保持不变时,才会保留现有的序列前缀。这些都是代码的重放约定所带来的结果。

为日常运行保留一小组固定种子,探索时尝试其他显式种子,并在具体输入发现缺陷时保存为回归用例。有效的重放始于相同的值和已知的初始状态;只有当测试控制了种子的使用方式,种子才会可靠。

来源