Test iOS LRU cache eviction and capacity on a cloud Mac

Test iOS LRU cache eviction and capacity on a cloud Mac

Test iOS LRU cache eviction and capacity on a cloud Mac

After paging through a list, you find that data you just viewed has already been evicted—even though the cache has not reached its expected capacity. Before adding memory, check the logic. More often, reads are not refreshing the access order, or updates to an existing key are leaving duplicate records behind. On a cloud Mac, extracting the cache into a standalone Swift package lets you reproduce these problems with a few deterministic operation sequences, without the UI, network, or simulator.

Define five cache contracts first

This article tests an LRU cache whose capacity is measured in entries: the least recently used entry is evicted first. Expiration and disk persistence are out of scope. Start by agreeing on five rules:

The team should agree on these rules rather than infer them from implementation details. For example, some applications deliberately avoid extending an entry’s lifetime on reads. That is a different access policy from the one defined here, so the same expectations cannot be reused unchanged.

Hit rate is an operational metric; eviction order is a functional contract. A healthy-looking hit rate can still hide duplicate keys and incorrect evictions.

Build an observable model in a separate directory

Once the node has the full toolchain installed, run the following two commands in a separate working directory. Neither administrator privileges nor a graphical session is required.

mkdir LRUContract
cd LRUContract
swift package init --type library

Replace Sources/LRUContract/LRUContract.swift with the implementation below. The order array runs from oldest to newest to make assertions straightforward; this is not a performance-oriented implementation for large caches.

public final class LRU<Key: Hashable, Value> {
    private let capacity: Int
    private var values: [Key: Value] = [:]
    public private(set) var oldestFirst: [Key] = []

    public init(capacity: Int) {
        precondition(capacity >= 0)
        self.capacity = capacity
    }

    public var count: Int { values.count }

    public func get(_ key: Key) -> Value? {
        guard let value = values[key] else { return nil }
        touch(key)
        return value
    }

    public func put(_ key: Key, _ value: Value) {
        guard capacity > 0 else { return }
        values.updateValue(value, forKey: key)
        touch(key)
        if values.count > capacity {
            let victim = oldestFirst.removeFirst()
            values.removeValue(forKey: victim)
        }
    }

    private func touch(_ key: Key) {
        oldestFirst.removeAll { $0 == key }
        oldestFirst.append(key)
    }
}

touch removes the old position before appending the new one, so a key cannot occupy two slots in the order array. Using updateValue for writes also keeps the deletion semantics of dictionary subscript assignment out of the update logic. If the production implementation switches to a dictionary and a doubly linked list, these tests can stay. Do not, however, claim that this array-based version has constant-time complexity.

Use short sequences to distinguish read and update bugs

Delete the generated sample tests from the test directory and save the following as Tests/LRUContractTests/LRUContractTests.swift. This explicitly uses XCTest, regardless of which testing framework the initialization template selected.

import XCTest
@testable import LRUContract

final class LRUContractTests: XCTestCase {
    func testReadPromotesAndMissDoesNot() {
        let cache = LRU<String, Int>(capacity: 2)
        cache.put("a", 1)
        cache.put("b", 2)
        XCTAssertNil(cache.get("missing"))
        XCTAssertEqual(cache.oldestFirst, ["a", "b"])
        XCTAssertEqual(cache.get("a"), 1)
        XCTAssertEqual(cache.oldestFirst, ["b", "a"])
        cache.put("c", 3)
        XCTAssertNil(cache.get("b"))
        XCTAssertEqual(cache.oldestFirst, ["a", "c"])
        XCTAssertEqual(cache.count, 2)
    }

    func testUpdateDoesNotConsumeAnotherSlot() {
        let cache = LRU<String, Int>(capacity: 2)
        cache.put("a", 1)
        cache.put("b", 2)
        cache.put("a", 9)
        XCTAssertEqual(cache.count, 2)
        XCTAssertEqual(cache.oldestFirst, ["b", "a"])
        cache.put("c", 3)
        XCTAssertNil(cache.get("b"))
        XCTAssertEqual(cache.get("a"), 9)
    }

    func testZeroCapacityStoresNothing() {
        let cache = LRU<String, Int>(capacity: 0)
        cache.put("a", 1)
        XCTAssertEqual(cache.count, 0)
        XCTAssertTrue(cache.oldestFirst.isEmpty)
        XCTAssertNil(cache.get("a"))
    }
}

Run swift test. A failure in the first test usually points to read promotion or side effects from a miss. If the second fails, start with updates to existing keys. The third prevents a zero-capacity cache from quietly retaining one entry. Do not assert only the final count: the count can be correct while the surviving keys are entirely wrong.

Assertions can change the order, too

get is not a read-only observation interface. Calling it to check a value changes which entry will be evicted next. That is why the example checks the order before reading values. Avoid casually iterating over every key and calling get in a test, or the test itself will rewrite the state under inspection.

Expand three test cases into sequence-based validation

When connecting the real cache, add four kinds of sequences: capacity one, repeated overwrites of the same key, consecutive misses, and reinsertion after eviction. After every step, check that the count does not exceed capacity, the order contains no duplicate keys, and the order’s length equals the actual entry count.

For broader testing, use pseudorandom operations with a fixed seed, but retain the complete operation list as well as the seed. A failure record should include at least the operation index, key, value written, and order before and after execution, so it remains replayable after implementation changes.

If the production version uses a linked list, use the simple array model as a reference: run the same sequence against both and compare the results. Do not copy the production implementation as the “correct answer,” or the same defect can easily pass on both sides.

Preserve the boundaries when reconnecting to iOS

The example has no synchronization. Even two asynchronous download tasks cannot safely share it as-is. With an actor or a single serial entry point, read promotion, writes, and eviction must all be protected together. Locking only writes still allows the order to break.

An entry limit is not a byte limit, either. Ten thumbnails and ten full-resolution images have different memory costs. If you switch to cost-based capacity, define separate rules for total cost, replacing the cost of an existing key, and rejecting an oversized individual entry. The existing count tests do not prove compliance with a memory budget.

Finally, adapt the actual application cache to the same contract rather than leaving the tests focused only on the teaching model. Once the pure algorithm tests pass, use a small set of integration tests to verify that writes after download completion and reads from the UI really use the same instance. Keeping failing sequences is more effective than blindly increasing cache capacity.

Frequently asked questions

Does NSCache guarantee strict LRU eviction?

No. NSCache does not promise strict LRU eviction order. If eviction order is a requirement, implement an explicit ordering structure and verify it with operation sequences.

Can concurrent tasks share the sample cache directly?

No. The sample has no synchronization. Protect shared access with an actor or a serial entry point, keeping read promotion and write eviction in the same isolation domain.

Need a dedicated Mac for your next build?

Choose a rental term that fits your needs. Get exclusive use of a physical Mac with full access to the macOS GUI and command line. Models, locations, and availability are shown live in the console.

Put these steps to work on your next archive.

Check the rental term and configuration before starting your build.