Testing ElectricSQL and Local-First Apps: A Complete Guide

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:

  1. Shape subscriptions — does the client receive the right rows, filtered correctly?
  2. Sync correctness — do writes made offline sync back to Postgres without data loss or duplication?
  3. 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:
      - postgres

In 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:5133

Wait 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.

Read more

Start now free