Skip to content

Testing your app

The testing artifact builds the client the way your app builds it, with install(Offline) { inMemory() }: the cache, the mutation queue and the file cache live in the client's memory, and nothing touches the disk. Its factories build the library's value types, for your own test doubles. The sample apps' tests use both: the client over a fake of the samples' project, and the factories for the library's values.

From the samples: FakeSupabaseServer.kt · AppModel.swift

/**
 * A client of this server with PostgREST, Storage and the offline plugin keeping its data in
 * memory; no Auth, so every request is the one anonymous identity.
 */
fun supabaseClient(): SupabaseClient =
    createSupabaseClient(
        supabaseUrl = "https://sample.supabase.test",
        supabaseKey = "test-key",
    ) {
        httpEngine = MockEngine { request -> answer(request) } // this fake server
        install(Postgrest) { maxRetries = 0 } // a failed read fails now, not after retries
        install(Storage) { resumable { cache = MemoryResumableCache() } }
        install(Offline) { inMemory(Clock.System) } // the cache and the queue in memory
    }
let client = SupabaseOfflineClient.inMemory(
    config: ClientSettings.config(
        accessToken: { await session.accessToken() },
        // The in-memory client watches no network monitor: by default, say it is online.
        networkAvailability: networkAvailability
    ),
    server: server.fakeServer,
    clock: clock
)

Add the test artifact

kotlin {
    sourceSets {
        commonTest.dependencies {
            implementation("io.github.nesfeder.supabase-offline:supabase-offline-kt-testing:0.1.0")
            implementation("io.ktor:ktor-client-mock:3.4.3")
        }
    }
}

Use it at exactly the library's version, from one version-catalog entry. Its factories and its in-memory client call into the library outside the API records, so they work only with the library of their own version. Its published metadata pins the library to that version with a strict constraint, and that works in one direction only:

  • an older testing artifact beside a newer library fails dependency resolution, rather than a test failing with a missing method;
  • a newer testing artifact raises the library in your test classpath to its own version without a word, so your tests would run on a library your app does not ship.

The in-memory client

inMemory(clock) keeps the plugin's local data in the client. Every other setting applies as in your app. What differs:

  • Each client starts empty, and a second client over the same configuration shares nothing with the first.
  • No connectivity monitor. syncStatus.value.network stays UNKNOWN, which counts as online, until the test sets networkAvailability or calls disableNetwork().
  • The clock is the time the client records: when rows were fetched and writes queued, and so how old a cached read is against a maxAge. It defaults to Clock.System.
  • Background failures are logged, never thrown into the test: the client's work runs in the library's own scope, not the test's.
  • supabase-kt's own plugins keep their own state. Give Storage a MemoryResumableCache and Auth a session manager that keeps the session in memory.
  • A CachedFile.path names a file in the client's memory, not on disk: read it through the offline bucket.

Use inMemory() under Robolectric too. The platform's own database needs a SQLite binary built for the host, and it records the application context once per process, so a later test's client would open an earlier test's database.

Pair it with a Ktor MockEngine

Give the client an engine that answers as your project would, and install PostgREST with maxRetries = 0, as above: supabase-kt retries a request that fails at the transport, so without it a read the cache cannot answer waits out those retries, about seven seconds, before it fails.

Simulate being offline

Call disableNetwork(): reads are answered from the cache and writes are queued, as on a device in airplane mode, until enableNetwork(). To see how your app meets a server that fails or a connection that drops, fail the requests in the mock engine instead. The sample's test goes through its own repository, whose Simulate offline is disableNetwork():

From the samples: OfflineTaskRepositoryTest.kt · ApiTourTests.swift

@Test
fun a_task_added_offline_is_listed_at_once_marked_pending_and_sent_on_reconnect() = runTest {
    // Given — the list loaded, then the network switched off
    repository.addTask("Older", notes = null)
    repository.tasks().awaitValue { it.size == 1 }
    repository.setNetworkEnabled(false)

    // When
    val outcome = repository.addTask("Newer, made offline", notes = null)

    // Then — queued, shown first and marked; nothing reached the server
    assertIs<NewTaskOutcome.SavedOnDevice>(outcome)
    val shown = repository.tasks().awaitValue { it.size == 2 }
    assertEquals("Newer, made offline", shown.first().title)
    assertTrue(shown.first().hasPendingMutations)
    assertEquals(1, server.rows(Tables.TASKS).size)

    // When — back online, and the queue replayed
    repository.setNetworkEnabled(true)
    repository.syncPendingMutations()

    // Then — both on the server, and the list settles on both with nothing pending
    assertEquals(2, server.rows(Tables.TASKS).size)
    repository.tasks().awaitValue { tasks ->
        tasks.size == 2 && tasks.none { it.hasPendingMutations }
    }
}
@Test("Offline, a new task waits in the queue on this device, and is sent once the network is back")
func offlineWriteWaits() async throws {
    // Given
    let test = TestApp()
    try await test.start()
    try await test.setOffline(true)

    // When
    let outcome = try await test.repository.addTask(title: "Buy milk", notes: nil)

    // Then
    if case .saved = outcome { Issue.record("It was sent while offline") }
    #expect(try await test.repository.pendingMutationCountNow() == 1)
    #expect(await test.writeRequests().isEmpty)

    // When
    try await test.setOffline(false)

    // Then
    try await eventually("it reaches the server") { await test.server.rows(of: Tables.tasks).count == 1 }
    try await eventually("nothing waits") { test.app.syncStatus.pendingMutationCount == 0 }
    try await test.stop()
}

Waiting for the client

The client runs on real time, not the test's virtual clock: inMemory(clock) sets the times it records, not how long it waits. A refresh or a replay that failed waits a few seconds, then up to a minute, before it tries again by itself. To cut that wait, give the client a networkAvailability flow and turn it true, which wakes a waiting refresh, and the replay with autoSync, at once; syncPendingMutations() sends without waiting. Await what you expect (first { … } on a flow) rather than advancing the test's clock. syncStatus's counts follow the queue shortly after each change, so wait for the value you expect there too.

Values for your own test doubles

The library's value types have no public constructors, so a test of code that reads them, or a fake of your own repository, builds them with the testing artifact's factories. The sample tests the mappings from the library's values to its own screens' this way:

From the samples: LibraryMappingsTest.kt · WordingTests.swift

@Test
fun a_status_whose_last_sync_stopped_says_why() {
    // Given — the library's status, built by the testing artifact
    val status =
        syncStatus(
            network = NetworkStatus.DISABLED,
            pendingMutationCount = 2,
            rejectedMutationCount = 1,
            lastSyncError = syncFailedException(httpStatus = 503, code = "PGRST002"),
        )

    // When
    val shown = status.toAppSyncStatus()

    // Then — the banner's terms: Simulate offline, two waiting, the server's answer
    assertEquals(AppNetworkStatus.Disabled, shown.network)
    assertEquals(2, shown.pendingMutationCount)
    assertEquals(1, shown.rejectedMutationCount)
    val stopped = assertIs<SyncStoppedException>(shown.lastSyncError)
    assertEquals(503, stopped.httpStatus)
    assertEquals("PGRST002", stopped.code)
    assertFalse(stopped.isConnectivityFailure)
}
@Test("A refused change says when it was made and refused, and which row it was for")
func refusedChanges() {
    // Given
    let refusal = rejectedMutation(
        id: 7,
        table: Tables.tasks,
        operation: .insert,
        createdAt: Self.now - 60,
        rowKeys: [["id": "3f2a91"]],
        body: ["title": "   "],
        serverError: serverError(status: 400, code: "23514", message: "violates check constraint \"tasks_title_length\""),
        message: "Refused",
        rejectedAt: Self.now,
        isRetryable: false
    )

    // Then
    let made = (Self.now - 60).formatted(date: .omitted, time: .shortened)
    let refused = Self.now.formatted(date: .omitted, time: .shortened)
    #expect(Wording.refusedChange(refusal) == "Added “(no title)” · made \(made) · refused \(refused) · row 3f2a…")
    #expect(refusal.reason == "The server refused it: a title can’t be blank or longer than 200 characters.")
    #expect(Wording.title(refusal.taskTitle) == "(no title)")
    #expect(Wording.rowKeys([]) == "no row key")
    #expect(Wording.rowKeys([["id": "a"], ["id": "b"]]) == "2 rows")
}
Factory Builds
appliedOutcome, queuedOutcome MutationOutcome.Applied, MutationOutcome.Queued
pendingMutation, rejectedMutation, serverError The queue's values
querySnapshot, stalledRefresh A live read's emission, and a stalled refresh
syncReport, syncStatus What a sync returns, and the status
cachedFile A Storage file in the cache
cacheMissException, mutationNotQueueableException, networkDisabledException, syncFailedException The exceptions; syncFailedException(isConnectivityFailure = true) is one isNetworkOrConnectivityFailure() accepts

Every field is a parameter, and the ones a test rarely sets have defaults. The factories that take a PostgrestResult need one built over a client with an engine, since supabase-kt's Postgrest needs one: PostgrestResult(data, Headers.Empty, supabase.postgrest).