swift-protocol-di-testing
Protocol-based dependency injection for testable Swift code — mock file system, network, and external APIs using focused protocols and Swift Testing.
Swift Protocol-Based Dependency Injection for Testing
Patterns for making Swift code testable by abstracting external dependencies (file system, network, iCloud) behind small, focused protocols. Enables deterministic tests without I/O.
When to Activate
- Writing Swift code that accesses file system, network, or external APIs
- Need to test error handling paths without triggering real failures
- Building modules that work across environments (app, test, SwiftUI preview)
- Designing testable architecture with Swift concurrency (actors, Sendable)
Core Pattern
1. Define Small, Focused Protocols
Each protocol handles exactly one external concern.
// File system accesspublic protocol FileSystemProviding: Sendable { func containerURL(for purpose: Purpose) -> URL?}
// File read/write operationspublic protocol FileAccessorProviding: Sendable { func read(from url: URL) throws -> Data func write(_ data: Data, to url: URL) throws func fileExists(at url: URL) -> Bool}
// Bookmark storage (e.g., for sandboxed apps)public protocol BookmarkStorageProviding: Sendable { func saveBookmark(_ data: Data, for key: String) throws func loadBookmark(for key: String) throws -> Data?}2. Create Default (Production) Implementations
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 with 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") ) // Process data... }}5. Write Tests with Swift Testing
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 one concern — don’t create “god protocols” with many methods
- Sendable conformance: Required when protocols are used across actor boundaries
- Default parameters: Let production code use real implementations by default; only tests need to specify mocks
- Error simulation: Design mocks with configurable error properties for testing failure paths
- Only mock boundaries: Mock external dependencies (file system, network, APIs), not internal types
Anti-Patterns to Avoid
- Creating a single large protocol that covers all external access
- Mocking internal types that have no external dependencies
- Using
#if DEBUGconditionals instead of proper dependency injection - Forgetting
Sendableconformance when used with actors - Over-engineering: if a type has no external dependencies, it doesn’t need a protocol
When to Use
- Any Swift code that touches file system, network, or external APIs
- Testing error handling paths that are hard to trigger in real environments
- Building modules that need to work in app, test, and SwiftUI preview contexts
- Apps using Swift concurrency (actors, structured concurrency) that need testable architecture