Injection de dépendances par protocole en Swift pour les tests
Modèles pour rendre le code Swift testable en abstrayant les dépendances externes (système de fichiers, réseau, iCloud) derrière de petits protocoles ciblés. Permet des tests déterministes sans E/S.
Quand l'activer
- Écrire du code Swift qui accède au système de fichiers, au réseau ou aux API externes
- Besoin de tester les chemins de gestion d'erreurs sans déclencher de vraies pannes
- Construire des modules qui fonctionnent dans différents environnements (app, test, aperçu SwiftUI)
- Concevoir une architecture testable avec la concurrence Swift (acteurs, Sendable)
Motif central
1. Définir de petits protocoles ciblés
Chaque protocole gère exactement une préoccupation externe.
// Accès au système de fichiers
public protocol FileSystemProviding: Sendable {
func containerURL(for purpose: Purpose) -> URL?
}
// Opérations de lecture/écriture de fichiers
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
}
// Stockage de signets (par exemple, pour les applications sandboxées)
public protocol BookmarkStorageProviding: Sendable {
func saveBookmark(_ data: Data, for key: String) throws
func loadBookmark(for key: String) throws -> Data?
}
2. Créer des implémentations par défaut (production)
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. Créer des implémentations simulées (mocks) pour les tests
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. Injecter les dépendances avec des paramètres par défaut
Le code de production utilise les valeurs par défaut ; les tests injectent des 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")
)
// Traiter les données...
}
}
5. Écrire des tests avec Swift Testing
import Testing
@Test("Le gestionnaire de synchronisation gère un conteneur manquant")
func testMissingContainer() async {
let mockFileSystem = MockFileSystemProvider(containerURL: nil)
let manager = SyncManager(fileSystem: mockFileSystem)
await #expect(throws: SyncError.containerNotAvailable) {
try await manager.sync()
}
}
@Test("Le gestionnaire de synchronisation lit correctement les données")
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("Le gestionnaire de synchronisation gère les erreurs de lecture avec élégance")
func testReadError() async {
let mockFileAccessor = MockFileAccessor()
mockFileAccessor.readError = CocoaError(.fileReadCorruptFile)
let manager = SyncManager(fileAccessor: mockFileAccessor)
await #expect(throws: SyncError.self) {
try await manager.sync()
}
}
Bonnes pratiques
- Responsabilité unique : Chaque protocole doit gérer une seule préoccupation — ne créez pas de « protocoles dieux » avec de nombreuses méthodes
- Conformité Sendable : Requise lorsque les protocoles sont utilisés à travers les limites des acteurs
- Paramètres par défaut : Laissez le code de production utiliser les implémentations réelles par défaut ; seuls les tests ont besoin de spécifier des mocks
- Simulation d'erreurs : Concevez les mocks avec des propriétés d'erreur configurables pour tester les chemins d'échec
- Ne simuler que les frontières : Simulez les dépendances externes (système de fichiers, réseau, API), pas les types internes
Anti-patrons à éviter
- Créer un seul grand protocole qui couvre tout l'accès externe
- Simuler des types internes qui n'ont pas de dépendances externes
- Utiliser des conditionnelles
#if DEBUGau lieu d'une véritable injection de dépendances - Oublier la conformité
Sendablelorsqu'utilisé avec des acteurs - Sur-ingénierie : si un type n'a pas de dépendances externes, il n'a pas besoin d'un protocole
Quand utiliser
- Tout code Swift qui touche au système de fichiers, au réseau ou aux API externes
- Tester des chemins de gestion d'erreurs difficiles à déclencher dans des environnements réels
- Construire des modules qui doivent fonctionner dans des contextes d'application, de test et d'aperçu SwiftUI
- Applications utilisant la concurrence Swift (acteurs, concurrence structurée) nécessitant une architecture testable