Test iOS memory cache contracts on a cloud Mac

Test iOS memory cache contracts on a cloud Mac

If the same information card still shows old content after an account switch, network caching may not be the cause: the in-memory object may be stored by resource ID alone, without the account identity. When running these regression tests on an ArmShell cloud Mac, there is no need to create memory pressure. First turn key identity, write-time snapshots, explicit clearing, and cache misses into tests. Observe system-driven eviction separately.

Define the cache contract first

This article tests only an in-process NSCache, not disk caches or network response caches. Create an iOS unit test target with XCTest enabled, and put the types and tests below in the same test file. Create a fresh cache each time the test process starts; do not reuse a global singleton.

Define each key as a combination of account, resource ID, and content revision. The same resource ID must not produce cross-account hits, and data from an old revision must not serve as the result for a new revision. Store values as Data. If the caller continues modifying its own variable after a write, those changes must not affect the cached snapshot.

A cache is a disposable acceleration layer, not a source of truth. Every read must allow for a miss, and application logic must remain correct even when cached objects disappear.

Do not test passwords, tokens, or complete user profiles here. Use only short text in fixtures, and record only the test case name and hit status in failure logs.

Build keys from the complete identity

Do not simply join the three fields with hyphens: if a field contains the delimiter, the result can be ambiguous. The implementation below expresses key identity through object equality. Hash collisions are acceptable, but equality must compare every field. NSCache does not copy keys for you, so keep the key fields immutable.

import Foundation
import XCTest

final class MemoryKey: NSObject {
    let account: String
    let resource: String
    let revision: String

    init(_ account: String, _ resource: String, _ revision: String) {
        self.account = account
        self.resource = resource
        self.revision = revision
    }

    override var hash: Int {
        var hasher = Hasher()
        hasher.combine(account)
        hasher.combine(resource)
        hasher.combine(revision)
        return hasher.finalize()
    }

    override func isEqual(_ object: Any?) -> Bool {
        guard let other = object as? MemoryKey else { return false }
        return account == other.account
            && resource == other.resource
            && revision == other.revision
    }
}

@MainActor
final class SessionMemoryCache {
    private let storage = NSCache<MemoryKey, NSData>()

    init() {
        storage.totalCostLimit = 4 * 1024 * 1024
    }

    func store(_ data: Data, for key: MemoryKey) {
        storage.setObject(
            NSData(data: data),
            forKey: key,
            cost: data.count
        )
    }

    func read(_ key: MemoryKey) -> Data? {
        guard let value = storage.object(forKey: key) else { return nil }
        return Data(referencing: value)
    }

    func clear() {
        storage.removeAllObjects()
    }
}

The example confines the wrapper to the main actor so it can be used consistently with UI state. This does not mean NSCache itself can only be accessed on the main thread. If background access is needed, define a separate isolation strategy rather than casually removing the constraint.

Test isolation, revisions, and snapshots

The first test deliberately uses the same resource ID for different accounts and reads with newly created equivalent keys. This avoids merely proving that “the same key object can retrieve a value.” The second test modifies the original Data, then checks the cached value and explicit clearing.

final class SessionMemoryCacheTests: XCTestCase {
    @MainActor
    func testIdentityIsolation() {
        let cache = SessionMemoryCache()
        cache.store(Data([1]), for: MemoryKey("a", "card", "v1"))
        cache.store(Data([2]), for: MemoryKey("b", "card", "v1"))

        XCTAssertEqual(
            cache.read(MemoryKey("a", "card", "v1")), Data([1])
        )
        XCTAssertEqual(
            cache.read(MemoryKey("b", "card", "v1")), Data([2])
        )
        XCTAssertNil(cache.read(MemoryKey("a", "card", "v2")))
    }

    @MainActor
    func testSnapshotAndClear() {
        let cache = SessionMemoryCache()
        let key = MemoryKey("a", "card", "v1")
        var source = Data([1, 2])
        cache.store(source, for: key)
        source.append(3)

        XCTAssertEqual(cache.read(key), Data([1, 2]))
        cache.clear()
        XCTAssertNil(cache.read(key))
    }
}

These small objects are intended for functional regression tests under normal resource conditions. If the system happens to evict them, the hit assertions can still fail. For strictly deterministic tests of application logic, inject a dictionary-backed cache test double. Do not make the entire test suite depend on the system retaining objects.

Also add a test for overwriting the same key: write two values in succession and verify that the read returns the second value. Distinguish empty Data from a cache miss as well: the former is a valid value; the latter is nil.

Do not treat capacity hints as an eviction algorithm

The example measures cost in bytes because each write passes data.count. The caller defines the unit for totalCostLimit; it is not an automatically measured total of process memory. Objects, keys, and the container itself still add overhead. If an image is stored in decoded form, its compressed file size does not represent its actual cost either.

Neither countLimit nor totalCostLimit is a strict limit. You cannot insert a set of objects and assert that the first one written must have disappeared, nor can you assume that NSCache implements a deterministic least-recently-used policy. Memory-pressure experiments should report only hit rates and changes in process memory, not impose fixed results that every merge must pass.

Likewise, do not try to trigger system cleanup by allocating large arrays indefinitely. That can kill the test process first, demonstrating resource exhaustion rather than providing evidence about the cache contract.

Include cache misses and sign-out in acceptance tests

Inject a cache test double that always returns nil into the application's read layer, and verify that the loader can still obtain authoritative data. Then inject a test double containing a value, and verify that the branch allowed to use the cache does not load the data again. This checks both paths directly without waiting for real eviction.

When signing out, first stop the old account's requests and update the session identity, then clear the cache. Clearing alone is not enough: an old request that completes afterward can still write data back. Before writing, compare the session generation captured by the request with the current generation, and discard the write if they differ. This rule belongs in the loading coordination layer and should be tested independently.

Finish by checking four things: whether the account key comes from a trusted session, whether the revision field changes when content is updated, whether old requests can write back after sign-out, and whether loading still succeeds on a cache miss. Keep stable application assertions in routine regression tests and leave unstable pressure observations for diagnostics, so the cache does not become a hidden dependency behind intermittent failures.

Frequently asked questions

Does countLimit guarantee an exact number of cached objects?

No. countLimit and totalCostLimit are not hard bounds suitable for exact assertions. Test recovery from cache misses; implement a separate deterministic policy if strict capacity and eviction order are required.

How do I test that cached data cannot cross account boundaries?

Include an account namespace in every key and explicitly clear the cache on logout. Store different values for two accounts using the same resource identifier, then verify isolated reads and misses after clearing.

Get a Cloud Mac Ready for Your Next Build

Choose a model and rental term that fit your workload. Get exclusive access to a physical Apple Silicon node, not a virtual machine.

Rent a Cloud Mac

From Reading to Your First Build

Get your workload ready, then choose the exclusive physical node that suits your needs.