Immutable vs Mutable Fakes¶
Understanding when to use each mode and the trade-offs involved.
Immutable Fakes (Default)¶
By default, Fakt generates immutable fakes where all behavior is set at construction time via the factory function and cannot change afterwards:
@Fake
interface UserRepository {
fun findById(id: String): User?
suspend fun save(user: User): User
}
// Behavior is fixed at construction
val fake = fakeUserRepository {
findById { id -> User(id, "Alice") }
}
// To test different behavior, create a new fake
val fakeNotFound = fakeUserRepository {
findById { null }
}
Why Immutable by Default?¶
Immutable fakes provide several guarantees that make tests safer and easier to reason about:
Thread Safety
Immutable fakes are inherently thread-safe. Since behavior properties are private val, they cannot be modified after construction. Multiple coroutines or threads can safely call the same fake concurrently with no risk of data races on the behavior configuration:
@Test
fun `GIVEN immutable fake WHEN called from 100 coroutines THEN all calls are safe`() = runTest {
val fake = fakeUserRepository {
findById { id -> User(id, "Alice") }
}
// Safe: behavior is immutable, no data races possible
List(100) { i ->
async { fake.findById("$i") }
}.awaitAll()
assertEquals(100, fake.findByIdCalls.value.size)
}
Predictability
Each fake instance has exactly one behavior for its entire lifetime. When reading a test, you can trace any fake’s behavior back to its construction site without scanning for later mutations:
@Test
fun `GIVEN user found WHEN loading profile THEN displays name`() {
// Everything about this fake is visible right here
val fake = fakeUserRepository {
findById { id -> User(id, "Alice") }
}
val result = profileLoader.load(fake, "123")
assertEquals("Alice", result.name)
}
No Test Pollution
Immutable fakes cannot accumulate state changes across test phases. Each scenario uses its own fake instance, preventing one test’s configuration from leaking into another:
@Test
fun `GIVEN user exists WHEN deleting THEN succeeds`() {
val fake = fakeUserRepository { findById { User("1", "Alice") } }
assertTrue(userService.delete(fake, "1"))
}
@Test
fun `GIVEN user missing WHEN deleting THEN fails gracefully`() {
val fake = fakeUserRepository { findById { null } } // Fresh instance
assertFalse(userService.delete(fake, "1"))
}
Debuggability
When a test fails, the fake’s behavior is fully determined by its construction. There is no need to trace through mid-test mutations to understand what went wrong.
Mutable Fakes (Opt-In)¶
For scenarios where behavior needs to change during a single test, Fakt supports mutable fakes as an opt-in feature:
import com.rsicarelli.fakt.MutabilityMode
@Fake(mutability = MutabilityMode.MUTABLE)
interface UserRepository {
fun findById(id: String): User?
suspend fun save(user: User): User
val status: String
}
Mutable fakes include a modify {} method that allows selective reconfiguration:
@Test
fun `GIVEN repository WHEN database fails mid-operation THEN service recovers`() {
val fake = fakeUserRepository {
findById { id -> User(id, "Alice") }
save { user -> user }
status { "connected" }
}
// Phase 1: Normal operation
val user = fake.findById("1")
assertEquals("Alice", user?.name)
// Phase 2: Simulate database failure
fake.modify {
findById { null }
status { "disconnected" }
}
// Phase 3: Verify degraded behavior
assertNull(fake.findById("1"))
assertEquals("disconnected", fake.status)
// save was NOT reconfigured — it still works as before
}
When Mutable Fakes Make Sense¶
Mutable fakes are designed for integration tests where the same injected fake instance must simulate different states during a single test:
Failure simulation — Test how your system handles a dependency that starts working and then fails:
@Test
fun `GIVEN cache working WHEN cache becomes unavailable THEN falls back to database`() {
val fakeCache = fakeCacheService {
get { key -> "cached_value" }
}
val service = DataService(fakeCache, realDatabase)
// Cache works initially
assertEquals("cached_value", service.fetch("key"))
// Cache goes down
fakeCache.modify {
get { throw ConnectionException("Cache unavailable") }
}
// Service falls back to database
val result = service.fetch("key")
assertEquals("db_value", result)
}
State machine transitions — Test components that depend on state changes from a single dependency:
@Test
fun `GIVEN auth service WHEN token expires THEN refreshes automatically`() {
val fakeAuth = fakeAuthService {
isAuthenticated { true }
getToken { "valid-token-123" }
}
val client = ApiClient(fakeAuth)
assertTrue(client.makeRequest("/api/data").isSuccess)
// Token expires
fakeAuth.modify {
isAuthenticated { false }
getToken { throw TokenExpiredException() }
}
// Client should trigger refresh flow
val result = client.makeRequest("/api/data")
assertTrue(result.isRefreshTriggered)
}
Feature flag toggling — Test runtime behavior changes:
@Test
fun `GIVEN feature disabled WHEN feature enabled mid-session THEN new UI renders`() {
val fakeFlags = fakeFeatureFlagService {
isEnabled { false }
}
val viewModel = SettingsViewModel(fakeFlags)
assertFalse(viewModel.showNewFeature.value)
fakeFlags.modify {
isEnabled { true }
}
assertTrue(viewModel.showNewFeature.value)
}
Selective Reconfiguration¶
The modify {} method only updates the behaviors you explicitly set. All other behaviors remain unchanged:
fake.modify {
findById { null } // Only this changes
// save, status — remain as previously configured
}
This means you can reconfigure one method without worrying about accidentally resetting others.
Concurrency Considerations¶
Immutable Fakes: Thread-Safe by Design¶
Behavior properties are private val — they cannot change after construction. This means:
- Multiple coroutines can call the same fake simultaneously
- No synchronization is needed
- Call history uses thread-safe internal state (backed by
AtomicReference) - Safe for
runTestwith concurrentasync/launchblocks
Mutable Fakes: Caller Responsibility¶
Behavior properties are @Volatile private var — they can be reassigned via modify {}. This means:
- Do not call
modify {}from multiple threads/coroutines simultaneously - Do not call
modify {}while other coroutines are actively calling the fake - Use
modify {}in sequential test phases, not during concurrent execution
// SAFE: Sequential phases
fake.findById("1") // Phase 1
fake.modify { ... } // Reconfigure between phases
fake.findById("2") // Phase 2
// UNSAFE: Concurrent mutation
launch { fake.modify { findById { null } } } // Don't do this
launch { fake.findById("1") } // while this runs
Rule of thumb
If your test uses concurrent coroutines to call a fake, use an immutable fake. Reserve mutable fakes for sequential, phase-based integration tests.
Comparison¶
| Aspect | Immutable (Default) | Mutable (Opt-In) |
|---|---|---|
| Behavior properties | private val |
@Volatile private var |
| Reconfiguration | Not possible — create a new fake | Via modify {} method |
| Thread safety | Guaranteed | Caller responsibility |
| Recommended for | Unit tests, concurrent tests | Integration tests, state simulation |
modify {} method |
Not generated | Generated |
| Annotation | @Fake (default) |
@Fake(mutability = MutabilityMode.MUTABLE) |
| Plugin setting | enableMutableFakes.set(false) |
enableMutableFakes.set(true) |
Call History Composition¶
Mutable fakes and call history are independent features that compose without conflicts. When both are enabled:
- Call history accumulates across reconfigurations — it is never reset by
modify {} - This means you can verify the total number of calls across all phases of a test
@Test
fun `GIVEN mutable tracked fake WHEN reconfigured THEN call history persists`() {
val fake = fakeMutableTrackedRepository {
find { listOf(User("1", "Test")) }
}
// Phase 1: Two calls
fake.find("query1")
fake.find("query2")
assertEquals(2, fake.findCalls.value.size)
// Reconfigure
fake.modify {
find { emptyList() }
}
// Phase 2: One more call with new behavior
assertEquals(emptyList(), fake.find("query3"))
// Call history accumulated: 2 + 1 = 3
assertEquals(3, fake.findCalls.value.size)
}
Configuration¶
Mutable fakes can be enabled at two levels:
Project-wide default in build.gradle.kts:
Per-interface override via annotation:
// Always mutable, regardless of plugin default
@Fake(mutability = MutabilityMode.MUTABLE)
interface UserRepository { ... }
// Always immutable, regardless of plugin default
@Fake(mutability = MutabilityMode.IMMUTABLE)
interface Logger { ... }
// Follow plugin default
@Fake // or @Fake(mutability = MutabilityMode.DEFAULT)
interface UserService { ... }
Resolution order: Annotation setting takes precedence over plugin default.
See Plugin Configuration for complete configuration reference.
Recommendations¶
- Start with immutable fakes — they are the safe default for the vast majority of tests
- Use mutable fakes sparingly — only when a single test truly needs mid-test behavior changes
- Prefer separate immutable fakes over mutable reconfiguration when testing multiple scenarios
- Avoid concurrent
modify {}calls — use mutable fakes in sequential test phases only - Combine with call history when you need to verify interactions across reconfiguration boundaries
Next Steps¶
- Plugin Configuration - Enable mutable fakes project-wide or per-interface
- Testing Patterns - Practical testing patterns for mutable fakes
- Generated Code Reference - Understanding the generated
modify {}method - Usage Guide - Quick start with mutable fakes