解决 Swift Testing 中 DI 容器的竞态条件

欢迎给个 star:RickeyBoy-Github

🎬 背景:单元测试不稳定

当项目逐渐扩大,Unit Test 越来越多的时候,必然会出现问题:某些单元测试有时能通过,有时又不行。最麻烦的是有些时候本地运行能通过,在 CI Pipeline 中又通过不了。

通常来讲本地设备和 CI 设备确实很不一样,一个常见的问题就是存在静态条件(Race Condition)的问题,本篇就尝试从根本上解决这类似的问题,并且从原理层面也讲清楚来龙去脉。

🔁 快速回顾:为什么 DI 很重要

如果是在 SwiftUI 上使用 MVVM 的架构,那 DI (Dependency Injection 依赖注入)对你来说肯定不陌生。这里简单回顾一下,为什么要用 DI

不可测试的 ViewModel

swift 复制代码
final class CheckoutViewModel {
    private let paymentService = PaymentService()  // 硬编码的依赖
​
    func placeOrder() async throws {
        try await paymentService.charge(amount: 99.99)
    }
}

这个 ViewModel 直接持有了它的依赖。想在测试中把 PaymentService 换成 mock?根本换不了。

可测试的 ViewModel

swift 复制代码
final class CheckoutViewModel {
    private let paymentService: PaymentServiceProtocol
​
    init(paymentService: PaymentServiceProtocol) {
        self.paymentService = paymentService
    }
    func placeOrder() async throws {
        try await paymentService.charge(amount: 99.99)
    }
}

现在我们可以在测试中注入 mock,在生产环境中使用真实服务了:

swift 复制代码
// 生产环境
let vm = CheckoutViewModel(paymentService: StripePaymentService())
// 测试
let vm = CheckoutViewModel(paymentService: MockPaymentService())

生产环境中,ViewModel 连接真实服务。测试中,由 mock 替代:

道理很简单,小项目用着也没问题。但随着 app 不断膨胀,事情就开始变味了。

💥 MVVM 的依赖问题

构造器注入复杂度爆炸

现实中的 ViewModel 不可能只有一个依赖,随着业务迭代它们只会越来越多:

swift 复制代码
init(
    paymentService: PaymentServiceProtocol,
    orderRepository: OrderRepositoryProtocol,
    analyticsTracker: AnalyticsTrackerProtocol,
    authService: AuthServiceProtocol,
    featureFlagService: FeatureFlagServiceProtocol
) { ... }

而且如果父级 ViewModel 需要创建子级 ViewModel,那父级就必须知道子级的全部依赖。往下嵌套三层之后再新增一个依赖,你就得一路往上改文件......

SwiftUI 的 @Environment 帮不上忙

SwiftUI 有一个内置的 DI 机制,@Environment

swift 复制代码
struct CheckoutView: View {
    @Environment(.paymentService) var paymentService  // 可以!
}

看起来挺好。但问题是 @Environment 只能在 View 的 body 里用,不适用于 ViewModel:

swift 复制代码
class CheckoutViewModel {
    @Environment(.paymentService) var paymentService  // 编译报错
}

所以我们需要一个像 @Environment 一样好用,但能在 View 层之外工作的方案。

🏭 Factory:适配 MVVM 的 DI 框架

Factory 是一个轻量级的 DI 框架,刚好解决了这个问题。ViewModel 通过 @Injected 主动从容器中拉取依赖:

swift 复制代码
final class CheckoutViewModel {
    @Injected(.paymentService) private var paymentService
    @Injected(.orderRepository) private var orderRepository

    func placeOrder() async throws {
        let order = try await orderRepository.createOrder()
        try await paymentService.charge(amount: order.total)
    }
}

不用传 init 参数,也不用维护依赖链。容器负责创建每个服务,@Injected 在运行时自动解析,ViewModel 压根不需要关心依赖从哪来。

Factory 就像个中间人,根据当前环境决定注入什么:

ViewModel 只管说"我要什么",Factory 负责"给你什么"。

Container:依赖注册中心

Container 是你定义每个依赖创建方式的地方:

swift 复制代码
extension Container {
    var paymentService: Factory<PaymentServiceProtocol> {
        self { StripePaymentService() }
    }
    var orderRepository: Factory<OrderRepositoryProtocol> {
        self { OrderRepository() }
    }
}

在生产环境中,@Injected(.paymentService) 解析为 StripePaymentService()。在测试中,你可以覆盖它:

swift 复制代码
Container.shared.paymentService.register { MockPaymentService() }

SharedContainer:按模块组织依赖

一个全局 Container 小项目够用。但在拥有几十个服务的模块化工程里,它很快就变成了大杂烩。Factory 提供了 SharedContainer,让你按模块拆分管理,就像是文件夹一样:

swift 复制代码
PaymentSharedContainer
├── paymentService
└── paymentGateway

OrderSharedContainer
├── orderRepository
└── orderValidator

AuthSharedContainer
├── authService
└── tokenStorage

每个模块管好自己的容器,边界清晰,各司其职:

swift 复制代码
public final class PaymentSharedContainer: SharedContainer {
    public static var shared = PaymentSharedContainer()
    public let manager = ContainerManager()

    public var paymentService: Factory<PaymentServiceProtocol> {
        self { StripePaymentService() }
    }
    public var paymentGateway: Factory<PaymentGatewayProtocol> {
        self { PaymentGateway() }
    }
}

在 ViewModel 中使用:

swift 复制代码
final class CheckoutViewModel {
    @Injected(\PaymentSharedContainer.paymentService)
    private var paymentService
}

到这里一切都很美好,直到你开始并行跑测试(尤其是 Swift Testing 默认就是并行模式)。

🐛 核心问题:并行测试与共享可变状态

好,现在终于说到重点了。

一个典型的测试配置

swift 复制代码
@Suite
struct CheckoutViewModelTests {
    let mockPayment: PaymentServiceProtocolSpy
    let sut: CheckoutViewModel

    init() {
        mockPayment = PaymentServiceProtocolSpy()
        PaymentSharedContainer.shared.paymentService.register { mockPayment }
        sut = CheckoutViewModel()
    }

    @Test func placeOrder_chargesCorrectAmount() async throws {
        try await sut.placeOrder()
        #expect(mockPayment.chargeCallCount == 1)
    }
}

看着没毛病,单独跑这个测试 Suite 的时候确实也没问题。

并行执行时发生了什么

Swift Testing 默认并发执行测试。这对 CI 速度来说是好事,但这意味着多个测试 Suite 同时执行,且共享同一个 PaymentSharedContainer.shared 实例:

Suite A 注册了 MockASuite B 在同一个容器上注册了 MockB。当 Suite A 解析依赖时,它可能拿到的是 MockB,反过来也一样。最终结果完全取决于线程调度顺序,而线程调度本身就是不确定的。换句话说,你的测试结果现在全凭运气。

更糟糕的是:Suite 内部的竞态

即使是同一个 Suite 内部的测试,也可能产生竞态。Swift Testing 并不保证 @Test 方法按顺序执行:

swift 复制代码
@Suite
struct OrderViewModelTests {
    let mockRepo: OrderRepositoryProtocolSpy
    let sut: OrderViewModel

    init() {
        mockRepo = OrderRepositoryProtocolSpy()
        OrderSharedContainer.shared.orderRepository.register { mockRepo }
        sut = OrderViewModel()
    }

    @Test func fetchOrders_success() async {
        mockRepo.fetchOrdersResult = [Order.sample]
        await sut.fetchOrders()
        #expect(sut.orders.count == 1)
    }

    @Test func fetchOrders_empty() async {
        mockRepo.fetchOrdersResult = []
        await sut.fetchOrders()
        #expect(sut.orders.isEmpty)
    }
}

两个测试配置的是同一个 mockRepo 实例。并发运行时,一个测试的配置会影响到另一个,本来期望拿到空列表的测试,结果却看到了一条数据,显然这样会影响最终的测试结果。

最终症状

  • 测试本地通过但 CI 上失败(不同机器,不同时序)
  • 重跑失败的流水线就能通过
  • 添加或删除不相关的测试导致其他地方失败
  • 稳定运行很长时间的测试突然变得不可靠

根本原因都是一样的:DI 容器中的共享可变状态,导致 Race Condition

🔐 解决方案:使用 @TaskLocal 实现容器隔离

思路很简单:给每个测试分配自己的独立容器副本。不共享,自然就不会有竞态。

理解 @TaskLocal

在讲方案之前,先简单科普一下 @TaskLocal。这是 Swift Concurrency 提供的一个属性,它能让每个 Task 持有自己的 copy,互相之间完全隔离:

swift 复制代码
enum Scope {
    @TaskLocal static var currentUser: String = "default"
}

// Task A 看到 "Alice"
Task {
    Scope.$currentUser.withValue("Alice") {
        print(Scope.currentUser)  // "Alice"
    }
}

// Task B 看到 "Bob",完全独立
Task {
    Scope.$currentUser.withValue("Bob") {
        print(Scope.currentUser)  // "Bob"
    }
}

每个 Task 有自己的 currentUser。无需加锁,没有竞态,没有共享状态。

将 @TaskLocal 应用到容器

聪明的你大概已经猜到了,把容器的 shared 属性标记为 @TaskLocal,每个测试任务就能拿到自己的容器了:

swift 复制代码
public final class PaymentSharedContainer: SharedContainer {
    @TaskLocal public static var shared = PaymentSharedContainer()
    // ^^^^^^^^^ 就是这行代码

    public let manager = ContainerManager()

    public var paymentService: Factory<PaymentServiceProtocol> {
        self { StripePaymentService() }
    }
}

就这么一行代码的事。当测试运行在一个绑定了新容器到 $sharedTask 里时,所有 @Injected 解析拿到的都是当前测试专属的容器,而不是全局那个。

Factory 的内置支持:Container Traits

当然,你也可以手动调用 $shared.withValue(...),但写起来实在太啰嗦了。Factory(通过 FactoryTesting)提供了 Container Traits 来帮你搞定这些样板代码,可以直接嵌入 Swift Testing 的 @Suite@Test 属性。

定义 Container Trait

为每个 SharedContainer 在测试支持模块中定义一个 trait:

swift 复制代码
import Testing
import FactoryTesting

// 辅助方法:配置测试默认值
private func configurePaymentDefaults(_ container: PaymentSharedContainer) {
    container.paymentService.register { PaymentServiceProtocolSpy() }
    container.paymentGateway.register { PaymentGatewayProtocolSpy() }
}

// Suite 级别的 trait:隔离整个 Suite
extension SuiteTrait where Self == ContainerTrait<PaymentSharedContainer> {
    static var paymentContainer: ContainerTrait<PaymentSharedContainer> {
        let container = PaymentSharedContainer()
        configurePaymentDefaults(container)
        return .init(shared: PaymentSharedContainer.$shared, container: container)
    }
}

// Test 级别的 trait:允许单个测试覆盖配置
extension TestTrait where Self == ContainerTrait<PaymentSharedContainer> {
    static func paymentContainer(
        _ configure: @escaping (PaymentSharedContainer) -> Void
    ) -> ContainerTrait<PaymentSharedContainer> {
        let container = PaymentSharedContainer()
        configurePaymentDefaults(container)
        configure(container)  // 应用测试专属的配置
        return .init(shared: PaymentSharedContainer.$shared, container: container)
    }
}

在测试中使用 Traits

Suite 级别的隔离。 Suite 中的每个测试都获得自己的容器:

swift 复制代码
@Suite(.paymentContainer, .orderContainer)
struct CheckoutViewModelTests {
    let mockPayment: PaymentServiceProtocolSpy
    let sut: CheckoutViewModel

    init() {
        // 在隔离的容器上注册测试专属的 mock
        let spy = PaymentServiceProtocolSpy()
        PaymentSharedContainer.shared.paymentService.register { spy }
        mockPayment = spy
        sut = CheckoutViewModel()
    }

    @Test func placeOrder_chargesOnce() async throws {
        try await sut.placeOrder()
        #expect(mockPayment.chargeCallCount == 1)
    }

    @Test func placeOrder_passesCorrectAmount() async throws {
        try await sut.placeOrder()
        #expect(mockPayment.lastChargedAmount == 99.99)
    }
}

这样两个测试就可以放心地并行跑了,各自都有独立的 PaymentSharedContainer 实例,互不干扰。

Test 级别的覆盖。 还可以针对某个测试单独定制配置:

swift 复制代码
@Suite(.paymentContainer)
struct DiscountTests {
    @Test(
        "高级用户享受 20% 折扣",
        .paymentContainer { $0.discountRate.register { 0.20 } }
    )
    func premiumDiscount() async {
        let vm = CheckoutViewModel()
        let price = vm.calculateFinalPrice(for: 100.0)
        #expect(price == 80.0)
    }

    @Test("普通用户没有折扣")
    func noDiscount() async {
        let vm = CheckoutViewModel()
        let price = vm.calculateFinalPrice(for: 100.0)
        #expect(price == 100.0)  // 使用 Suite 默认值(无折扣)
    }
}

整体运作方式

用上 Container Traits 之后,每个测试都拥有自己的独立容器,彼此之间完全隔离:

不再有共享状态,不再有竞态,单元测试从此稳定!

📋 完整实践清单

我们在每个模块中遵循的模式:

1. 为 SharedContainer 标记 @TaskLocal

swift 复制代码
public final class OrderSharedContainer: SharedContainer {
    @TaskLocal public static var shared = OrderSharedContainer()
    public let manager = ContainerManager()
    // ... 依赖定义
}

2. 在测试支持模块中创建 trait 辅助方法:

swift 复制代码
// OrderMocks/ContainerTraits.swift
private func configureOrderDefaults(_ container: OrderSharedContainer) {
    container.orderRepository.register { OrderRepositoryProtocolSpy() }
    container.orderValidator.register { OrderValidatorProtocolSpy() }
}

extension SuiteTrait where Self == ContainerTrait<OrderSharedContainer> {
    static var orderContainer: ContainerTrait<OrderSharedContainer> {
        let container = OrderSharedContainer()
        configureOrderDefaults(container)
        return .init(shared: OrderSharedContainer.$shared, container: container)
    }
}

3. 在测试Suite中应用 traits:

swift 复制代码
@Suite(.orderContainer, .paymentContainer)
struct OrderFlowTests {
    // 每个测试都获得隔离的容器,可安全并行执行
}

🧭 核心要点

  1. DI 的本质是为了可测试性
  2. Factory 填补了 MVVM + DI 的空白 ,解决了 SwiftUI @Environment 无法覆盖的场景
  3. 并行测试 + 共享容器(Shared Container) = 竞态条件(Race Condition)
  4. @TaskLocal 容器隔离从根本上解决了问题,为每个测试提供独立的容器实例
  5. Container Traits 让方案切实可行, 每个模块定义一次,在每个测试Suite中声明式地应用
相关推荐
2501_915918414 小时前
苹果App Store上架审核卡住原因分析与解决方案指南
android·ios·小程序·https·uni-app·iphone·webview
开心就好20255 小时前
不依赖 Mac 也能做 iOS 开发?跨设备开发流程
后端·ios
开心就好20256 小时前
Windows 上传 IPA 到 App Store 的步骤讲解
后端·ios
for_ever_love__7 小时前
Objective- C学习: 手动内存管理
c语言·学习·ios·objective-c
风启新尘12 小时前
ios巨魔越狱
支持向量机·ios·智能手机
Digitally12 小时前
没有充电器,如何给 iPhone 充电?
ios·iphone
bcbnb12 小时前
基于Mach-O文件的动态库与静态库归属方案及API扫描实践
后端·ios
2501_9159214313 小时前
VSCode 写 Swift 运行到 iPhone?快蝎 IDE 开发实战体验
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
spencer_tseng14 小时前
anti-screenshot (Android + iOS)
android·ios