Testing ElectricSQL and Local-First Apps: A Complete Guide
Your local-first app feels instant. Writes land immediately, the UI updates without a spinner, and sync happens invisibly in the background. Until it doesn't. A conflict resolver fires incorrectly, a shape subscription misses rows, or the client state diverges from Postgres after a partition. Nobody sees it in development because your dev environment never goes offline and never has concurrent writers.
This is the testing problem specific to local-first architectures, and ElectricSQL — which syncs Postgres tables to SQLite on the client in real time — has a distinct set of failure modes that standard integration tests don't catch.
What You're Actually Testing
ElectricSQL sits between Postgres and your client SQLite. The failure surface has three layers:
- Shape subscriptions — does the client receive the right rows, filtered correctly?
- Sync correctness — do writes made offline sync back to Postgres without data loss or duplication?
- Conflict resolution — when two clients write the same row concurrently, does the LWW (last-write-wins) strategy do what you expect?
Standard database tests don't exercise any of these. You need a test harness that can control network connectivity, simulate concurrent writers, and inspect both the Postgres state and the client SQLite state.
Setting Up the Test Environment
ElectricSQL requires a running Electric sync service and a Postgres instance. Use Docker Compose for tests:
# docker-compose.test.yml
version: "3.8"
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: electric_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5433:5432"
command: postgres -c wal_level=logical
electric:
image: electricsql/electric:latest
environment:
DATABASE_URL: postgresql://postgres:password@postgres:5432/electric_test
AUTH_MODE: insecure
ports:
- "5133:5133"
depends_on:
- postgresIn your test setup:
import { ElectricDatabase, electrify } from "electric-sql/node";
import { schema } from "../src/db/schema";
import { Client } from "pg";
const ELECTRIC_URL = "http://localhost:5133";
async function createTestClient(clientId: string) {
const db = new ElectricDatabase(`:memory:`);
const electric = await electrify(db, schema, {
url: ELECTRIC_URL,
auth: { token: "insecure" },
});
return { db, electric };
}
async function getPgClient() {
const client = new Client({
host: "localhost",
port: 5433,
database: "electric_test",
user: "postgres",
password: "password",
});
await client.connect();
return client;
}Testing Shape Subscriptions
Shapes are ElectricSQL's unit of sync — a filtered view of a Postgres table streamed to the client. Test that shapes deliver the right rows:
describe("Shape Subscriptions", () => {
let electric: Electric;
let pg: Client;
beforeEach(async () => {
({ electric } = await createTestClient("test-client-1"));
pg = await getPgClient();
await pg.query("TRUNCATE items RESTART IDENTITY CASCADE");
});
afterEach(async () => {
await electric.close();
await pg.end();
});
it("syncs only rows matching shape filter", async () => {
// Write rows to Postgres directly
await pg.query(`
INSERT INTO items (id, user_id, name) VALUES
('item-1', 'user-a', 'Apple'),
('item-2', 'user-b', 'Banana'),
('item-3', 'user-a', 'Cherry')
`);
// Subscribe to shape filtered by user_id
const shape = await electric.db.items.sync({
where: { user_id: "user-a" },
});
await shape.synced;
const localItems = await electric.db.items.findMany();
// Should only have user-a's items
expect(localItems).toHaveLength(2);
expect(localItems.map((i) => i.id)).toEqual(
expect.arrayContaining(["item-1", "item-3"])
);
expect(localItems.find((i) => i.id === "item-2")).toBeUndefined();
});
it("delivers new rows that match shape filter after initial sync", async () => {
const shape = await electric.db.items.sync({
where: { user_id: "user-a" },
});
await shape.synced;
// Insert a matching row after subscription
await pg.query(
`INSERT INTO items (id, user_id, name) VALUES ('item-4', 'user-a', 'Date')`
);
// Wait for sync to deliver the new row
await waitForSync(() =>
electric.db.items.findFirst({ where: { id: "item-4" } })
);
const item = await electric.db.items.findFirst({
where: { id: "item-4" },
});
expect(item).not.toBeNull();
expect(item!.name).toBe("Date");
});
});
async function waitForSync<T>(
check: () => Promise<T | null>,
timeoutMs = 5000
): Promise<T> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const result = await check();
if (result !== null) return result;
await new Promise((r) => setTimeout(r, 100));
}
throw new Error("waitForSync timed out");
}Testing Write Path and Sync Back
Local writes must round-trip to Postgres. Test the full write path:
describe("Write Sync", () => {
it("syncs local write to Postgres", async () => {
const { electric } = await createTestClient("writer");
const pg = await getPgClient();
await electric.db.items.sync();
await new Promise((r) => setTimeout(r, 500)); // let initial sync settle
// Write locally
await electric.db.items.create({
data: { id: "new-item", user_id: "user-test", name: "Test Item" },
});
// Verify it appears in Postgres
await waitForPg(
pg,
`SELECT * FROM items WHERE id = 'new-item'`,
(rows) => rows.length === 1
);
const { rows } = await pg.query(
`SELECT * FROM items WHERE id = 'new-item'`
);
expect(rows[0].name).toBe("Test Item");
await electric.close();
await pg.end();
});
it("syncs offline writes when connection restores", async () => {
const { electric, db } = await createTestClient("offline-writer");
const pg = await getPgClient();
await electric.db.items.sync();
await new Promise((r) => setTimeout(r, 500));
// Simulate offline: disconnect Electric sync
// Note: ElectricSQL buffers writes locally and syncs on reconnect
await electric.disconnect();
// Write while offline
await electric.db.items.create({
data: { id: "offline-item", user_id: "user-x", name: "Offline Write" },
});
// Item should be in local SQLite immediately
const localItem = await electric.db.items.findFirst({
where: { id: "offline-item" },
});
expect(localItem).not.toBeNull();
// Item should NOT be in Postgres yet
const { rows: beforeReconnect } = await pg.query(
`SELECT * FROM items WHERE id = 'offline-item'`
);
expect(beforeReconnect).toHaveLength(0);
// Reconnect
await electric.connect();
// Wait for sync
await waitForPg(
pg,
`SELECT * FROM items WHERE id = 'offline-item'`,
(rows) => rows.length === 1
);
const { rows } = await pg.query(
`SELECT * FROM items WHERE id = 'offline-item'`
);
expect(rows[0].name).toBe("Offline Write");
await electric.close();
await pg.end();
});
});
async function waitForPg(
pg: Client,
query: string,
predicate: (rows: any[]) => boolean,
timeoutMs = 5000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const { rows } = await pg.query(query);
if (predicate(rows)) return;
await new Promise((r) => setTimeout(r, 100));
}
throw new Error(`waitForPg timed out: ${query}`);
}Testing Conflict Resolution
ElectricSQL uses last-write-wins (LWW) based on timestamp. Test concurrent writes:
describe("Conflict Resolution", () => {
it("last writer wins on concurrent updates", async () => {
const pg = await getPgClient();
// Pre-seed the row
await pg.query(
`INSERT INTO items (id, user_id, name, updated_at) VALUES ('conflict-item', 'user-a', 'Original', NOW())`
);
// Create two clients
const [clientA, clientB] = await Promise.all([
createTestClient("client-a"),
createTestClient("client-b"),
]);
await Promise.all([
clientA.electric.db.items.sync(),
clientB.electric.db.items.sync(),
]);
// Wait for both to receive the seed row
await Promise.all([
waitForSync(() =>
clientA.electric.db.items.findFirst({
where: { id: "conflict-item" },
})
),
waitForSync(() =>
clientB.electric.db.items.findFirst({
where: { id: "conflict-item" },
})
),
]);
// Both write concurrently — Client B writes 100ms after Client A
await clientA.electric.db.items.update({
where: { id: "conflict-item" },
data: { name: "Client A Update" },
});
await new Promise((r) => setTimeout(r, 100));
await clientB.electric.db.items.update({
where: { id: "conflict-item" },
data: { name: "Client B Update" },
});
// Wait for both to sync to Postgres
await waitForPg(
pg,
`SELECT name FROM items WHERE id = 'conflict-item'`,
(rows) => rows[0]?.name !== "Original"
);
// LWW: Client B wrote last, should win
const { rows } = await pg.query(
`SELECT name FROM items WHERE id = 'conflict-item'`
);
expect(rows[0].name).toBe("Client B Update");
await clientA.electric.close();
await clientB.electric.close();
await pg.end();
});
});Testing React Integration
If you use @electric-sql/react, test the useShape hook:
import { render, screen, waitFor } from "@testing-library/react";
import { useShape } from "@electric-sql/react";
import { ELECTRIC_URL } from "../config";
function ItemList({ userId }: { userId: string }) {
const { data: items, isLoading } = useShape({
url: `${ELECTRIC_URL}/v1/shape`,
table: "items",
where: `user_id = '${userId}'`,
});
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
describe("ItemList", () => {
it("renders items from shape subscription", async () => {
const pg = await getPgClient();
await pg.query(`
INSERT INTO items (id, user_id, name) VALUES ('r1', 'user-render', 'Rendered Item')
`);
render(<ItemList userId="user-render" />);
await waitFor(
() => expect(screen.getByText("Rendered Item")).toBeInTheDocument(),
{ timeout: 5000 }
);
await pg.end();
});
});Testing Optimistic Updates
Local-first apps update the UI before the server confirms. Test that optimistic state is consistent:
describe("Optimistic Updates", () => {
it("item appears locally before Postgres confirms", async () => {
const { electric } = await createTestClient("optimistic-client");
await electric.db.items.sync();
const writePromise = electric.db.items.create({
data: { id: "optimistic-1", user_id: "user-y", name: "Optimistic" },
});
// Check local state BEFORE awaiting the write (fire and check)
// In ElectricSQL, writes are applied locally synchronously
const localItem = await electric.db.items.findFirst({
where: { id: "optimistic-1" },
});
// Wait for write to complete
await writePromise;
expect(localItem).not.toBeNull();
expect(localItem!.name).toBe("Optimistic");
await electric.close();
});
});CI Configuration
Run Electric and Postgres in CI before tests:
# .github/workflows/test.yml
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: electric_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
ports:
- 5433:5432
electric:
image: electricsql/electric:latest
env:
DATABASE_URL: postgresql://postgres:password@postgres:5432/electric_test
AUTH_MODE: insecure
ports:
- 5133:5133Wait for Electric to be healthy before running tests using a readiness check in your global test setup.
What to Test, What to Skip
Test:
- Shape subscription delivers correct filtered rows
- New rows matching the shape filter arrive after initial sync
- Local writes appear immediately in SQLite
- Offline writes sync to Postgres on reconnect
- Concurrent writes resolve to the expected winner
- Delete operations propagate both directions
Skip:
- Internal Electric sync protocol behavior
- Postgres WAL replication internals
- SQLite storage format
The Electric sync service handles correctness guarantees internally. Your tests verify that your application-level assumptions — which rows you subscribe to, how you handle conflicts, what offline behavior users expect — are met.
Replicache and ZeroSync: Same Principles
If you use Replicache or Zero instead of ElectricSQL, the same testing approach applies:
- Test the mutation pipeline (local write → server push endpoint → server state)
- Test subscription delivery (server state change → client pull)
- Test conflict resolution with your custom rebase logic
- Test offline queue flushing
Each framework has its own test helpers — Replicache provides TestContext, Zero has ZeroContext for testing — but the conceptual surface is identical.
Local-first testing is more work than standard API testing, but the bugs it catches are the worst kind: silent data divergence that users discover days later. Build this harness once and run it on every PR.