Mock mode lets you run the full app against fake endpoints — no real backend needed
The mock infrastructure (MockModeBuilder, MockEndpointRouter, MockServerStore) is Kotlin Multiplatform and works on Android, iOS, and JVM. The examples below use Android patterns (debug source sets, Hilt), but the core mock APIs are identical on iOS. On iOS, seed files loaded via seedFile are resolved from NSBundle.mainBundle instead of the JVM classpath — add seed JSON files to your Xcode project's resources.
One line swaps every service from real HTTP to a mock router. Your service classes stay exactly the same.
Four steps to a fully functional mock app. No real server required.
Add the testing module as a debugImplementation so it's stripped from release builds.
// buoyient core
implementation("com.elvdev.buoyient:syncable-objects:0.1.0")
// mock mode (debug only)
debugImplementation("com.elvdev.buoyient:syncable-objects-mock-mode:0.1.0")
Create JSON files with the data you want pre-populated in mock mode. Place them in src/debug/resources/ so they're stripped from release builds.
[
{ "title": "Welcome", "body": "Hello from mock!" },
{ "title": "Second note", "body": "More content" }
]
Define a MockServiceServer subclass for each service, then register them with MockModeBuilder. One call to .install() wires everything up.
val BASE = "https://api.example.com/v1/notes"
class MockNoteServer : MockServiceServer() {
override val name = "notes"
override val seedFile = "seeds/notes.json"
override fun endpoints(c: MockServerCollection) = listOf(
MockEndpoint(POST, BASE, "create") { req ->
MockResponse(201, wrap(c.create(req.body)))
},
MockEndpoint(PUT, "$BASE/*", "update") { req ->
val id = req.url.substringAfterLast("/")
wrap(c.update(id, req.body)) ?: notFound()
},
MockEndpoint(GET, "$BASE/*", "get") { req ->
val id = req.url.substringAfterLast("/")
wrap(c.get(id)) ?: notFound()
},
MockEndpoint(GET, BASE, "list") { _ ->
MockResponse(200, wrapList(c.getAll()))
},
MockEndpoint(DELETE, "$BASE/*", "void") { req ->
val id = req.url.substringAfterLast("/")
wrap(c.void(id)) ?: notFound()
},
)
}
// Tiny helpers — adapt to match your API's response shape
fun wrap(r: MockServerRecord?) =
r?.let { MockResponse(200, buildJsonObject { put("data", it.toJsonObject()) }) }
fun wrapList(rs: List<MockServerRecord>) =
buildJsonObject { put("data", JsonArray(rs.map { it.toJsonObject() })) }
fun notFound() = MockResponse(404, JsonObject(emptyMap()))
class DebugApp : Application() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val mockMode = MockModeBuilder()
.service(MockNoteServer())
.service(MockTaskServer())
.install()
// Done! All services now route through the mock.
}
}
Each MockServiceServer owns its seed data and endpoint declarations. See the inline seeds section below for defining seeds in code.
Override the application class for debug builds so DebugApp runs instead of your production Application class.
<manifest ...>
<application
android:name=".DebugApp"
tools:replace="android:name" />
</manifest>
install() returns a handle with everything you need for advanced testing scenarios.
Add custom handlers or inspect requestLog to see what the app sent.
Access collections to seed data, mutate records (simulate another device), or inspect server-side state.
Toggle any endpoint to return server errors or simulate timeouts — per endpoint, per service, or globally.
Set .online = false to simulate offline mode. Requests queue locally instead of hitting the mock server.
Mock mode isn't just for happy paths. Simulate the edge cases that break real apps.
Set mockMode.connectivityChecker.online = false and all operations queue locally with pending status badges.
Toggle any endpoint with FailureOverride.ServerError(503) via the endpoint controller. No handler code changes needed.
Use FailureOverride.Timeout() for pre-server failures, or Timeout(serverReceivedRequest = true) for response-lost-in-transit.
Call store.collection("items").mutate(...) to change server data while the app has local edits, then trigger sync.
Read router.requestLog to see every request the app made — method, URL, headers, and body.
Use store.collection("items").create(...) in a debug drawer to simulate records arriving from another device.
If you prefer to define seed data directly in code instead of a JSON file, pass a seeds list. This is mutually exclusive with seedFile — use one or the other per service.
class MockNoteServer : MockServiceServer() {
override val name = "notes"
override val seeds = listOf(
SeedEntry(
data = buildJsonObject {
put("title", "Welcome")
put("body", "Hello from mock!")
},
serverId = "srv-1", // optional — auto-generated if omitted
),
)
override fun endpoints(
collection: MockServerCollection,
): List<MockEndpoint> =
crudEndpoints(
collection = collection,
baseUrl = "https://api.example.com/v1/notes",
)
}
Inline seeds support explicit serverId, clientId, and version via SeedEntry. This is mutually exclusive with seedFile — use one or the other.
If your API wraps responses differently than the default {"data": ...}, pass custom wrappers.
class MockInvoiceServer : MockServiceServer() {
override val name = "invoices"
override val seedFile = "seeds/invoices.json"
override fun endpoints(
collection: MockServerCollection,
): List<MockEndpoint> =
crudEndpoints(
collection = collection,
baseUrl = "https://api.example.com/v2/invoices",
responseWrapper = { record ->
buildJsonObject {
put("invoice", record.toJsonObject(
serverIdKey = "id",
clientIdKey = "reference_id",
))
}
},
listResponseWrapper = { records ->
buildJsonObject {
put("invoices", JsonArray(records.map {
it.toJsonObject(serverIdKey = "id")
}))
}
},
)
}
toJsonObject() accepts optional serverIdKey, clientIdKey, versionKey, and voidedKey parameters to match your API's field names.
Toggle any endpoint to fail — by label, by service, or globally. Changes take effect on the very next request.
val handle = MockModeBuilder()
.service(MockNoteServer())
.service(MockTaskServer())
.install()
// Make one endpoint return a 503
handle.endpointController.setOverride(
"notes", "create",
FailureOverride.ServerError(503),
)
// Simulate a timeout where the request never reaches the server
handle.endpointController.setOverride(
"notes", "update",
FailureOverride.Timeout(), // serverReceivedRequest = false
)
// Simulate a timeout where the server DID process the request
// (mutation takes effect, but the response is lost in transit)
handle.endpointController.setOverride(
"notes", "create",
FailureOverride.Timeout(serverReceivedRequest = true),
)
// Fail every endpoint for an entire service
handle.endpointController.setServiceOverride(
"tasks",
FailureOverride.ServerError(),
handle.endpointIndex["tasks"]!!,
)
// Fail everything globally (per-endpoint overrides still take precedence)
handle.endpointController.setGlobalOverride(FailureOverride.Timeout())
// Back to normal
handle.endpointController.clearAll()
The handler runs (server state mutates), but the response is replaced with an HTTP error. Accepts an optional statusCode (default 500).
With serverReceivedRequest = false (default): handler is skipped, connection error thrown. With true: handler runs, then timeout thrown — simulating a lost response.
Per-endpoint overrides always take precedence over the global override. All methods are thread-safe.
Keep mock code in src/debug/ so it's automatically stripped from release builds.
app/src/
├── main/java/com/example/yourapp/
│ ├── data/services/ # Service classes (unchanged)
│ └── ServiceLocator.kt # Abstraction for debug/release
├── debug/
│ ├── java/com/example/yourapp/
│ │ ├── DebugApp.kt # MockModeBuilder setup
│ │ ├── MockNoteServer.kt # MockServiceServer for notes
│ │ └── MockTaskServer.kt # MockServiceServer for tasks
│ ├── resources/seeds/ # Seed data (classpath JSON files)
│ │ ├── notes.json
│ │ └── tasks.json
│ └── AndroidManifest.xml # Overrides android:name
└── release/java/com/example/yourapp/
└── ReleaseApp.kt # (Optional) real service wiring
Mock mode uses the same SQLite database as real mode. Clear the DB when switching modes if you want a fresh start.
Set TestConnectivityChecker.online = true so requests flow through the mock server. Set to false only to test offline queueing.
Call MockModeBuilder.install() (or set Buoyient.httpClient) before creating any service instances.
MockEndpointRouter and MockServerStore are fully thread-safe. Mock handlers are evaluated at request time, not registration time.