返回博客

用 Protocol 切断依赖:Swift 中让代码可测的最短路径

写 Swift 有一段时间之后,我发现「代码写得通不通」和「代码能不能测」是两件完全不同的事。前者看逻辑,后者看依赖。当一个模块把 FileManager.default、网络请求、iCloud 容器 URL 全部埋进实现里,测试要做的第一件事就是想办法绕过这些 I/O——而绕不过去的时候,测试就根本写不起来。

我踩过的弯路是用 #if DEBUG 分支:在编译期判断环境,注入不同的逻辑。这个方案有效,但代价是生产代码里夹着测试逻辑,边界模糊,越改越乱。后来换成 Protocol-based DI,感觉像是找到了真正合适的工具。

核心思路:在真实边界处划一刀

「依赖注入」这个词听起来很重,但做法其实很简单:在你不想在测试里真的执行的地方,抽出一个协议

文件系统是一个边界。网络是一个边界。沙盒 bookmark 存储是一个边界。每一处边界对应一个小协议,协议只描述「我需要什么操作」,不关心怎么实现:

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
}

生产环境用 DefaultFileAccessor 走真实的 FileManager,测试环境换一个 MockFileAccessor,内部维护一张 [URL: Data] 字典。两套实现,同一个协议接口,调用方完全不知道也不需要知道背后是哪个。

默认参数:生产代码零改动

依赖注入有一个常见的顾虑:调用方需要手动传每个依赖,代码会变得很啰嗦。Swift 的默认参数可以优雅地解决这个问题:

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

应用层直接 SyncManager() 就能用,什么都不用改。测试里才需要显式传 mock:

let manager = SyncManager(fileAccessor: MockFileAccessor())

这个模式的好处是「测试能力」不污染「使用体验」。协议和 mock 是测试基础设施的一部分,不是业务代码的负担。

Mock 要支持注入错误状态

好的 Mock 不只是「返回假数据」,还要能模拟失败路径。给 MockFileAccessor 加一个 readError: Error? 属性,测试里直接赋值,就能验证模块在磁盘损坏、权限不足等场景下的降级行为——这些路径在真实环境里几乎不可能稳定复现。

协议粒度:越小越好

这个模式最容易踩的坑是把协议做大。把文件读写、网络请求、缓存管理全塞进同一个 AppServicesProviding,表面上减少了协议数量,实际上每个测试都要提供一个实现了十几个方法的 mock,大部分方法是空的或是直接 fatalError

正确的姿势是每个协议只管一件事。FileSystemProviding 只负责路径解析,FileAccessorProviding 只负责读写操作,BookmarkStorageProviding 只管 bookmark 持久化。粒度小了,每个 mock 的实现也极简,测试代码的意图更清晰。

有一个判断标准:如果一个类型完全没有外部依赖,不需要协议。协议是为了切断边界,内部类型、纯计算逻辑直接测,不要为了「统一风格」给它也套一个协议。

与 Swift 并发的配合

如果模块用了 actor,协议上的 Sendable 约束是必须的,否则跨 actor 边界传递时编译器会报错。Mock 实现通常需要 @unchecked Sendable,因为它的可变状态(filesreadError)在测试里是单线程操作,我们明确知道没有数据竞争,但编译器看不出来。这是一个明确的、局部的权衡,不是架构上的坏味道。

一句话心法

可测性不是事后加进去的质量,而是在设计依赖边界时顺手得到的副产品。