Test without a server

Mock mode lets you run the full app against fake endpoints — no real backend needed

Works on all platforms

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.

How it works

One line swaps every service from real HTTP to a mock router. Your service classes stay exactly the same.

🌐 Normal Mode

Your App
NoteService, TaskService, …
buoyient Engine
SyncableObjectService
Real Server
REST API

🧹 Mock Mode

Your App
Same service classes, unchanged
buoyient Engine
SyncableObjectService
Buoyient.httpClient = mock
MockEndpointRouter
Fake responses, stateful store

Quick start with MockModeBuilder

Four steps to a fully functional mock app. No real server required.

1

Add the dependency

Add the testing module as a debugImplementation so it's stripped from release builds.

app/build.gradle.kts
// 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")
2

Define seed data

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.

src/debug/resources/seeds/notes.json
[
  { "title": "Welcome", "body": "Hello from mock!" },
  { "title": "Second note", "body": "More content" }
]
3

Install mock mode

Define a MockServiceServer subclass for each service, then register them with MockModeBuilder. One call to .install() wires everything up.

src/debug/.../MockNoteServer.kt
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()))
src/debug/.../DebugApp.kt
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.

4

Add the debug manifest

Override the application class for debug builds so DebugApp runs instead of your production Application class.

src/debug/AndroidManifest.xml
<manifest ...>
    <application
        android:name=".DebugApp"
        tools:replace="android:name" />
</manifest>
WHAT install() DOES UNDER THE HOOD
Creates MockEndpointRouter
Creates MockServerStore
Declares endpoints()
Seeds initial data
Wraps handlers with EndpointController
Sets Buoyient.httpClient
Enables PrintSyncLogger

The MockModeHandle

install() returns a handle with everything you need for advanced testing scenarios.

router

MockEndpointRouter

Add custom handlers or inspect requestLog to see what the app sent.

store

MockServerStore

Access collections to seed data, mutate records (simulate another device), or inspect server-side state.

endpointController

MockEndpointController

Toggle any endpoint to return server errors or simulate timeouts — per endpoint, per service, or globally.

connectivityChecker

TestConnectivityChecker

Set .online = false to simulate offline mode. Requests queue locally instead of hitting the mock server.

Simulate anything

Mock mode isn't just for happy paths. Simulate the edge cases that break real apps.

🔌

Offline Mode

Set mockMode.connectivityChecker.online = false and all operations queue locally with pending status badges.

💥

Server Errors

Toggle any endpoint with FailureOverride.ServerError(503) via the endpoint controller. No handler code changes needed.

Timeouts

Use FailureOverride.Timeout() for pre-server failures, or Timeout(serverReceivedRequest = true) for response-lost-in-transit.

Sync Conflicts

Call store.collection("items").mutate(...) to change server data while the app has local edits, then trigger sync.

🔍

Inspect Traffic

Read router.requestLog to see every request the app made — method, URL, headers, and body.

📱

Multi-Device

Use store.collection("items").create(...) in a debug drawer to simulate records arriving from another device.

Alternative: inline seeds

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.

Inline seed data
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.

Custom response shapes

If your API wraps responses differently than the default {"data": ...}, pass custom wrappers.

Custom response 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.

Endpoint failure controller

Toggle any endpoint to fail — by label, by service, or globally. Changes take effect on the very next request.

Using MockEndpointController
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()

FailureOverride.ServerError

The handler runs (server state mutates), but the response is replaced with an HTTP error. Accepts an optional statusCode (default 500).

FailureOverride.Timeout

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.

Project structure

Keep mock code in src/debug/ so it's automatically stripped from release builds.

Recommended layout
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

Important rules

Shared database

Mock mode uses the same SQLite database as real mode. Clear the DB when switching modes if you want a fresh start.

Keep connectivity online

Set TestConnectivityChecker.online = true so requests flow through the mock server. Set to false only to test offline queueing.

Set before constructing

Call MockModeBuilder.install() (or set Buoyient.httpClient) before creating any service instances.

Thread-safe

MockEndpointRouter and MockServerStore are fully thread-safe. Mock handlers are evaluated at request time, not registration time.