Testing Mastery

Abstracting File System Access

April 1, 2026
5 min read
Featured image for blog post: 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:

  1. Real I/O is slow: Disk operations are orders of magnitude slower than memory
  2. Tests leave artifacts: Forgotten cleanup leaves files on disk
  3. Tests interfere: Parallel tests writing to the same directory
  4. 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.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch