首页
/ ECC 实战指南:基于 Swift Protocol 的依赖注入测试模式,让文件系统与网络 I/O 彻底可 Mock

ECC 实战指南:基于 Swift Protocol 的依赖注入测试模式,让文件系统与网络 I/O 彻底可 Mock

2026-09-06 18:31:27作者:温玫谨Lighthearted

本篇指南源自 ECC(Agent Harness Performance Optimization System)仓库中面向 Swift 开发者的 skill 文档 swift-protocol-di-testing。它系统讲解了如何把文件系统、网络与 iCloud 这类"外部副作用"抽象到小而专注的协议之后,借助默认参数注入Swift Testing 框架写出完全不触碰真实 I/O 的确定性测试。读完本文,你将掌握"定义协议 → 实现生产默认 → 编写可配置 Mock → 注入被测类型 → 断言错误路径"的完整五步套路,并能直接套用到任何需要测试错误处理、跨环境(App / 测试 / SwiftUI Preview)运行的 Swift 并发代码中。

为什么 Swift 代码需要 Protocol 级别的依赖注入

凡是读写磁盘、发起网络请求或访问 iCloud 容器(Ubiquity Container)的 Swift 代码,天然存在三个测试难题:

  1. 不可确定性——测试结果取决于文件是否真实存在、网络是否连通;
  2. 错误路径难以触发——真实环境里"容器不可用""文件损坏""写入失败"很难被稳定复现;
  3. 环境割裂——同一段逻辑必须在 App 沙盒、单元测试进程与 SwiftUI Preview 三种运行环境里行为一致。

Protocol 依赖注入(Protocol-Based Dependency Injection)正是为解决上述问题而生:把 FileManagerData(contentsOf:)、URLSession 等"直接敲在方法体里的系统调用"全部收敛到协议背后,被测类型只依赖协议抽象,测试时向协议注入"想让它怎么表现就怎么表现"的 Mock。

这一模式在 ECC 仓库的 Swift 规则体系中被列为显式引用项:Swift 测试规则 明确写明"See skill: swift-protocol-di-testing for protocol-based dependency injection and mock patterns with Swift Testing";Swift 模式规则 的 Dependency Injection 小节同样引用本 skill。换句话说,这是 ECC 对 Swift 工程"可测试架构"的官方推荐打法,并且它已随 install-modules.json 一起被打包进 ECC 的 Swift 模块(stack 映射见 project-stack-mappings.json),在引入 swift build / swift test 命令时即可用。

When to Activate:何时启用这套模式

在以下四种场景中,应当主动套用本 skill 的模式:

  • 正在编写访问文件系统、网络或外部 API 的 Swift 代码;
  • 需要在不触发真实故障的前提下测试错误处理路径(例如容器缺失、数据损坏、写入失败);
  • 正在构建需要跨环境工作的模块(App 运行、单元测试、SwiftUI Preview 三者共用同一套业务代码);
  • 正在基于 Swift 并发(actors、结构化并发)设计可测试架构,需要正确传递 Sendable

反过来,如果某类型完全不接触外部依赖(纯计算、纯数据转换),则不需要为其引入协议——这正是下文中 "Anti-Patterns" 要杜绝的过度设计。

Core Pattern:五步核心套路

本 skill 的骨架是编号 1 到 5 的递进式模式。下面逐步展开,并在每一步给出可直接复制运行的 Swift 代码。

第 1 步:定义小而专注的协议

核心原则是每个协议只负责一个外部关注点。不要把"文件系统查找、文件读写、安全书签"塞进同一个接口——那会变成难以实现、难以 Mock 的"上帝协议"(god protocol)。协议应明确继承 Sendable,以便日后跨 actor 边界传递:

// 文件系统访问(定位容器位置)
public protocol FileSystemProviding: Sendable {
    func containerURL(for purpose: Purpose) -> URL?
}

// 文件读 / 写操作
public protocol FileAccessorProviding: Sendable {
    func read(from url: URL) throws -> Data
    func write(_ data: Data, to url: URL) throws
    func fileExists(at url: URL) -> Bool
}

// 书签存储(例如沙盒化 App 的 security-scoped bookmark)
public protocol BookmarkStorageProviding: Sendable {
    func saveBookmark(_ data: Data, for key: String) throws
    func loadBookmark(for key: String) throws -> Data?
}

上述三个协议的职责边界一目了然:定位容器、读写数据、持久化书签各自独立,任何一个都可以被单独替换或单独 Mock。Purpose 可以是业务自行定义的枚举(如下文的 .sync),用于区分"同步容器"与"备份容器"等不同用途。

从 ECC Swift 模式规则 的 Protocol-Oriented Design 一节还能看到同类风格被推广到领域层:定义带 associatedtypeRepository 协议,并要求关联类型同时满足 Identifiable & Sendable。这意味着"小协议 + Sendable"不是测试专用技巧,而是贯穿数据层、服务层的一致架构主张。

第 2 步:编写默认(生产环境)实现

协议定好后,为生产环境提供符合直觉的默认实现——这里直接调用真实的 FileManagerData 读写。生产实现通常是无状态 struct,仅做薄薄一层转发:

public struct DefaultFileSystemProvider: FileSystemProviding {
    public init() {}

    public func containerURL(for purpose: Purpose) -> URL? {
        FileManager.default.url(forUbiquityContainerIdentifier: nil)
    }
}

public struct DefaultFileAccessor: FileAccessorProviding {
    public init() {}

    public func read(from url: URL) throws -> Data {
        try Data(contentsOf: url)
    }

    public func write(_ data: Data, to url: URL) throws {
        try data.write(to: url, options: .atomic)
    }

    public func fileExists(at url: URL) -> Bool {
        FileManager.default.fileExists(atPath: url.path)
    }
}

两点值得注意的工程细节:

  • Data.write(to:options:) 使用 .atomic 原子写,保证写入过程要么完整成功、要么不产生半截文件——生产实现本身就承载了正确的系统调用语义;
  • 从源码看,这些实现刻意保持无状态且可空 init,便于在 init 默认参数里直接 DefaultFileAccessor() 实例化(详见第 4 步),对 Sendable 也无额外负担。

第 3 步:为测试编写可注入故障的 Mock 实现

Mock 的灵魂是可配置性:除了用内存字典替代真实磁盘,还必须开放 readErrorwriteError 这类属性,让测试能够随心所欲地"注入故障"。

skill 文档给出了标准模板(注意仓库内 .kiro 副本 特别标注了 /// NOTE: Not thread-safe 注释,说明它专为单线程测试上下文设计):

public final class MockFileAccessor: FileAccessorProviding, @unchecked Sendable {
    public var files: [URL: Data] = [:]
    public var readError: Error?
    public var writeError: Error?

    public init() {}

    public func read(from url: URL) throws -> Data {
        if let error = readError { throw error }
        guard let data = files[url] else {
            throw CocoaError(.fileReadNoSuchFile)
        }
        return data
    }

    public func write(_ data: Data, to url: URL) throws {
        if let error = writeError { throw error }
        files[url] = data
    }

    public func fileExists(at url: URL) -> Bool {
        files[url] != nil
    }
}

该 Mock 的三个行为契约与生产实现一一对应:

Mock 状态 触发的行为 对应测试诉求
readError 被设置 read 直接抛出该错误 测试"文件损坏/读取失败"路径
writeError 被设置 write 直接抛出该错误 测试"写入失败/磁盘已满"路径
files 无对应 key 且无错误 readCocoaError(.fileReadNoSuchFile) 测试"文件不存在"路径
files 中存在 key read 返回内存数据、write 写入字典 测试正常读写与内存回放

由于 Mock 内部持有可变字典,而协议要求 Sendable,这里需要声明为 final class 并标注 @unchecked Sendable——这是对"仅单线程测试使用"这一约束的显式契约,务必遵守。

第 4 步:用默认参数注入依赖

这是整套模式最关键的一环:生产代码用默认参数拿到真实实现,测试代码通过构造参数显式注入 Mock。被测类型(这里是 actor)因此做到了零配置可运行、完全可注入可测试:

public actor SyncManager {
    private let fileSystem: FileSystemProviding
    private let fileAccessor: FileAccessorProviding

    public init(
        fileSystem: FileSystemProviding = DefaultFileSystemProvider(),
        fileAccessor: FileAccessorProviding = DefaultFileAccessor()
    ) {
        self.fileSystem = fileSystem
        self.fileAccessor = fileAccessor
    }

    public func sync() async throws {
        guard let containerURL = fileSystem.containerURL(for: .sync) else {
            throw SyncError.containerNotAvailable
        }
        let data = try fileAccessor.read(
            from: containerURL.appendingPathComponent("data.json")
        )
        // Process data...
    }
}

需要读懂的几个设计信号:

  • 存储类型是协议private let fileSystem: FileSystemProviding,意味着运行时可以是默认实现,也可以是任意 Mock,业务方法 sync() 完全不知道也不关心背后是谁;
  • actor 隔离要求 Sendable:依赖协议声明了 Sendable,才能安全地作为 actor 的存储属性存在,这正是第 1 步强制 Sendable 的原因;
  • 错误上抛:容器不可用时抛出 SyncError.containerNotAvailable(业务自定义错误类型),文件读取失败时向上传播 CocoaError——错误边界清晰,测试才好精确断言。

ECC 的 Swift 模式规则 Dependency Injection 一节给出了同构但更贴近日常服务的示例——UserService 通过 init(repository: any UserRepository = DefaultUserRepository()) 完成注入,可见"默认参数注入"是 ECC 认可的标准 Swift DI 形态,本 skill 只是把它从服务层延伸到了文件系统层。

第 5 步:用 Swift Testing 编写测试

测试使用 Swift 标准测试框架 Swift Testingimport Testing),通过 @Test 宏声明用例、#expect / #expect(throws:) 断言结果。ECCs Swift 测试规则 也确认:新测试一律用 Swift Testing,而非 XCTest。

先看一个完整的"容器缺失"用例——它根本不创建任何真实 iCloud 容器,而是注入一个 containerURL 恒为 nilMockFileSystemProvider

import Testing

@Test("Sync manager handles missing container")
func testMissingContainer() async {
    let mockFileSystem = MockFileSystemProvider(containerURL: nil)
    let manager = SyncManager(fileSystem: mockFileSystem)

    await #expect(throws: SyncError.containerNotAvailable) {
        try await manager.sync()
    }
}

再看"正常读取"用例——把内存数据预先塞进 Mock 的 files 字典,再让被测对象从"假文件系统"中读出并校验:

@Test("Sync manager reads data correctly")
func testReadData() async throws {
    let mockFileAccessor = MockFileAccessor()
    mockFileAccessor.files[testURL] = testData

    let manager = SyncManager(fileAccessor: mockFileAccessor)
    let result = try await manager.loadData()

    #expect(result == expectedData)
}

最后是"读取失败优雅降级"用例——把 readError 设成 CocoaError(.fileReadCorruptFile),验证被测对象能正确感知并上抛错误,整个过程零真实 I/O:

@Test("Sync manager handles read errors gracefully")
func testReadError() async {
    let mockFileAccessor = MockFileAccessor()
    mockFileAccessor.readError = CocoaError(.fileReadCorruptFile)

    let manager = SyncManager(fileAccessor: mockFileAccessor)

    await #expect(throws: SyncError.self) {
        try await manager.sync()
    }
}

三个用例组合起来,恰好覆盖了第 3 步设计 Mock 时规划的全部可配置故障面:缺失(nil 容器 / 缺文件)、正常、损坏。这正是"Mock 的故障注入属性"与"测试的错误路径断言"一一对应的最好体现。

再进一步:协议注入 + Swift Testing 规则协同的细节

本 skill 聚焦"协议 + 注入 + Mock",而 ECC 的 Swift 测试规则 为它补充了测试用例本身的写法规范,二者配合使用效果最佳:

  • 用例描述即文档@Test("User creation validates email") 中的描述字符串会被 Swift Testing 直接展示,作为用例的第一手语义说明;
  • #expect(throws:) 精确断言:需要精确断言某个具体错误时用 #expect(throws: SomeError.specificCase);只关心"确实抛了这类错误"时用 #expect(throws: SomeError.self)——这正是上文第三个用例的写法来源;
  • 测试隔离:Swift Testing 每个用例获得全新实例,可在 init 中搭建、在 deinit 中清理,用例之间不共享可变状态。配合本 skill 的 Mock(每次测试 new 一个),天然满足隔离要求;
  • 参数化测试@Test("Validates formats", arguments: ["json", "xml", "csv"]) 让同一套协议 Mock 驱动多种输入成为可能;
  • 覆盖率检查:规则给出 swift test --enable-code-coverage 命令,用于收集 --enable-code-coverage 模式下的行覆盖数据,验证"哪些错误路径真正被 Mock 触发过"。

此外,第 4 步中被测对象采用 actor 时,请特别注意 Swift 规则中 Actor Pattern 的建议:用 actor 封装共享可变状态,替代 lock 与串行队列——这与"协议方法返回值必须 Sendable"一起,构成了 Swift 6 并发严格检查下的可测试架构基线。

Best Practices:必须遵守的五条最佳实践

skill 文档明确列出五条,直接继承如下:

  1. 单一职责(Single Responsibility):每个协议只处理一个关注点,绝不创建包含大量方法的 "god protocols"。宁可多几个 1~2 个方法的小协议,也不要一个 10 方法的巨型接口。
  2. Sendable 一致性(Sendable conformance):协议只要被跨 actor 边界使用就必须继承 Sendable;类型层面的具体限制按各 Swift 版本并发规则执行。
  3. 默认参数(Default parameters):生产代码默认使用真实实现,只有测试才需要显式传入 Mock——这样调用方代码路径零改动,注入成本降到最低。
  4. 错误模拟(Error simulation):Mock 要设计出可配置的错误属性(如 readErrorwriteError),专门用于测试失败路径;没有故障注入能力的 Mock 是不完整的。
  5. 只 Mock 边界(Only mock boundaries):Mock 的对象是外部依赖(文件系统、网络、外部 API),而不是无外部依赖的内部类型。

Anti-Patterns:五种应规避的反模式

对照以下反模式自查,可以避免把好的架构思路做成过度工程:

  1. 创建覆盖所有外部访问的单一巨型协议——违背单一职责,实现与 Mock 成本双高;
  2. Mock 无外部依赖的内部类型——纯内部计算不需要协议,Mock 它等于给逻辑加无用抽象层;
  3. #if DEBUG 条件编译代替正当的依赖注入——条件编译让生产与测试走不同分支,等于测试了另一份代码;
  4. 在 actor 场景中忘记 Sendable 一致性——会导致并发编译错误或运行期数据竞争,前文所有协议都显式继承 Sendable 正是为此;
  5. 过度工程如果一个类型没有外部依赖,它就不需要协议——这可以视为整份文档的"总闸门"。

When to Use:何时使用这份模式(总结清单)

  • 任何触及文件系统、网络或外部 API 的 Swift 代码;
  • 需要测试真实环境难以触发的错误处理路径(容器不可用、文件损坏、网络超时等);
  • 构建需要同时工作在 App、测试与 SwiftUI Preview 三种上下文中的模块;
  • 使用 Swift 并发(actors、结构化并发)且需要可测试架构的 App。

对照本 skill 的 frontmatter,其官方 description 也印证了适用范围:"Use when Swift code needs testing and file system, network, or external APIs must be mocked",即"Swift 代码需要测试、且文件系统 / 网络 / 外部 API 必须被 Mock"之时,就是本模式启动之日。

在 ECC 仓库中如何获取与定位这套指南

本指南以 skill 形式随仓库分发,除英文原文外还提供多语言副本,方便按阅读习惯查阅:

与本 skill 相互引用的 Swift 规则族同样值得通读:Swift 模式规则(协议导向设计、值类型、actor 模式、DI 注入)、Swift 测试规则(Swift Testing 框架规范、参数化与覆盖率)、Swift 编码风格Swift 安全规则。在工程接入层面,project-stack-mappings.jsonswift-protocol-di-testing 注册为 Swift / SwiftUI stack 的推荐 skill,并将测试命令映射为 swift testxcodebuild test——安装该模块后即可按 ECC 的规则约定在真实 Swift 工程中落地本文所述全部模式。

登录后查看全文
热门项目推荐
相关项目推荐