| name | buoyient-integration-testing |
| description | How to write automated integration tests for SyncableObjectService implementations using buoyient's testing module. Use this skill when the user wants to test a buoyient service, write unit or integration tests for sync behavior, test online/offline paths, verify mock server responses, assert on pending request queues, or test sync-up/sync-down flows. Also trigger when the user mentions TestServiceEnvironment, MockEndpointRouter, MockResponse, or asks about testing offline-first sync logic. |
Writing Integration Tests for buoyient Services
This skill covers how to write automated JVM integration tests for SyncableObjectService implementations using the :testing module. The module provides pre-built test doubles and a mock HTTP server so you don't need to manually wire Ktor MockEngine, ServerManager, LocalStoreManager, or anonymous stub implementations.
Dependencies
Add the testing module as a testImplementation dependency in the consuming app's build.gradle.kts:
testImplementation(project(":testing"))
testImplementation("com.elvdev.buoyient:testing:<version>")
The :testing module transitively provides everything from :syncable-objects, plus ktor-client-mock and an in-memory SQLite driver. No additional test dependencies are needed.
Imports
Classes from the :testing module are in com.elvdev.buoyient.testing. When writing tests you'll also need types from the core library — note the package reorganization:
- Service configs (
ServerProcessingConfig, ConnectivityChecker, etc.) are in com.elvdev.buoyient.serviceconfigs
- Data types for service operations (
HttpRequest, SyncableObjectServiceResponse, etc.) are in com.elvdev.buoyient.datatypes
- Utilities (
SyncCodec, BuoyientLog) are in com.elvdev.buoyient.utils
Core Concepts
TestServiceEnvironment
TestServiceEnvironment is the all-in-one harness. It bundles every dependency a SyncableObjectService needs with test-friendly defaults:
| Property | Type | Default | Purpose |
|---|
mockRouter | MockEndpointRouter | empty router | Register mock endpoint handlers |
connectivityChecker | TestConnectivityChecker | online = true | Control online/offline state |
logger | BuoyientLogger | NoOpSyncLogger (silent) | Swap to PrintSyncLogger for debugging |
syncScheduleNotifier | SyncScheduleNotifier | NoOpSyncScheduleNotifier | No-op (no WorkManager in tests) |
idGenerator | IdGenerator | IncrementingIdGenerator | Deterministic IDs: test-id-1, test-id-2, ... Installed as the global IdGenerator.generator |
database | SyncDatabase | in-memory SQLite | Isolated per TestServiceEnvironment instance |
serverManager | ServerManager | built from mockRouter (lazy) | Pass to service constructor |
Each TestServiceEnvironment instance gets its own isolated in-memory database, so tests cannot interfere with each other.
MockEndpointRouter
The mock HTTP server. Register handlers by HTTP method and URL pattern before exercising the service:
env.mockRouter.onPost("https://api.example.com/items") { request ->
MockResponse(statusCode = 201, body = buildJsonObject { ... })
}
URL pattern matching (matched in registration order, first match wins):
- Exact:
"https://api.example.com/items" -- matches only that URL
- Trailing wildcard:
"https://api.example.com/items/*" -- matches any URL starting with prefix
- Leading wildcard:
"*/items" -- matches any URL ending with suffix
- Both:
"*/items/*" -- matches if URL contains the substring
Request recording: every request is captured in env.mockRouter.requestLog for assertions.
MockResponse and RecordedRequest
data class MockResponse(
val statusCode: Int,
val body: JsonObject,
val epochTimestamp: Long = System.currentTimeMillis() / 1000,
)
data class RecordedRequest(
val method: HttpRequest.HttpMethod,
val url: String,
val body: JsonObject,
val headers: Map<String, String>,
)
MockConnectionException
Throw from a handler to simulate a network failure (translates to ServerManagerResponse.ConnectionError):
env.mockRouter.onPost("https://api.example.com/items") { throw MockConnectionException() }
Test Structure Template
Every integration test follows the same pattern:
- Create a
TestServiceEnvironment
- Register mock endpoint handlers
- Construct the service under test, passing env dependencies
- Stop periodic sync-down (if testing sync-up only)
- Exercise the service
- Assert on response, local DB state, and/or request log
import com.elvdev.buoyient.testing.*
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class YourModelServiceTest {
@Test
fun `create item online returns server response`() = runBlocking {
val env = TestServiceEnvironment()
env.mockRouter.onPost("https://api.example.com/v2/items") { request ->
MockResponse(
statusCode = 201,
body = buildJsonObject {
put("item", buildJsonObject {
put("id", "srv-1")
put("reference_id", request.body["reference_id"]!!)
put("name", request.body["name"]!!)
put("version", 1)
})
},
)
}
env.mockRouter.onGet("https://api.example.com/v2/items") { _ ->
MockResponse(200, buildJsonObject { put("items", kotlinx.serialization.json.JsonArray(emptyList())) })
}
val service = YourModelService(
serverProcessingConfig = YourModelServerProcessingConfig(),
connectivityChecker = env.connectivityChecker,
serverManager = env.serverManager,
localStoreManager = env.createLocalStoreManager(
codec = SyncCodec(YourModel.serializer()),
serviceName = "your_model",
),
logger = env.logger,
)
val result = service.createItem(YourModel(name = "Test", amount = 100))
assertTrue(result is CreateItemResponse.Success)
assertEquals("srv-1", result.item.serverId)
assertEquals(1, env.mockRouter.requestLog.size)
assertEquals(HttpRequest.HttpMethod.POST, env.mockRouter.requestLog[0].method)
service.close()
}
}
Testing the Online Path (create, update, void)
When env.connectivityChecker.online is true (the default), the service sends requests to the mock server immediately.
@Test
fun `update item online sends PUT and returns updated data`() = runBlocking {
val env = TestServiceEnvironment()
env.mockRouter.onPut("https://api.example.com/v2/items/*") { request ->
MockResponse(200, buildJsonObject {
put("item", buildJsonObject {
put("id", "srv-1")
put("reference_id", "c1")
put("name", "Updated Name")
put("version", 2)
})
})
}
val result = service.updateItem(existingItem, newName = "Updated Name")
assertTrue(result is SyncableObjectServiceResponse.Finished.NetworkResponseReceived)
assertEquals(200, result.statusCode)
}
Testing the Offline Path (queuing and sync-up)
Set online = false to force the offline code path, then flip back to true and trigger sync-up manually.
@Test
fun `create item offline queues locally then syncs up`() = runBlocking {
val env = TestServiceEnvironment()
env.connectivityChecker.online = false
env.mockRouter.onPost("https://api.example.com/v2/items") { request ->
MockResponse(201, buildJsonObject {
put("item", buildJsonObject {
put("id", "srv-1")
put("reference_id", request.body["reference_id"]!!)
put("name", request.body["name"]!!)
put("version", 1)
})
})
}
val service = buildService(env)
val result = service.createItem(YourModel(name = "Offline Item", amount = 50))
assertTrue(result is CreateItemResponse.Success)
assertEquals(0, env.mockRouter.requestLog.size)
env.connectivityChecker.online = true
val synced = service.syncUpLocalChanges(env.database)
assertEquals(1, synced)
assertEquals(1, env.mockRouter.requestLog.size)
}
Testing Connection Errors
Simulate connection failures using MockConnectionException:
@Test
fun `create returns NoInternetConnection on connection error`() = runBlocking {
val env = TestServiceEnvironment()
env.mockRouter.onPost("https://api.example.com/v2/items") {
throw MockConnectionException()
}
val service = buildService(env)
val result = service.createItem(YourModel(name = "Test", amount = 100))
assertTrue(result is SyncableObjectServiceResponse.NoInternetConnection)
}
Testing Server Error Responses
Return non-2xx status codes from handlers:
@Test
fun `create handles 422 validation error`() = runBlocking {
val env = TestServiceEnvironment()
env.mockRouter.onPost("https://api.example.com/v2/items") { _ ->
MockResponse(422, buildJsonObject {
put("errors", buildJsonArray {
add(buildJsonObject { put("detail", "Name is required") })
})
})
}
val service = buildService(env)
val result = service.createItem(YourModel(name = "", amount = 100))
assertTrue(result is CreateItemResponse.Failed)
}
Testing Sync-Down (server-to-local)
Register a GET or POST handler for the sync-fetch endpoint, then call syncDownFromServer():
@Test
fun `sync down upserts server items into local store`() = runBlocking {
val env = TestServiceEnvironment()
env.mockRouter.onGet("https://api.example.com/v2/items") { _ ->
MockResponse(200, buildJsonObject {
put("items", buildJsonArray {
add(buildJsonObject {
put("id", "srv-1")
put("reference_id", "c1")
put("name", "Server Item")
put("version", 1)
})
})
})
}
val service = buildService(env)
service.syncDownFromServer()
val localItems = service.getAllFromLocalStore()
assertEquals(1, localItems.size)
assertEquals("Server Item", localItems[0].name)
}
Asserting on Request Details
Use env.mockRouter.requestLog to verify what was sent:
assertEquals(2, env.mockRouter.requestLog.size)
val createRequest = env.mockRouter.requestLog[0]
assertEquals(HttpRequest.HttpMethod.POST, createRequest.method)
assertEquals("https://api.example.com/v2/items", createRequest.url)
assertTrue(createRequest.body.containsKey("idempotency_key"))
assertEquals("Test Item", createRequest.body["name"]?.jsonPrimitive?.content)
assertTrue(createRequest.headers.containsKey("Authorization"))
env.mockRouter.clearRequestLog()
Dynamic / Stateful Mock Responses
For tests where the server response depends on request order (e.g., CREATE then UPDATE):
val responseQueue = ArrayDeque(listOf(createResponseJson, updateResponseJson))
env.mockRouter.onPost("https://api.example.com/v2/items") { _ ->
MockResponse(200, responseQueue.removeFirst())
}
Or use a counter:
var requestCount = 0
env.mockRouter.onPost("https://api.example.com/v2/items") { _ ->
requestCount++
val body = if (requestCount == 1) createResponseBody else updateResponseBody
MockResponse(200, body)
}
Deterministic ID Generation
IncrementingIdGenerator produces IDs in sequence: test-id-1, test-id-2, etc. This makes assertions on idempotency keys predictable:
val env = TestServiceEnvironment(
idGenerator = IncrementingIdGenerator(prefix = "idem"),
)
Call (env.idGenerator as IncrementingIdGenerator).reset() between test phases if needed.
Since TestServiceEnvironment installs idGenerator as the global IdGenerator.generator, services automatically use it without explicit injection.
Debugging Failing Tests
Swap NoOpSyncLogger for PrintSyncLogger to see all internal sync engine logs:
val env = TestServiceEnvironment(logger = PrintSyncLogger)
This prints every HTTP request/response, sync status transition, and merge operation to stdout.
Helper Pattern: buildService()
To reduce repetition, define a helper that constructs the service from a TestServiceEnvironment:
private fun buildService(env: TestServiceEnvironment): YourModelService {
return YourModelService(
serverProcessingConfig = YourModelServerProcessingConfig(),
connectivityChecker = env.connectivityChecker,
serverManager = env.serverManager,
localStoreManager = env.createLocalStoreManager(
codec = SyncCodec(YourModel.serializer()),
serviceName = "your_model",
),
logger = env.logger,
)
}
Important Rules
- One
TestServiceEnvironment per test. Each instance has its own in-memory database, so tests are isolated by default.
- Register sync-down endpoint handlers even if you're only testing create/update/void. The
SyncDriver init starts periodic sync-down immediately, which will hit the sync-fetch endpoint. If you don't register a handler, it will get a 404 (which is usually harmless but can produce confusing log output). Alternatively, set syncCadenceSeconds to a very large value in your test config.
- Call
service.close() at the end of each test to cancel the periodic sync-down coroutine and release resources.
- Stop periodic sync-down if you only want to test sync-up in isolation. After constructing the service, call
service.stopPeriodicSyncDown() (inherited from SyncDriver).
- The
serverManager is lazy. Handlers registered on env.mockRouter after TestServiceEnvironment construction are still picked up because the MockEngine evaluates handlers at request time, not at build time.
Stateful Mock Server (MockServerStore)
For tests that need realistic server-side state — divergence simulation, conflict testing, delta sync-down — use MockServerStore instead of canned responses. The store holds persistent server-side records that mock handlers read from and write to.
Setup
val env = TestServiceEnvironment()
val todos = env.mockServerStore.collection("todos")
env.mockRouter.registerCrudHandlers(
collection = todos,
baseUrl = "https://api.example.com/v2/items",
responseWrapper = { record ->
buildJsonObject { put("item", record.toJsonObject()) }
},
)
Now POST /items creates a real record in the store, GET /items/srv-1 returns it, PUT /items/srv-1 updates it, etc. No need to hand-write mock handlers for standard CRUD.
Seeding Initial Server State
todos.seed("srv-1", buildJsonObject {
put("name", "Existing Item")
put("amount", 1500)
}, version = 3, clientId = "c-1")
Simulating Another Client's Edit (Divergence)
Use mutate to change a record as if another client updated the server. This increments the version and updates the timestamp:
todos.mutate("srv-1") { data ->
buildJsonObject {
data.forEach { (k, v) -> put(k, v) }
put("name", "Changed by another device")
}
}
service.syncDownFromServer()
Testing 3-Way Merge Conflicts
@Test
fun `conflicting edits produce Conflict sync status`() = runBlocking {
val env = TestServiceEnvironment()
val items = env.mockServerStore.collection("items")
items.seed("srv-1", buildJsonObject {
put("name", "Original")
put("amount", 100)
}, version = 1, clientId = "c-1")
env.connectivityChecker.online = false
service.updateItem(localItem, newName = "Client Edit")
items.mutate("srv-1") { data ->
buildJsonObject {
data.forEach { (k, v) -> put(k, v) }
put("name", "Server Edit")
}
}
env.connectivityChecker.online = true
service.syncDownFromServer()
val local = service.get("c-1")
assertTrue(local.syncStatus is SyncStatus.Conflict)
}
Delta Sync-Down (Timestamp Filtering)
Register a sync-down handler that uses the store's getUpdatedSince:
env.mockRouter.registerSyncDownHandler(
collection = items,
urlPattern = "https://api.example.com/v2/items/sync",
extractTimestamp = { request ->
request.body["last_synced_timestamp"]?.jsonPrimitive?.content?.toLongOrNull()
},
listResponseWrapper = { records ->
buildJsonObject { put("items", JsonArray(records.map { it.toJsonObject() })) }
},
)
Inspecting Server-Side State
assertEquals(3, items.count())
assertNotNull(items.findByClientId("c-1"))
assertTrue(items.get("srv-1")!!.voided)
Clock Control for Deterministic Timestamps
var now = 1000L
val env = TestServiceEnvironment(
mockServerStore = MockServerStore(clock = { now }),
)
now = 2000L
Available Testing Utilities Reference
All classes are in the com.elvdev.buoyient.testing package:
| Class | Purpose |
|---|
TestServiceEnvironment | All-in-one harness bundling all dependencies |
MockEndpointRouter | Register mock HTTP endpoint handlers, inspect request log |
MockRequestHandler | Functional interface: (RecordedRequest) -> MockResponse |
RecordedRequest | Captured request: method, url, body, headers |
MockResponse | Mock response: statusCode, body, epochTimestamp |
MockConnectionException | Throw to simulate network failure |
MockServerStore | Top-level stateful mock server managing named collections |
MockServerCollection | Per-collection CRUD, seed, mutate, inspect operations |
MockServerRecord | Data class for a single server-side record |
registerCrudHandlers() | Extension on MockEndpointRouter to auto-wire CRUD handlers to a collection |
registerSyncDownHandler() | Extension on MockEndpointRouter to auto-wire sync-down with timestamp filtering |
TestConnectivityChecker | Mutable online property to control connectivity |
TestDatabaseFactory | createInMemory() for isolated in-memory databases |
IncrementingIdGenerator | Deterministic sequential IDs |
NoOpSyncLogger | Silent logger |
PrintSyncLogger | Stdout logger for debugging |
NoOpSyncScheduleNotifier | No-op notifier (no WorkManager in tests) |