SuperAPI AI Directory

swift-protocol-di-testing

Testing & quality

View repository

Updated 10-16 04:39

About this skill

Protocol-based Dependency Injection in Swift (Testing)

Make Swift code testable by abstracting external dependencies (file system, network, iCloud) behind lean, focused protocols. This pattern enables deterministic tests without actual I/O.

When to Activate

  • When writing Swift code that accesses the file system, network, or external APIs
  • When you need to test error-handling paths without triggering actual failures
  • When building modules that need to run in different environments (App, tests, SwiftUI previews)
  • When designing a testable architecture using Swift Concurrency (such as Actor, Sendable)

Core Patterns

1. Define lean and focused protocols

Each protocol should handle only one specific external concern.

// 文件系统访问
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
}

// 书签存储(例如:用于沙盒应用)
public protocol BookmarkStorageProviding: Sendable {
    func saveBookmark(_ data: Data, for key: String) throws
    func loadBookmark(for key: String) throws -> Data?
}

2. Create the default (production) implementation

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)
    }
}

3. Create mock implementations for testing

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
    }
}

4. Inject dependencies using default parameters

Production code uses defaults; tests inject mocks.

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")
        )
        // 处理数据...
    }
}

5. Write tests using the Swift Testing framework

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()
    }
}

@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)
}

@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()
    }
}

Best Practices

  • Single Responsibility: Each protocol should handle only one concern — do not create "God Protocols" with too many methods.
  • Conform to Sendable: This is required when protocols are used across Actor boundaries.
  • Default Parameters: Allow production code to use the real implementation by default; mocks only need to be specified in tests.
  • Error Simulation: Design configurable error properties for mocks so failure paths can be tested.
  • Mock only at the boundaries: Mock external dependencies (file system, network, API); do not mock internal types.

Anti-Patterns to Avoid

  • Creating one large all-encompassing protocol that covers all external access.
  • Mocking internal types that have no external dependencies.
  • Using #if DEBUG conditional compilation blocks instead of proper dependency injection.
  • Forgetting to make protocols conform to Sendable。
  • Over-engineering: if a type has no external dependencies, there is no need to define a protocol for it.

When to Use

  • Any Swift code that involves the file system, network, or external APIs.
  • Testing error-handling paths that are difficult to trigger in a real environment.
  • Building modules that need to run simultaneously in App, test, and SwiftUI preview contexts.
  • Apps that use Swift Concurrency (Actor, structured concurrency) and need a testable architecture.