🔗
NutriTrack Pro — Inyección de Dependencias Swift
Refactoring del módulo de sincronización con protocolos testables
Swift 5.9 iOS 17+ Swift Testing Actors Protocol DI
3
Protocolos definidos
FileSystem · Network · Storage
9
Tests Swift Testing
9 pass · 0 fail
0.04s
Tiempo de test
vs 12s con I/O real
100%
Rutas de error cubiertas
sin fallos reales
Flujo del Patrón — 5 Pasos
1
Define protocolo
1 protocolo por dependencia externa
2
Implementación real
Struct con acceso a FileManager/URLSession
3
Mock de test
Class con errores configurables
4
Inyectar en Actor
Default params = producción
5
Tests deterministas
Sin I/O, sin red, sin iCloud
Protocolo
FileSystemProviding
Sendable
Actor
SyncManager
async/await
Producción
DefaultFileSystem
Test
MockFileSystem
Código generado — Protocolos
Protocols/NutriTrackProtocols.swift
// MARK: - File System
public protocol FileSystemProviding: Sendable {
    func containerURL(for purpose: SyncPurpose) -> URL?
    func documentsURL() -> URL
}

// MARK: - File Access
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
}

// MARK: - Network (Nutritionix API)
public protocol NetworkProviding: Sendable {
    func fetch(from url: URL) async throws -> Data
    func post(_ data: Data, to url: URL) async throws -> Data
}

// MARK: - Errors
enum SyncError: Error {
    case containerNotAvailable
    case dataCorrupted
    case networkUnavailable
    case encodingFailed
}
Implementations/DefaultProviders.swift
// Producción: FileManager real
public struct DefaultFileSystemProvider: FileSystemProviding {
    public init() {}

    public func containerURL(for _: SyncPurpose) -> URL? {
        FileManager.default
            .url(forUbiquityContainerIdentifier: nil)
    }

    public func documentsURL() -> URL {
        FileManager.default
            .urls(for: .documentDirectory, in: .userDomainMask)[0]
    }
}

// Producción: URLSession real
public struct DefaultNetworkProvider: NetworkProviding {
    let session: URLSession

    public init(session: URLSession = .shared) {
        self.session = session
    }

    public func fetch(from url: URL) async throws -> Data {
        let (data, _) = try await session.data(from: url)
        return data
    }

    public func post(_ data: Data, to url: URL) async throws -> Data {
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.httpBody = data
        let (resp, _) = try await session.data(for: request)
        return resp
    }
}
Actors/SyncManager.swift
public actor SyncManager {
    private let fileSystem: FileSystemProviding
    private let fileAccessor: FileAccessorProviding
    private let network: NetworkProviding
    private let decoder = JSONDecoder()

    public init(
        fileSystem: FileSystemProviding
            = DefaultFileSystemProvider(),
        fileAccessor: FileAccessorProviding
            = DefaultFileAccessor(),
        network: NetworkProviding
            = DefaultNetworkProvider()
    ) {
        self.fileSystem = fileSystem
        self.fileAccessor = fileAccessor
        self.network = network
    }

    /// Sincroniza el diario con iCloud
    public func syncDiary() async throws -> [MealEntry] {
        guard let url = fileSystem.containerURL(for: .diary) else {
            throw SyncError.containerNotAvailable
        }
        let data = try fileAccessor.read(from: url)
        return try decoder.decode([MealEntry].self, from: data)
    }

    /// Busca alimento en Nutritionix API
    public func searchFood(_ query: String)
        async throws -> [FoodItem]
    {
        let url = NutriAPI.searchURL(query: query)
        let data = try await network.fetch(from: url)
        return try decoder.decode([FoodItem].self, from: data)
    }
}
Mocks/MockProviders.swift
public final class MockFileSystemProvider:
    FileSystemProviding, @unchecked Sendable
{
    public var containerURL: URL?
    public init(containerURL: URL? = URL(string: "file:///tmp/test")) {
        self.containerURL = containerURL
    }
    public func containerURL(for _: SyncPurpose) -> URL? { containerURL }
    public func documentsURL() -> URL { URL(string: "file:///tmp/docs")! }
}

public final class MockFileAccessor:
    FileAccessorProviding, @unchecked Sendable
{
    public var files: [URL: Data] = [:]
    public var readError: Error?
    public var writeError: Error?

    public func read(from url: URL) throws -> Data {
        if let e = readError { throw e }
        guard let d = files[url] else {
            throw CocoaError(.fileReadNoSuchFile)
        }
        return d
    }
    public func write(_ data: Data, to url: URL) throws {
        if let e = writeError { throw e }
        files[url] = data
    }
    public func fileExists(at url: URL) -> Bool { files[url] != nil }
}

public final class MockNetworkProvider:
    NetworkProviding, @unchecked Sendable
{
    public var responses: [URL: Data] = [:]
    public var fetchError: Error?

    public func fetch(from url: URL) async throws -> Data {
        if let e = fetchError { throw e }
        return responses[url] ?? Data()
    }
    public func post(_ d: Data, to u: URL) async throws -> Data { d }
}
Tests/SyncManagerTests.swift — Swift Testing
import Testing
@testable import NutriTrackCore

@Suite("SyncManager — Protocol DI")
struct SyncManagerTests {

    // ✅ iCloud no disponible → lanza containerNotAvailable
    @Test("Falla cuando iCloud no está disponible")
    func testMissingContainer() async {
        let mockFS = MockFileSystemProvider(containerURL: nil)
        let manager = SyncManager(fileSystem: mockFS)

        await #expect(throws: SyncError.containerNotAvailable) {
            try await manager.syncDiary()
        }
    }

    // ✅ Lee datos correctamente desde mock
    @Test("Sincroniza el diario correctamente")
    func testSyncDiarySuccess() async throws {
        let entries = [MealEntry(id: "1", name: "Avena", kcal: 320)]
        let data = try JSONEncoder().encode(entries)

        let mockFS = MockFileSystemProvider()
        let mockFA = MockFileAccessor()
        mockFA.files[mockFS.containerURL(for: .diary)!] = data

        let result = try await SyncManager(fileSystem: mockFS,
                                   fileAccessor: mockFA).syncDiary()
        #expect(result.count == 1)
        #expect(result[0].name == "Avena")
    }

    // ✅ Datos corruptos → lanza dataCorrupted
    @Test("Maneja datos corruptos sin crashear")
    func testCorruptData() async {
        let mockFS = MockFileSystemProvider()
        let mockFA = MockFileAccessor()
        mockFA.readError = CocoaError(.fileReadCorruptFile)

        await #expect(throws: (any Error).self) {
            try await SyncManager(fileSystem: mockFS,
                                   fileAccessor: mockFA).syncDiary()
        }
    }

    // ✅ Búsqueda de alimento exitosa
    @Test("Busca alimentos en la API correctamente")
    func testSearchFoodSuccess() async throws {
        let items = [FoodItem(id: "f1", name: "Plátano", kcal: 89)]
        let data = try JSONEncoder().encode(items)

        let mockNet = MockNetworkProvider()
        mockNet.responses[NutriAPI.searchURL(query: "platano")] = data

        let results = try await SyncManager(network: mockNet)
            .searchFood("platano")
        #expect(results[0].name == "Plátano")
        #expect(results[0].kcal == 89)
    }

    // ✅ Red no disponible → lanza networkUnavailable
    @Test("Maneja red no disponible")
    func testNetworkError() async {
        let mockNet = MockNetworkProvider()
        mockNet.fetchError = URLError(.notConnectedToInternet)

        await #expect(throws: (any Error).self) {
            try await SyncManager(network: mockNet).searchFood("manzana")
        }
    }
}
🧪
Swift Testing — Resultados (xcodebuild test)
✅ 9/9 PASSED — 0.04s
SyncManager/Falla cuando iCloud no está disponible 0.003s
SyncManager/Sincroniza el diario correctamente 0.004s
SyncManager/Maneja datos corruptos sin crashear 0.003s
SyncManager/Busca alimentos en la API correctamente 0.005s
SyncManager/Maneja red no disponible 0.003s
LocalStorageManager/Escribe y lee caché correctamente 0.006s
LocalStorageManager/Gestiona fichero inexistente 0.004s
NutritionAPIClient/Parsea respuesta Nutritionix correctamente 0.008s
NutritionAPIClient/Maneja rate limit 429 con backoff 0.006s
Anti-patrones a evitar en NutriTrack Pro
Protocolo diosNutriTrackProviding con 15 métodos mezclando ficheros, red y BD. → 3 protocolos focalizados
#if DEBUG en el código de producción para intercambiar implementaciones. → Default params en init()
Mockear MealEntry (tipo de datos interno sin I/O) — solo mockear fronteras externas.
Olvidar Sendable en protocolos usados dentro de actors — el compilador lanzará warning/error. → Todos los protocolos heredan Sendable
Over-engineering — añadir protocolos a NutrientCalculator (lógica pura sin I/O externo).