Testing your app
Most of a Civility app tests headlessly in Deno: swap the IndexedDB backends for in-memory ones and the model layer runs anywhere, and anything that crosses the wire can run against a real CivServer started in the test process. Only the auth popup, the service worker, and IndexedDB persistence genuinely need a browser.
This is the workflow the reference apps use — static checks, model tests, integration tests against a live server — plus the gotchas that cost the most time.
Related: Store · Sync · Blobs. Server internals:
@civility/hono.
1. The layers #
| Layer | What it covers | How | Needs |
| ————— | ———————————————–– | –––––––––––––––––––––––––– | ––––––– |
| Static | Types, format, lint, version skew | deno task test | nothing |
| Model | Your store CRUD, transforms, merge behavior | in-memory Store/BlobStore in a Deno test | nothing |
| Integration | Sync, CivShare, blobs, encryption — the real wire | real client vs. a createServer in the test process | Deno |
| Browser | <civ-sync-input> popup, service worker, IDB | manual / Playwright | a real browser |
Push everything you can down to the cheap layers — the whole sync and share round-trip sits at Model and Integration.
2. Static checks #
Every app ships a test task; keep it as the first gate:
// deno.json
"tasks": {
"test": "deno fmt && deno lint && deno check ./www/index.ts"
}
deno task test
This is also your version-skew tripwire: Civility packages move together, so bumping one and not the rest fails here rather than at runtime. Bump the whole @civility/* set together and re-run.
3. Model tests with in-memory backends #
The app-store pattern puts all your logic behind a Store. The only thing tying it to the browser is the storage backend — so make the backend injectable and your model runs in Deno:
// www/store/app.ts
import { type DocumentStore, Store } from '@civility/store'
import { IDBStorage } from '@civility/store/idb'
export function createApp(
backend: DocumentStore<unknown> = new IDBStorage({ dbName: APP_NAME }),
) {
const store = new Store(backend, {
collections: ['items'],
versions: [/* … */],
})
return new App(store)
}
export default createApp() // production: IndexedDB
Tests pass an in-memory backend instead — @civility/store/memory and @civility/blobs/memory implement the same interfaces with no IndexedDB:
// www/store/app.test.ts
import { assertEquals } from '@std/assert'
import { MemoryStorage } from '@civility/store/memory'
import { MemoryBlobStorage } from '@civility/blobs/memory'
import { BlobStore } from '@civility/blobs'
import { createApp } from './app.ts'
Deno.test('addItem stores and lists', async () => {
const app = createApp(new MemoryStorage())
await app.addItem('hello')
assertEquals(app.items.length, 1)
})
Deno.test('images round-trip through the blob store', async () => {
const blobs = new BlobStore(new MemoryBlobStorage())
const hash = await blobs.put(
new Blob([bytes], { type: 'image/png' }),
'image/png',
)
assertEquals((await blobs.get(hash))?.type, 'image/png')
})
Because store writes are CRDT changes, you can also test merge behavior deterministically here — apply two divergent change sets and assert the converged state — with no network and no server.
4. A local CivServer for integration tests #
For anything that crosses the wire (sync, CivShare, encryption, blobs), run a real server in the test process. createServer from @civility/hono returns a Hono app; serve it on an ephemeral port and point your client at it. Add @civility/hono to your import map (deno add jsr:@civility/hono) — it’s imported only from tests and never reaches your www/ bundle.
// test/server.ts
import { createServer } from '@civility/hono'
import { createFsObjectStorage } from '@civility/hono/fs'
export async function startTestServer() {
const dir = await Deno.makeTempDir()
const app = await createServer({
dialect: 'sqlite',
dbPath: `${dir}/civility.db`, // or ':memory:' for a throwaway server
// ⚠️ Blobs need object storage. Without it, blob uploads 404 — and a failed
// upload throws mid-cycle and aborts the WHOLE sync, so no changes land
// either. Always wire storage if your app syncs blobs.
objectStorage: createFsObjectStorage({ basePath: `${dir}/blobs` }),
})
const server = Deno.serve({ port: 0, onListen() {} }, app.fetch)
return {
url: `http://localhost:${server.addr.port}`,
async stop() {
await server.shutdown()
await Deno.remove(dir, { recursive: true })
},
}
}
SQLite + local-fs blobs is zero-config: migrations run inside createServer, :memory: needs no files, and CIVILITY_AUTH_SECRET is optional (the server warns and generates an ephemeral one). Use civ api start only for a long-lived server for manual/browser testing — see §8.
5. Authenticate without the popup #
Tests can’t click through <civ-sync-input>’s popup, so mint a token over HTTP and set it directly. On a fresh server with no email adapter, verification is off and signup returns a token immediately:
// test/auth.ts
export async function signUp(url: string, appId: string): Promise<string> {
// Email AND username must be unique per server — derive both per run.
const uniq = `${Date.now()}-${Math.random().toString(36).slice(2)}`
const res = await fetch(`${url}/api/v1/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: `${uniq}@test.dev`,
name: uniq,
password: 'password123',
}),
})
const token = (await res.json()).data.token
// Register the app id — what the popup's "Create & Authorize" step does.
await fetch(`${url}/api/v1/apps`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ id: appId, name: 'Test App' }),
})
return token
}
Then, in the client, skip the popup and set the token straight onto the CloudStore:
cloud.connect(url)
cloud.setToken(token) // instead of the <civ-sync-input> flow
cloud.startSync()
6. Drive sync deterministically #
Sync is a background loop (default 30s). In a test you don’t wait for it — force one cycle and await it:
await cloud.sync() // one full push+pull cycle, now
Two habits keep these tests honest:
-
Watch for errors.
cloud.sync()resolves even when a phase fails, and the background loop swallows errors. Attach a listener so a broken push/upload surfaces instead of a silent 404:cloud.addEventListener( 'error', (e) => console.error('sync error:', e.error?.message), )(This is exactly how the blob-storage gotcha from §4 shows itself: an
Upload failed: Not Founderror that would otherwise be invisible.) -
Isolate per run. App ids are global and persisted server-side — the same id can’t be re-registered under a second user. Give each test a fresh server (temp DB, as in
startTestServer) or a unique app id so runs don’t collide.
Worked example — a sync round-trip
Two in-memory clients as two devices: write on A, sync up, pull down on B.
import { assertEquals } from '@std/assert'
import { startTestServer } from './test/server.ts'
import { signUp } from './test/auth.ts'
import { makeClient } from './test/client.ts' // CloudStore(MemoryStorage) + collections
Deno.test('items sync across devices', async () => {
const server = await startTestServer()
try {
const APP_ID = 'me.example.myapp'
const token = await signUp(server.url, APP_ID)
const a = makeClient(APP_ID)
a.cloud.connect(server.url), a.cloud.setToken(token), a.cloud.startSync()
await a.items.set('1', { id: '1', text: 'hello' })
await a.cloud.sync()
const b = makeClient(APP_ID) // same user+app, fresh local store
b.cloud.connect(server.url), b.cloud.setToken(token), b.cloud.startSync()
await b.cloud.sync()
await b.items.preload()
assertEquals(b.items.value?.get('1')?.text, 'hello')
a.cloud.dispose(), b.cloud.dispose()
} finally {
await server.stop()
}
})
7. Testing CivShare #
A share round-trip (see the CivShare recipe) is the same harness plus the anonymous read side: publish with the real client, then fetch as a visitor would — no session — via getShare:
import { CloudStore, getShare } from '@civility/cloud'
// publisher side (authenticated client). A `share:` block is all it takes —
// CloudStore declares and syncs the reserved `_civility.shares` collection.
const cloud = new CloudStore(new MemoryStorage(), {
collections: ['items'],
versions: [/* … */],
cloud: { appId: APP_ID },
share: { appShareUrl: url },
})
cloud.connect(url), cloud.setToken(token), cloud.startSync()
const { id } = await cloud.share.publish({ collection: 'items', docId: '1' })
await cloud.sync() // flush the share record so the server activates it
// visitor side — anonymous, no token:
const doc = await getShare(APP_ID, id, { server: url })
assertEquals(doc.text, 'hello')
// an image referenced by the shared doc, served publicly by hash:
const blobUrl = `${url}/api/v1/shares/${APP_ID}/${id}/blob/${hash}`
assertEquals((await fetch(blobUrl)).status, 200)
Cover the full lifecycle: not-shared → 404, published → 200 + doc/blob, revoked → 404 again. Treat the server as untrusted on the read side exactly as your render page should — validate getShare’s result against your own schema (Schema.parse(doc)) before using it.
8. Manual E2E against civ api start #
For clicking through the real PWA — popup auth, offline shell, IndexedDB across reloads — run a persistent server:
export CIVILITY_AUTH_SECRET=$(civ api gen-secret)
civ api start --port 3848 --db sqlite --blobs fs \
--db-path ./.data/civility.db --blob-path ./.data/blobs
Then civ start your app, open Settings, and connect <civ-sync-input> to http://localhost:3848 — the popup will offer Create & Authorize the first time to register your app id. CORS is open by default, so the app on its own dev port reaches the server with no extra config.
9. What still needs a browser #
The layers above cover your logic and the whole client↔server protocol. These have no headless equivalent — verify them manually, or with Playwright:
- The
<civ-sync-input>popup —window.open+ cross-originpostMessage. - The service worker — offline shell, precache, update flow (
worker.ts). - IndexedDB persistence — that data actually survives a reload / cold start.
- Version upgrades — a store built by an older release still opens after a schema bump.
Next #
- Store — the local state and the injectable-backend pattern.
- Sync — the client you drive in integration tests.
- Blobs — why blob storage must be wired on the test server.
- CivShare recipe — the publish/read flow §7 tests.
- App overview — the shape of an app the layers map onto.