Abstracting File System Access

Your image cache writes to disk. Tests create real files, run slowly, and leave artifacts behind.
Follow along with the code: iOS-Practice on GitHub
The Problem
class ImageCacheManagerBefore {
private let cacheDirectory: URL
init() {
let documentsPath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
cacheDirectory = documentsPath.appendingPathComponent("ImageCache")
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
func cacheImage(id: String, data: Data) throws {
let fileURL = cacheDirectory.appendingPathComponent("\(id).cache")
let cached = CachedImage(id: id, data: data, cachedAt: Date())
let encodedData = try JSONEncoder().encode(cached)
try encodedData.write(to: fileURL)
}
func getCachedImage(id: String) -> CachedImage? {
let fileURL = cacheDirectory.appendingPathComponent("\(id).cache")
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
guard let data = try? Data(contentsOf: fileURL),
let cached = try? JSONDecoder().decode(CachedImage.self, from: data) else {
return nil
}
return cached
}
}
Want to try it yourself? In FileSystemExerciseView.swift, delete everything from line 108 onwards (the // MARK: - AFTER section) and try to refactor ImageCacheManagerBefore to be testable. Your goal: make all tests in FileSystemExerciseTests.swift pass.
Why Direct FileManager Breaks Tests
Direct FileManager.default access causes several problems:
- Real I/O is slow: Disk operations are orders of magnitude slower than memory
- Tests leave artifacts: Forgotten cleanup leaves files on disk
- Tests interfere: Parallel tests writing to the same directory
- CI environment issues: Different permissions, paths, or disk space
The Fix: Abstract the File System
Define a protocol for file operations:
protocol FileSystemProtocol {
func fileExists(atPath path: String) -> Bool
func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws
func write(_ data: Data, to url: URL) throws
func read(from url: URL) throws -> Data
func removeItem(at url: URL) throws
func contentsOfDirectory(at url: URL) throws -> [URL]
}
Wrap FileManager
class SystemFileSystem: FileSystemProtocol {
private let fileManager = FileManager.default
func fileExists(atPath path: String) -> Bool {
fileManager.fileExists(atPath: path)
}
func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws {
try fileManager.createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories)
}
func write(_ data: Data, to url: URL) throws {
try data.write(to: url)
}
func read(from url: URL) throws -> Data {
try Data(contentsOf: url)
}
func removeItem(at url: URL) throws {
try fileManager.removeItem(at: url)
}
func contentsOfDirectory(at url: URL) throws -> [URL] {
try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
}
}
Inject the File System
class ImageCacheManager {
private let fileSystem: FileSystemProtocol
private let cacheDirectory: URL
init(
fileSystem: FileSystemProtocol = SystemFileSystem(),
cacheDirectory: URL? = nil
) {
self.fileSystem = fileSystem
if let dir = cacheDirectory {
self.cacheDirectory = dir
} else {
let documentsPath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
self.cacheDirectory = documentsPath.appendingPathComponent("ImageCache")
}
try? fileSystem.createDirectory(at: self.cacheDirectory, withIntermediateDirectories: true)
}
func cacheImage(id: String, data: Data) throws {
let fileURL = cacheDirectory.appendingPathComponent("\(id).cache")
let cached = CachedImage(id: id, data: data, cachedAt: Date())
let encodedData = try JSONEncoder().encode(cached)
try fileSystem.write(encodedData, to: fileURL)
}
func getCachedImage(id: String) -> CachedImage? {
let fileURL = cacheDirectory.appendingPathComponent("\(id).cache")
guard fileSystem.fileExists(atPath: fileURL.path) else { return nil }
guard let data = try? fileSystem.read(from: fileURL),
let cached = try? JSONDecoder().decode(CachedImage.self, from: data) else {
return nil
}
return cached
}
}
Production uses SystemFileSystem() by default. Tests inject an in-memory implementation.