Skip to main content

Command Palette

Search for a command to run...

Test-Driving a Cache Layer in SwiftUI

Updated
8 min read

In previous articles, we built a CachingFeedRepository by writing the implementation first and tests after. That works — but it leaves a subtle gap: the tests are shaped by the implementation you already wrote, not by the behaviour you actually need.

Test-Driven Development (TDD) reverses the order. You write a failing test first, write just enough code to make it pass, then refactor. Each test specifies a new piece of behaviour. The implementation emerges from the tests, not the other way around.

This article builds a cache layer for a feed feature from scratch, using the TDD cycle: Red → Green → Refactor.


What We're Building

A FeedStore that can:

  1. Save a list of feed items to disk.

  2. Load the previously saved items.

  3. Delete the cache.

  4. Deliver an empty result when no cache exists.

We'll implement this using the file system so the cache survives app restarts.


The Interface (Domain Layer)

Before writing any test, define the interface in the Domain layer. This is the contract our store must fulfil:

// Domain/Cache/FeedStore.swift
protocol FeedStore {
    func save(_ items: [FeedItem]) throws
    func load() throws -> [FeedItem]
    func delete() throws
}

Now we write tests against this interface. The concrete type under test is CodableFeedStore — but our tests only ever reference FeedStore. This means we could swap the implementation for Core Data or SQLite without changing a single test.


Red — Write a Failing Test First

Start with the simplest possible behaviour: loading from an empty store delivers an empty array.

// DataTests/Cache/CodableFeedStoreTests.swift
final class CodableFeedStoreTests: XCTestCase {

    func test_load_deliversEmptyResultOnEmptyCache() throws {
        let sut = makeSUT()

        let result = try sut.load()

        XCTAssertEqual(result, [])
    }

    // MARK: - Helpers

    private func makeSUT(file: StaticString = #file, line: UInt = #line) -> FeedStore {
        let storeURL = testSpecificStoreURL()
        let sut = CodableFeedStore(storeURL: storeURL)
        trackForMemoryLeaks(sut, file: file, line: line)
        return sut
    }

    private func testSpecificStoreURL() -> URL {
        FileManager.default
            .urls(for: .cachesDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("\(type(of: self)).store")
    }
}

Run the test. It doesn't compile — CodableFeedStore doesn't exist yet. That's the Red phase.

Memory Leak Tracking

A small helper that turns memory leaks into test failures:

extension XCTestCase {
    func trackForMemoryLeaks(_ instance: AnyObject, file: StaticString = #file, line: UInt = #line) {
        addTeardownBlock { [weak instance] in
            XCTAssertNil(instance, "Instance should have been deallocated. Potential memory leak.", file: file, line: line)
        }
    }
}

This is a technique from Essential Developer: catch retain cycles at the test layer before they ever reach production.


Green — Write the Minimum Code to Pass

Create the bare minimum CodableFeedStore:

// Data/Cache/CodableFeedStore.swift
final class CodableFeedStore: FeedStore {
    private let storeURL: URL

    init(storeURL: URL) {
        self.storeURL = storeURL
    }

    func load() throws -> [FeedItem] {
        guard let data = try? Data(contentsOf: storeURL) else {
            return []
        }
        return try JSONDecoder().decode([FeedItem].self, from: data)
    }

    func save(_ items: [FeedItem]) throws {
        // Implement later
        let data = try JSONEncoder().encode(items)
        try data.write(to: storeURL, options: .atomic)
    }

    func delete() throws {
        guard FileManager.default.fileExists(atPath: storeURL.path) else { return }
        try FileManager.default.removeItem(at: storeURL)
    }
}

Test passes. Green.


Refactor — Clean Up Before Moving On

Before the next test, address one issue: test isolation. If the test writes to disk and crashes, the next run finds leftover data and may get a false positive or false negative.

Add setUp and tearDown to always start and end with a clean slate:

override func setUp() {
    super.setUp()
    deleteStoreArtifacts()
}

override func tearDown() {
    super.tearDown()
    deleteStoreArtifacts()
}

private func deleteStoreArtifacts() {
    try? FileManager.default.removeItem(at: testSpecificStoreURL())
}

Each test now owns its own isolated store URL and cleans up after itself. Refactor done.


Red → Green → Refactor: Saving and Loading

Next behaviour: saving items and loading them back delivers the same items.

func test_load_deliversItemsPreviouslySaved() throws {
    let items = makeItems()
    let sut = makeSUT()

    try sut.save(items)
    let loaded = try sut.load()

    XCTAssertEqual(loaded, items)
}

Red — run it. It passes immediately because save and load were already implemented. This is expected: we wrote the minimum implementation in the first Green phase. The test confirms the behaviour is correct.

Add a helper to keep tests readable:

private func makeItems() -> [FeedItem] {
    [
        FeedItem(id: UUID(), title: "First", imageURL: URL(string: "https://first.com")!),
        FeedItem(id: UUID(), title: "Second", imageURL: URL(string: "https://second.com")!)
    ]
}

Red → Green → Refactor: Override on Save

Behaviour: saving twice delivers only the latest items.

func test_load_deliversLastSavedItemsOnMultipleSaves() throws {
    let firstItems = makeItems()
    let latestItems = [FeedItem(id: UUID(), title: "Latest", imageURL: URL(string: "https://latest.com")!)]
    let sut = makeSUT()

    try sut.save(firstItems)
    try sut.save(latestItems)
    let loaded = try sut.load()

    XCTAssertEqual(loaded, latestItems)
}

Green immediately — .atomic write overwrites the file. No code change needed.


Red → Green → Refactor: Deletion

Behaviour: loading after deletion delivers an empty result.

func test_load_deliversEmptyResultAfterDeletion() throws {
    let sut = makeSUT()

    try sut.save(makeItems())
    try sut.delete()
    let loaded = try sut.load()

    XCTAssertEqual(loaded, [])
}

Greendelete() removes the file, and load() returns [] when no file exists. Already handled.


Red → Green → Refactor: Side-Effect Isolation

A critical property of a well-behaved store: one instance's operations don't affect another instance pointing to the same URL.

func test_sideEffects_runSerially() throws {
    let sut = makeSUT()
    let items = makeItems()

    try sut.save(items)
    let loaded = try sut.load()

    XCTAssertEqual(loaded, items)
}

And a test for concurrent access safety — two instances, same URL:

func test_save_doesNotAffectAnotherInstanceWithSameURL() throws {
    let instance1 = makeSUT()
    let instance2 = makeSUT()
    let items = makeItems()

    try instance1.save(items)
    let loaded = try instance2.load()

    XCTAssertEqual(loaded, items)
}

Both pass — file system access with .atomic writes is safe for this use case.


The Final Test Suite

final class CodableFeedStoreTests: XCTestCase {

    override func setUp() {
        super.setUp()
        deleteStoreArtifacts()
    }

    override func tearDown() {
        super.tearDown()
        deleteStoreArtifacts()
    }

    func test_load_deliversEmptyResultOnEmptyCache() throws {
        XCTAssertEqual(try makeSUT().load(), [])
    }

    func test_load_deliversItemsPreviouslySaved() throws {
        let sut = makeSUT()
        let items = makeItems()
        try sut.save(items)
        XCTAssertEqual(try sut.load(), items)
    }

    func test_load_deliversLastSavedItemsOnMultipleSaves() throws {
        let sut = makeSUT()
        let latest = [FeedItem(id: UUID(), title: "Latest", imageURL: anyURL())]
        try sut.save(makeItems())
        try sut.save(latest)
        XCTAssertEqual(try sut.load(), latest)
    }

    func test_load_deliversEmptyResultAfterDeletion() throws {
        let sut = makeSUT()
        try sut.save(makeItems())
        try sut.delete()
        XCTAssertEqual(try sut.load(), [])
    }

    // MARK: - Helpers

    private func makeSUT(file: StaticString = #file, line: UInt = #line) -> FeedStore {
        let sut = CodableFeedStore(storeURL: testSpecificStoreURL())
        trackForMemoryLeaks(sut, file: file, line: line)
        return sut
    }

    private func testSpecificStoreURL() -> URL {
        FileManager.default
            .urls(for: .cachesDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("\(type(of: self)).store")
    }

    private func deleteStoreArtifacts() {
        try? FileManager.default.removeItem(at: testSpecificStoreURL())
    }

    private func makeItems() -> [FeedItem] {
        [
            FeedItem(id: UUID(), title: "First", imageURL: anyURL()),
            FeedItem(id: UUID(), title: "Second", imageURL: anyURL())
        ]
    }

    private func anyURL() -> URL { URL(string: "https://any-url.com")! }
}

Plugging It Into the System

CodableFeedStore implements FeedStore. The CachingFeedRepository from the previous article expects a FeedCacheStore. To connect them, either unify the protocol or add a simple adapter at the Composition Root.

The Composition Root stays the single place where concrete types meet:

let store = CodableFeedStore(
    storeURL: FileManager.default
        .urls(for: .cachesDirectory, in: .userDomainMask)[0]
        .appendingPathComponent("feed.store")
)

let repository = CachingFeedRepository(
    decoratee: RemoteFeedRepository(url: feedURL),
    cache: store
)

What TDD Gave Us Here

Without TDD With TDD
Tests shaped by existing implementation Implementation shaped by required behaviour
Easy to miss edge cases Each edge case is a deliberate test
Memory leaks discovered in production Caught at test time via teardown tracking
Test pollution between runs setUp/tearDown enforced isolation
Large class before any feedback Smallest possible class at each step

Summary

TDD is not about writing tests — it's about designing through tests. Each Red phase asks: what behaviour do I need? Each Green phase asks: what's the minimum code to deliver it? Each Refactor phase asks: how do I keep the code clean without changing its behaviour?

For a cache layer, TDD delivers:

  1. A clear, minimal interface driven by actual use.

  2. Guaranteed isolation between test runs.

  3. Memory leak detection built into the test harness.

  4. Confidence that every behaviour is covered — because no behaviour was written without a test that demanded it.