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
}
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.networkstaysUNKNOWN, which counts as online, until the test setsnetworkAvailabilityor callsdisableNetwork(). - 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 toClock.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
MemoryResumableCacheand Auth a session manager that keeps the session in memory. - A
CachedFile.pathnames 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).