Inyección de Dependencias Basada en Protocolos para Pruebas en Swift
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 acceda al sistema de archivos, red o APIs externas
- Necesidad de probar rutas de manejo de errores sin provocar fallos reales
- Construir módulos que funcionen en varios entornos (app, prueba, vista previa de SwiftUI)
- Diseñar arquitectura testeable con concurrencia de Swift (actores, Sendable)
Patrón Central
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 (por ejemplo, para aplicaciones en entorno limitado)
public protocol BookmarkStorageProviding: Sendable {
func saveBookmark(_ data: Data, for key: String) throws
func loadBookmark(for key: String) throws -> Data?
}
2. Crear Implementaciones por Defecto (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 (Mock) 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 por Defecto
El código de producción usa las implementaciones reales por defecto; las pruebas inyectan los simulacros.
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 todopoderosos" con muchos métodos
- Conformidad Sendable: Requerida cuando los protocolos se usan a través de límites de actores
- Parámetros por Defecto: Permite que el código de producción use implementaciones reales por defecto; solo las pruebas necesitan especificar simulacros
- Simulación de Errores: Diseña simulacros 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
Anti-patrones 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 una 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, red o APIs externas
- Probar rutas de manejo de errores que son difíciles de activar en entornos reales
- Construir módulos que necesiten funcionar en contextos de app, prueba y vista previa de SwiftUI
- Aplicaciones que usan concurrencia de Swift (actores, concurrencia estructurada) que necesitan una arquitectura testeable