Injection de dépendances basée sur des protocoles Swift pour les tests
Modèles pour rendre le code Swift testable en abstraisant 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 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'erreur sans déclencher de vraies défaillances
- Construire des modules qui fonctionnent dans plusieurs environnements (application, test, aperçu SwiftUI)
- Concevoir une architecture testable avec la concurrence Swift (acteurs, Sendable)
Modèle 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 en bac à sable)
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 les 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 le 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 grâce")
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 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 frontières 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 les mocks
- Simulation d'erreur : Concevez des 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 conditionnels
#if DEBUGau lieu d'une véritable injection de dépendances - Oublier la conformité
Sendablelorsqu'elle est utilisée 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 l'utiliser
- Tout code Swift qui touche au système de fichiers, au réseau ou aux API externes
- Tester les chemins de gestion d'erreur difficiles à déclencher dans des environnements réels
- Construire des modules qui doivent fonctionner dans les contextes application, test et aperçu SwiftUI
- Applications utilisant la concurrence Swift (acteurs, concurrence structurée) qui ont besoin d'une architecture testable