54 added, 22 removed. Audit A to A.
---
name: simba-testing
description: Guide for testing Simba distributed lock and leader-election code. Use when writing or reviewing tests for MutexContender, SimbaLocker, AbstractScheduler, backend TCK conformance via MutexContendServiceSpec, Redis/JDBC/Zookeeper integration tests, timing-sensitive lock behavior, or new Kotlin assertions in Simba-based code.
---
# Testing Simba-Based Code
## Test Strategy Overview
Simba testing has three layers:
1. **Unit tests** — mock the `MutexContendServiceFactory`, test your business logic in isolation
2. **TCK (Technology Compatibility Kit)** — extend `MutexContendServiceSpec` to verify a backend implementation
3. **Integration tests** — run against a real backend (Redis, MySQL, Zookeeper)
Choose the simplest layer that gives confidence. Most application code only needs unit tests with mocks. Backend implementors need TCK + integration tests.
Before writing a test, decide:
- - **Application behavior**: mock `MutexContendServiceFactory`, capture the contender, and trigger callbacks directly.
+ - **Application behavior**: mock `MutexContendServiceFactory`, capture the contender, and pass ownership changes to `notifyOwner`.
- **Backend implementation**: extend `MutexContendServiceSpec` and run against the real backend.
- **Scheduler behavior**: verify leadership gating separately from the business logic in `work()`.
## Unit Tests with MockK
- For application code that injects `MutexContendServiceFactory`, mock it:
+ For application code that injects `MutexContendServiceFactory`, call the component under test and mock only the factory seam:
```kotlin
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import me.ahoo.simba.core.MutexContendService
import me.ahoo.simba.core.MutexContendServiceFactory
import me.ahoo.simba.core.MutexContender
import me.ahoo.simba.core.MutexOwner
import me.ahoo.simba.core.MutexState
- class MyServiceTest {
+ class MyComponentTest {
private val mockFactory = mockk<MutexContendServiceFactory>()
private val mockService = mockk<MutexContendService>(relaxed = true)
@BeforeEach
fun setup() {
every { mockFactory.createMutexContendService(any()) } returns mockService
}
@Test
fun `should start contend service`() {
- val contender = MyContender()
- val service = mockFactory.createMutexContendService(contender)
- service.start()
+ val myComponent = MyComponent(mockFactory)
+ myComponent.start()
- verify { service.start() }
+ verify(exactly = 1) { mockService.start() }
}
}
```
### Simulating Leadership Changes
- To test code that reacts to `onAcquired`/`onReleased`, capture the contender and invoke callbacks directly:
+ To test code that reacts to `onAcquired`/`onReleased`, capture the contender and pass states through its real `notifyOwner` dispatch:
```kotlin
@Test
fun `should react to leadership change`() {
val contenderSlot = slot<MutexContender>()
every { mockFactory.createMutexContendService(capture(contenderSlot)) } returns mockService
// Create your service/component that uses Simba
val myComponent = MyComponent(mockFactory)
myComponent.start()
// Simulate acquiring leadership
- val mutexState = MutexState(MutexOwner.NONE, MutexOwner("test-contender"))
- contenderSlot.captured.onAcquired(mutexState)
+ val contender = contenderSlot.captured
+ val owner = MutexOwner(contender.contenderId)
+ contender.notifyOwner(MutexState(MutexOwner.NONE, owner))
// Assert your component's behavior
myComponent.isLeader.assert().isTrue()
// Simulate losing leadership
- val releasedState = MutexState(MutexOwner("test-contender"), MutexOwner.NONE)
- contenderSlot.captured.onReleased(releasedState)
+ contender.notifyOwner(MutexState(owner, MutexOwner.NONE))
myComponent.isLeader.assert().isFalse()
}
```
### SimbaLocker Unit Tests
Mock the factory and verify the locker lifecycle:
```kotlin
import me.ahoo.simba.locker.SimbaLocker
@Test
- fun `locker should acquire and release`() {
+ fun `locker should stop running service on close`() {
val mockService = mockk<MutexContendService>(relaxed = true)
every { mockFactory.createMutexContendService(any()) } returns mockService
+ every { mockService.running } returns true
val locker = SimbaLocker("test-lock", mockFactory)
- // acquire() will block, so in unit tests we typically don't call it directly
- // Instead test the code that uses the locker
locker.close()
verify { mockService.stop() }
}
```
+ Stub `running` as true because `close()` calls `stop()` only for an active contend service.
+
## TCK — Extending MutexContendServiceSpec
When implementing a new Simba backend, extend the TCK to verify correctness:
```kotlin
import me.ahoo.simba.test.MutexContendServiceSpec
class MyBackendMutexContendServiceTest : MutexContendServiceSpec() {
override val mutexContendServiceFactory: MutexContendServiceFactory =
MyBackendMutexContendServiceFactory(/* dependencies */)
}
```
This gives you five standard tests:
1. **`start()`** — acquire, verify owner, stop, verify released
2. **`restart()`** — stop and restart, verify full lifecycle repeats
3. **`guard()`** — acquire, wait 3s, verify owner hasn't changed (TTL renewal works)
4. **`multiContend()`** — 10 contenders compete, exactly one owner at any time
5. **`schedule()`** — AbstractScheduler lifecycle, work executes on leader
### Backend-Specific Test Requirements
| Backend | External dependency | Notes |
|---------|-------------------|-------|
| Redis | Running Redis instance | Current repository tests use `RedisStandaloneConfiguration` defaults |
| JDBC | Running MySQL instance | Current repository tests use `jdbc:mysql://localhost:3306/simba_db`, `root`/`root`; init script: `simba-jdbc/src/init-script/init-simba-mysql.sql` |
| Zookeeper | None | Uses Curator's `TestingServer` (embedded) |
Do not silently add Testcontainers to this repository's tests. If CI isolation is required, add the dependency and Gradle wiring intentionally, then update the backend setup code and this skill together.
## AbstractScheduler Tests
Test that scheduled work runs only on the leader:
```kotlin
+ import io.mockk.capture
+ import io.mockk.every
+ import io.mockk.mockk
+ import io.mockk.slot
+ import me.ahoo.simba.core.MutexContendService
+ import me.ahoo.simba.core.MutexContendServiceFactory
+ import me.ahoo.simba.core.MutexContender
+ import me.ahoo.simba.core.MutexOwner
+ import me.ahoo.simba.core.MutexState
import me.ahoo.simba.schedule.AbstractScheduler
import me.ahoo.simba.schedule.ScheduleConfig
+ import me.ahoo.test.asserts.assert
+ import org.junit.jupiter.api.Test
+ import java.time.Duration
+ import java.util.concurrent.CountDownLatch
+ import java.util.concurrent.TimeUnit
+ import java.util.concurrent.atomic.AtomicBoolean
@Test
fun `scheduler should run work only when leader`() {
- val workLatch = CountDownLatch(1)
+ val contenderSlot = slot<MutexContender>()
+ val mockService = mockk<MutexContendService>(relaxed = true)
+ val mockFactory = mockk<MutexContendServiceFactory>()
+ every { mockFactory.createMutexContendService(capture(contenderSlot)) } returns mockService
+ val leader = AtomicBoolean(false)
+ val leaderWork = CountDownLatch(1)
+ val outsideLeadershipWork = CountDownLatch(1)
val scheduler = object : AbstractScheduler("test-scheduler", mockFactory) {
override val config = ScheduleConfig.delay(Duration.ZERO, Duration.ofMillis(100))
override val worker = "test"
override fun work() {
- workLatch.countDown()
+ if (leader.get()) leaderWork.countDown() else outsideLeadershipWork.countDown()
}
}
scheduler.start()
- // Simulate acquiring leadership by triggering the contender
- // ...
-
- workLatch.await(5, TimeUnit.SECONDS).assert().isTrue()
- scheduler.stop()
+ val contender = contenderSlot.captured
+ val owner = MutexOwner(contender.contenderId)
+ try {
+ outsideLeadershipWork.await(200, TimeUnit.MILLISECONDS).assert().isFalse()
+ leader.set(true)
+ contender.notifyOwner(MutexState(MutexOwner.NONE, owner))
+ leaderWork.await(5, TimeUnit.SECONDS).assert().isTrue()
+ contender.notifyOwner(MutexState(owner, MutexOwner.NONE))
+ leader.set(false)
+ outsideLeadershipWork.await(200, TimeUnit.MILLISECONDS).assert().isFalse()
+ } finally {
+ if (leader.getAndSet(false)) {
+ contender.notifyOwner(MutexState(owner, MutexOwner.NONE))
+ }
+ scheduler.stop()
+ }
}
```
## Assertion Style
Use `fluent-assert` for new Kotlin assertions:
```kotlin
import me.ahoo.test.asserts.assert
value.assert().isEqualTo(expected)
collection.assert().hasSize(3)
bool.assert().isTrue()
```
Do not churn existing Hamcrest/AssertJ assertions solely for style. When adding or touching assertions, prefer `.assert()` and keep the local test readable.
## Common Test Pitfalls
1. **Timing-dependent tests**: Distributed lock tests are inherently timing-sensitive. Use generous timeouts (5-30s) and prefer `CountDownLatch` / `CompletableFuture` over new `Thread.sleep` calls.
2. **Shared mutex names**: Each test should use a unique mutex name to avoid cross-test interference. Use `"test-mutex-${UUID.randomUUID()}"`.
3. **Resource cleanup**: Always stop/close services in `@AfterEach` to avoid leaked threads and held locks.
4. **Mocking `MutexContendService` vs `MutexContendServiceFactory`**: Mock the factory (the DI seam), not the service directly. The factory is what application code injects.
5. **Testing `AbstractScheduler` without Simba**: If you only need to test the `work()` method, call it directly. The scheduler pattern is about leadership gating, not the work itself.