Testing Mastery
Cache Behavior Testing
April 28, 2026
5 min read

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
| Scenario | How to Test |
|---|---|
| Cache hit | Fetch twice, assert service called once |
| Cache miss | Clear cache, fetch, assert service called |
| Force refresh | Pass forceRefresh: true, assert service called |
| Expiration | Use short TTL, sleep, fetch again |
| Per-key caching | Fetch different keys, verify separate cache entries |