Inyección de Dependencias Basada en Protocolos en Swift para Pruebas
Patrones para hacer que el código Swift sea testeable abstrayendo dependencias externas (sistema de archivos, red, iCloud) detrás de protocolos pequeños y enfocados. Permite pruebas deterministas sin E/S.
Cuándo activarlo
- Escribir código Swift que accede al sistema de archivos, red o APIs externas
- Necesidad de probar rutas de manejo de errores sin desencadenar fallos reales
- Construir módulos que funcionen en diferentes entornos (app, prueba, vista previa de SwiftUI)
- Diseñar arquitectura testeable con concurrencia de Swift (actores, Sendable)
Patrón Principal
1. Definir Protocolos Pequeños y Enfocados
Cada protocolo maneja exactamente una preocupación externa.
// Acceso al sistema de archivos
public protocol FileSystemProviding: Sendable {
func containerURL(for purpose: Purpose) -> URL?
}
// Operaciones de lectura/escritura de archivos
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
}
// Almacenamiento de marcadores (ej., para aplicaciones en zona de pruebas)
public protocol BookmarkStorageProviding: Sendable {
func saveBookmark(_ data: Data, for key: String) throws
func loadBookmark(for key: String) throws -> Data?
}
2. Crear Implementaciones Predeterminadas (Producción)
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. Crear Implementaciones Simuladas para Pruebas
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. Inyectar Dependencias con Parámetros Predeterminados
El código de producción usa valores predeterminados; las pruebas inyectan simulaciones.
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")
)
// Procesar datos...
}
}
5. Escribir Pruebas con Swift Testing
import Testing
@Test("El gestor de sincronización maneja contenedor faltante")
func testMissingContainer() async {
let mockFileSystem = MockFileSystemProvider(containerURL: nil)
let manager = SyncManager(fileSystem: mockFileSystem)
await #expect(throws: SyncError.containerNotAvailable) {
try await manager.sync()
}
}
@Test("El gestor de sincronización lee datos correctamente")
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("El gestor de sincronización maneja errores de lectura correctamente")
func testReadError() async {
let mockFileAccessor = MockFileAccessor()
mockFileAccessor.readError = CocoaError(.fileReadCorruptFile)
let manager = SyncManager(fileAccessor: mockFileAccessor)
await #expect(throws: SyncError.self) {
try await manager.sync()
}
}
Mejores Prácticas
- Responsabilidad Única: Cada protocolo debe manejar una sola preocupación: no crees "protocolos dios" con muchos métodos
- Conformidad Sendable: Requerido cuando los protocolos se usan a través de límites de actores
- Parámetros Predeterminados: Permite que el código de producción use implementaciones reales por defecto; solo las pruebas necesitan especificar simulaciones
- Simulación de Errores: Diseña simulaciones con propiedades de error configurables para probar rutas de fallo
- Solo simular límites: Simula dependencias externas (sistema de archivos, red, APIs), no tipos internos
Antipatrones a Evitar
- Crear un único protocolo grande que cubra todo el acceso externo
- Simular tipos internos que no tienen dependencias externas
- Usar condicionales
#if DEBUGen lugar de inyección de dependencias adecuada - Olvidar la conformidad
Sendablecuando se usa con actores - Sobreingeniería: si un tipo no tiene dependencias externas, no necesita un protocolo
Cuándo Usarlo
- Cualquier código Swift que toque el sistema de archivos, la red o APIs externas
- Probar rutas de manejo de errores que son difíciles de desencadenar en entornos reales
- Construir módulos que necesitan funcionar en contextos de app, prueba y vista previa de SwiftUI
- Aplicaciones que usan concurrencia de Swift (actores, concurrencia estructurada) que necesitan arquitectura testeable