| name | swift-protocol-di-testing |
| description | 테스트 가능한 Swift 코드를 위한 프로토콜 기반 의존성 주입 패턴입니다. 작은 책임의 프로토콜과 Swift Testing을 사용해 파일 시스템, 네트워크, 외부 API를 모킹합니다. |
| origin | ECC |
테스트를 위한 Swift 프로토콜 기반 의존성 주입
외부 의존성(파일 시스템, 네트워크, iCloud)을 작고 집중된 프로토콜 뒤로 추상화해 Swift 코드를 테스트 가능하게 만드는 패턴입니다. 실제 I/O 없이 결정론적 테스트를 가능하게 합니다.
사용 시점
- 파일 시스템, 네트워크, 외부 API에 접근하는 Swift 코드를 작성할 때
- 실제 실패를 일으키지 않고 오류 처리 경로를 테스트해야 할 때
- 앱, 테스트, SwiftUI 미리보기 등 여러 환경에서 동작할 모듈을 만들 때
- Swift concurrency(actors, Sendable)를 사용하는 테스트 가능한 아키텍처를 설계할 때
핵심 패턴
1. 작고 집중된 프로토콜 정의
각 프로토콜은 정확히 하나의 외부 관심사만 처리합니다.
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. 기본 구현(운영용) 작성
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. 테스트용 모의 구현 작성
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
}
( : ) -> {
files[url]
}
}
4. 기본 매개변수로 의존성 주입
운영 코드는 기본 구현을 사용하고, 테스트는 mock을 주입합니다.
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. 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)
#expect(throws: .) {
manager.sync()
}
}
모범 사례
- 단일 책임: 각 프로토콜은 하나의 관심사만 맡습니다. 메서드가 많은 거대 프로토콜을 만들지 않습니다.
- Sendable 준수: 프로토콜이 actor 경계를 넘나들 때 필요합니다.
- 기본 매개변수: 운영 코드는 기본적으로 실제 구현을 쓰고, 테스트에서만 mock을 지정합니다.
- 오류 시뮬레이션: 실패 경로 테스트를 위해 mock에 설정 가능한 오류 속성을 둡니다.
- 경계만 mock 처리: 파일 시스템, 네트워크, API 같은 외부 의존성만 mock 처리하고, 내부 타입은 mock하지 않습니다.
피해야 할 안티패턴
- 모든 외부 접근을 하나의 큰 프로토콜로 덮는 것
- 외부 의존성이 없는 내부 타입까지 mock 처리하는 것
- 올바른 의존성 주입 대신
#if DEBUG 조건문에 의존하는 것
- actor와 함께 쓸 때
Sendable 준수를 빼먹는 것
- 과도한 설계: 외부 의존성이 없는 타입에는 프로토콜이 필요하지 않습니다
사용 대상
- 파일 시스템, 네트워크, 외부 API를 다루는 모든 Swift 코드
- 실제 환경에서 재현하기 어려운 오류 처리 경로 테스트
- 앱, 테스트, SwiftUI 미리보기 환경에서 모두 동작해야 하는 모듈
- 테스트 가능한 아키텍처가 필요한 Swift concurrency 기반 앱