Unit Testing Guide
CoCache provides abstract test specification classes (TCK) that validate cache behavior. This guide shows how to use them to test custom cache implementations.
Setting Up Test Dependencies
Add the cocache-test module as a test dependency:
// build.gradle.kts
dependencies {
testImplementation("me.ahoo.cocache:cocache-test:4.0.2")
testImplementation("me.ahoo.test:fluent-assert-core")
testImplementation("io.mockk:mockk")
}Using CacheSpec (Base Cache Tests)
To test any Cache<K, V> implementation, extend CacheSpec and implement two factory methods:
class MyCacheTest : CacheSpec<String, String>() {
override fun createCache(): Cache<String, String> {
return MyCustomCache()
}
override fun createCacheEntry(): Pair<String, String> {
return "test-key" to "test-value"
}
}This automatically runs 8 tests covering get, set, evict, TTL, and missing guard behavior.
graph TB
subgraph sg_97 ["CacheSpec Tests"]
direction TB
T1["get() - missing key returns null"]
T2["getWhenExpired() - expired entry returns null"]
T3["set() - basic set/get round-trip"]
T4["setWithTtl() - TTL is stored correctly"]
T5["setWithTtlAmplitude() - jitter TTL works"]
T6["evict() - entry is removed"]
T7["setMissing() - MissingGuard is treated as absent"]
T8["setMissingTtl() - MissingGuard with TTL is absent"]
end
T1 --> T2
T2 --> T3
T3 --> T4
T4 --> T5
T5 --> T6
T6 --> T7
T7 --> T8
style T1 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style T2 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style T3 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style T4 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style T5 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style T6 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style T7 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style T8 fill:#2d333b,stroke:#6d5dfc,color:#e6edf3Source: cocache-test/.../CacheSpec.kt
Testing ClientSideCache (L2)
Extend ClientSideCacheSpec<V> for local cache implementations. It adds a clear() test on top of CacheSpec:
class MapClientSideCacheTest : ClientSideCacheSpec<String>() {
override fun createCache(): ClientSideCache<String> {
return MapClientSideCache()
}
override fun createCacheEntry(): Pair<String, String> {
return "key-${UUID.randomUUID()}" to "value-${UUID.randomUUID()}"
}
}Source: cocache-test/.../ClientSideCacheSpec.kt
Testing DistributedCache (L1)
Extend DistributedCacheSpec<V> for distributed cache implementations:
class MockDistributedCacheTest : DistributedCacheSpec<String>() {
override fun createCache(): DistributedCache<String> {
return MockDistributedCache()
}
override fun createCacheEntry(): Pair<String, String> {
return "dist-key-${UUID.randomUUID()}" to "dist-value-${UUID.randomUUID()}"
}
}Source: cocache-test/.../DistributedCacheSpec.kt
Testing DefaultCoherentCache
Extend DefaultCoherentCacheSpec<K, V> for the full coherent cache. This spec requires implementing factory methods for all dependencies:
class DefaultCoherentCacheTest : DefaultCoherentCacheSpec<String, String>() {
override fun createKeyConverter(): KeyConverter<String> {
return ToStringKeyConverter("test:")
}
override fun createClientSideCache(): ClientSideCache<String> {
return MapClientSideCache()
}
override fun createDistributedCache(): DistributedCache<String> {
return MockDistributedCache()
}
override fun createCacheEvictedEventBus(): CacheEvictedEventBus {
return GuavaCacheEvictedEventBus()
}
override fun createCacheName(): String = "test-cache"
override fun createCacheEntry(): Pair<String, String> {
return "coherent-key" to "coherent-value"
}
}graph TB
subgraph sg_98 ["DefaultCoherentCacheSpec Factory Methods"]
direction TB
CK["createKeyConverter()"]
CSC["createClientSideCache()"]
DC["createDistributedCache()"]
EB["createCacheEvictedEventBus()"]
CN["createCacheName()"]
CE["createCacheEntry()"]
end
subgraph sg_99 ["Assembled CoherentCache"]
direction TB
CC["DefaultCoherentCache<br>L2 + L1 + CacheSource + EventBus"]
end
CK --> CC
CSC --> CC
DC --> CC
EB --> CC
CN --> CC
style CK fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style CSC fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style DC fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style EB fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style CN fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style CE fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style CC fill:#2d333b,stroke:#6d5dfc,color:#e6edf3Source: cocache-test/.../DefaultCoherentCacheSpec.kt
Testing CacheEvictedEventBus
Extend CacheEvictedEventBusSpec for event bus implementations:
class GuavaCacheEvictedEventBusTest : CacheEvictedEventBusSpec() {
override fun createCacheEvictedEventBus(): CacheEvictedEventBus {
return GuavaCacheEvictedEventBus()
}
}Source: cocache-test/.../consistency/CacheEvictedEventBusSpec.kt
Testing Multi-Instance Synchronization
Extend MultipleInstanceSyncSpec<K, V> to verify that two cache instances stay coherent through the event bus:
class MultipleInstanceSyncTest : MultipleInstanceSyncSpec<String, String>() {
override fun createKeyConverter(): KeyConverter<String> {
return ToStringKeyConverter("sync:")
}
override fun createClientSideCache(): ClientSideCache<String> {
return MapClientSideCache()
}
override fun createDistributedCache(): DistributedCache<String> {
return MockDistributedCache()
}
override fun createCacheEvictedEventBus(): CacheEvictedEventBus {
return GuavaCacheEvictedEventBus()
}
override fun createCacheName(): String = "sync-cache"
override fun createCacheEntry(): Pair<String, String> {
return "sync-key" to "sync-value"
}
}sequenceDiagram
autonumber
participant Current as Current Instance
participant EB as Event Bus
participant Other as Other Instance
Current->>Current: set(key, value)
Current->>Current: L2[key] = value
Current->>EB: publish(CacheEvictedEvent)
EB->>Other: onEvicted(event)
Other->>Other: L2.evict(key) [invalidate local]
Note over Other: Next get() fetches from L1
Current->>Current: set(key, newValue)
Current->>EB: publish(CacheEvictedEvent)
EB->>Other: onEvicted(event)
Other->>Other: L2.evict(key)
Current->>Current: evict(key)
Current->>EB: publish(CacheEvictedEvent)
EB->>Other: onEvicted(event)
Other->>Other: L2.evict(key)
Note over Other: L1 also evicted, so get() returns nullSource: cocache-test/.../MultipleInstanceSyncSpec.kt
Fluent Assert Pattern
CoCache uses the fluent-assert library for idiomatic Kotlin assertions. The pattern is:
import me.ahoo.test.asserts.assert
// Instead of AssertJ's assertThat(value).isEqualTo(expected):
value.assert().isEqualTo(expected)
// Null checks:
value.assert().isNull()
value.assert().isNotNull()
// Boolean:
result.assert().isTrue()
// Numeric:
count.assert().isZero()
count.assert().isOne()Important: Always use import me.ahoo.test.asserts.assert -- never use AssertJ's assertThat().
graph LR
subgraph sg_100 ["fluent-assert Pattern"]
direction LR
V["Any value"] --> Ext[".assert()"]
Ext --> Chain[".isEqualTo(expected)<br>.isNull()<br>.isTrue()<br>.isZero()"]
end
style V fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style Ext fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style Chain fill:#2d333b,stroke:#6d5dfc,color:#e6edf3Using mockk
For tests that need to mock dependencies:
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
// Create a mock CacheSource
val cacheSource = mockk<CacheSource<String, String>>()
every { cacheSource.loadCacheValue("key") } returns DefaultCacheValue.forever("value")
// Verify it was called
verify { cacheSource.loadCacheValue("key") }Writing a Custom Cache Implementation with TCK
When creating a new cache implementation (e.g., for a different distributed store), follow this pattern:
graph TB
subgraph sg_101 ["1. Implement the interface"]
direction TB
IFace["DistributedCache<V>"]
Impl["MyCustomDistributedCache<V>"]
Impl -.->|implements| IFace
end
subgraph sg_102 ["2. Extend the TCK spec"]
direction TB
Spec["DistributedCacheSpec<V>"]
Test["MyCustomDCTest"]
Test -.->|extends| Spec
end
subgraph sg_103 ["3. Implement factory methods"]
direction TB
Create["createCache() -> MyCustomDistributedCache"]
Entry["createCacheEntry() -> Pair(key, value)"]
end
Impl --> Test
Create --> Test
Entry --> Test
style IFace fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style Impl fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style Spec fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style Test fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style Create fill:#2d333b,stroke:#6d5dfc,color:#e6edf3
style Entry fill:#2d333b,stroke:#6d5dfc,color:#e6edf3Test Run Commands
# Run all tests for cocache-core
./gradlew :cocache-core:test
# Run a specific test class
./gradlew :cocache-core:test --tests "me.ahoo.cache.proxy.ProxyCacheTest"
# Run all TCK tests for a specific module
./gradlew :cocache-spring-redis:testSpecification Matrix
| Spec | Tests | For | Requires |
|---|---|---|---|
CacheSpec<K, V> | 8 | Any Cache implementation | Nothing external |
ClientSideCacheSpec<V> | 9 (8 + clear) | Any ClientSideCache | Nothing external |
DistributedCacheSpec<V> | 8 | Any DistributedCache | Nothing external |
DefaultCoherentCacheSpec<K, V> | 12+ (8 + coherence + concurrency) | Full coherent cache | All sub-components |
MultipleInstanceSyncSpec<K, V> | 1 (comprehensive) | Multi-instance sync | EventBus + DistributedCache |
CacheEvictedEventBusSpec | 2 | Event bus | Nothing external |
Related Pages
- Testing Overview -- TCK architecture and specification details
- Integration Testing -- Redis-based integration tests in CI
- Performance Patterns -- Concurrency test details
- Configuration Reference -- Annotation parameters