Testing Mastery

Cache Behavior Testing

April 28, 2026
5 min read
Featured image for blog post: Cache Behavior Testing

Caching adds complexity to async code. Here's how to test cache hits, misses, and expiration.

Follow along with the code: iOS-Practice on GitHub

The Caching Code

class WeatherManager {
    private let service: WeatherService
    private var cache: [String: Weather] = [:]
    private let cacheExpirationSeconds: TimeInterval

    init(service: WeatherService, cacheExpirationSeconds: TimeInterval = 300) {
        self.service = service
        self.cacheExpirationSeconds = cacheExpirationSeconds
    }

    func getWeather(for city: String, forceRefresh: Bool = false) async throws -> Weather {
        // Check cache first
        if !forceRefresh, let cached = cache[city] {
            let age = Date().timeIntervalSince(cached.date)
            if age < cacheExpirationSeconds {
                return cached
            }
        }

        // Fetch fresh data
        let weather = try await service.fetchCurrentWeather(for: city)
        cache[city] = weather
        return weather
    }

    func clearCache() {
        cache.removeAll()
    }

    var cachedCities: [String] {
        Array(cache.keys)
    }
}

Testing Cache Hits

func test_getWeather_cachesFreshData() async throws {
    // Act - Fetch twice
    _ = try await sut.getWeather(for: "Tokyo")
    _ = try await sut.getWeather(for: "Tokyo")

    // Assert - Service called only once due to caching
    XCTAssertEqual(mockService.fetchCurrentWeatherCallCount, 1)
}

Testing Force Refresh

func test_getWeather_forceRefresh_bypassesCache() async throws {
    // Arrange - First fetch populates cache
    _ = try await sut.getWeather(for: "Berlin")

    // Act - Force refresh
    _ = try await sut.getWeather(for: "Berlin", forceRefresh: true)

    // Assert - Service called twice
    XCTAssertEqual(mockService.fetchCurrentWeatherCallCount, 2)
}

Testing Cache Clear

func test_clearCache_removesAllCachedData() async throws {
    // Arrange
    _ = try await sut.getWeather(for: "NYC")
    _ = try await sut.getWeather(for: "LA")
    XCTAssertEqual(sut.cachedCities.count, 2)

    // Act
    sut.clearCache()

    // Assert
    XCTAssertTrue(sut.cachedCities.isEmpty)
}

func test_getWeather_afterClearCache_fetchesFreshData() async throws {
    // Arrange
    _ = try await sut.getWeather(for: "Seattle")
    sut.clearCache()

    // Act
    _ = try await sut.getWeather(for: "Seattle")

    // Assert - Service called twice (cache was cleared)
    XCTAssertEqual(mockService.fetchCurrentWeatherCallCount, 2)
}

Testing Cache Expiration

To test expiration, inject a short cache duration:

func test_getWeather_whenCacheExpired_fetchesFreshData() async throws {
    // Arrange - 100ms cache expiration
    let shortCacheSut = WeatherManager(service: mockService, cacheExpirationSeconds: 0.1)
    _ = try await shortCacheSut.getWeather(for: "Miami")

    // Wait for cache to expire
    try await Task.sleep(nanoseconds: 150_000_000) // 150ms

    // Act
    _ = try await shortCacheSut.getWeather(for: "Miami")

    // Assert - Service called twice (cache expired)
    XCTAssertEqual(mockService.fetchCurrentWeatherCallCount, 2)
}

func test_getWeather_whenCacheNotExpired_returnsCache() async throws {
    // Arrange - 1 second cache
    let sut = WeatherManager(service: mockService, cacheExpirationSeconds: 1.0)
    _ = try await sut.getWeather(for: "Denver")

    // Don't wait - cache still valid
    _ = try await sut.getWeather(for: "Denver")

    // Assert - Service called only once
    XCTAssertEqual(mockService.fetchCurrentWeatherCallCount, 1)
}

Testing Multiple Cities

func test_cache_separatesByCityKey() async throws {
    // Act
    _ = try await sut.getWeather(for: "CityA")
    _ = try await sut.getWeather(for: "CityB")
    _ = try await sut.getWeather(for: "CityA") // Should hit cache

    // Assert
    XCTAssertEqual(mockService.fetchCurrentWeatherCallCount, 2)
    XCTAssertEqual(mockService.requestedCities, ["CityA", "CityB"])
}

Key Patterns

ScenarioHow to Test
Cache hitFetch twice, assert service called once
Cache missClear cache, fetch, assert service called
Force refreshPass forceRefresh: true, assert service called
ExpirationUse short TTL, sleep, fetch again
Per-key cachingFetch different keys, verify separate cache entries

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