skills/local-first/references/testing.md

17 KiB

title description tags
Testing Local-First Applications Testing strategies for local-first apps covering conflict resolution, sync engine mocking, offline scenarios, multi-client convergence, and React component testing
testing
vitest
conflict-resolution
offline
sync
mock
crdt
indexeddb
multi-client
snapshot
electric
integration

Testing Challenges

Challenge What Makes It Different Testing Strategy
Async Sync Data arrives over time, not in a single request Mock streams with controlled emissions
Eventual Consistency Clients may see different states temporarily Assert convergence, not intermediate state
Offline States App must function without network Simulate network loss, verify queue
Conflict Resolution Concurrent edits produce divergent states Property-based testing for merge functions
Multi-Client Multiple clients editing the same data simultaneously Deterministic two-client test harness
IndexedDB Browser storage API unavailable in Node Use fake-indexeddb polyfill
Time Dependency LWW and retry logic depend on timestamps Control time with vi.useFakeTimers

Unit Testing Conflict Resolution

LWW Merge Correctness

import { describe, expect, it } from 'vitest';

type LWWRecord<T> = { value: T; updatedAt: number };

function lwwMerge<T>(local: LWWRecord<T>, remote: LWWRecord<T>): LWWRecord<T> {
  return remote.updatedAt > local.updatedAt ? remote : local;
}

describe('lwwMerge', () => {
  it('picks the record with the later timestamp', () => {
    const local: LWWRecord<string> = { value: 'local', updatedAt: 100 };
    const remote: LWWRecord<string> = { value: 'remote', updatedAt: 200 };
    expect(lwwMerge(local, remote)).toEqual(remote);
  });

  it('keeps local when timestamps are equal', () => {
    const local: LWWRecord<string> = { value: 'local', updatedAt: 100 };
    const remote: LWWRecord<string> = { value: 'remote', updatedAt: 100 };
    expect(lwwMerge(local, remote)).toEqual(local);
  });

  it('is idempotent', () => {
    const a: LWWRecord<string> = { value: 'a', updatedAt: 100 };
    const b: LWWRecord<string> = { value: 'b', updatedAt: 200 };
    expect(lwwMerge(a, b)).toEqual(lwwMerge(lwwMerge(a, b), b));
  });
});

Field-Level Merge with Property-Based Testing

import { describe, expect, it } from 'vitest';
import fc from 'fast-check';

type MergeableRecord<T> = { value: T; fieldTimestamps: Record<string, number> };

function fieldMerge<T extends Record<string, unknown>>(
  local: MergeableRecord<T>,
  remote: MergeableRecord<T>,
): MergeableRecord<T> {
  const merged = { ...local.value };
  const timestamps = { ...local.fieldTimestamps };
  for (const key of Object.keys(remote.value)) {
    if (
      (remote.fieldTimestamps[key] ?? 0) > (local.fieldTimestamps[key] ?? 0)
    ) {
      (merged as Record<string, unknown>)[key] = remote.value[key];
      timestamps[key] = remote.fieldTimestamps[key];
    }
  }
  return { value: merged as T, fieldTimestamps: timestamps };
}

describe('fieldMerge properties', () => {
  const recordArb = fc.record({
    value: fc.record({ title: fc.string(), done: fc.boolean() }),
    fieldTimestamps: fc.record({
      title: fc.nat({ max: 10000 }),
      done: fc.nat({ max: 10000 }),
    }),
  });

  it('is commutative when timestamps differ', () => {
    fc.assert(
      fc.property(recordArb, recordArb, (a, b) => {
        expect(fieldMerge(a, b).value).toEqual(fieldMerge(b, a).value);
      }),
    );
  });

  it('preserves both edits when fields have different winners', () => {
    const local = {
      value: { title: 'updated', done: false },
      fieldTimestamps: { title: 200, done: 50 },
    };
    const remote = {
      value: { title: 'original', done: true },
      fieldTimestamps: { title: 50, done: 200 },
    };
    const result = fieldMerge(local, remote);
    expect(result.value.title).toBe('updated');
    expect(result.value.done).toBe(true);
  });
});

Mocking Sync Engines

Mock ElectricSQL ShapeStream

type ShapeMessage = {
  headers: { operation?: string; control?: string };
  key?: string;
  value?: Record<string, unknown>;
  offset?: string;
};

type Subscriber = (messages: ShapeMessage[]) => void;

class MockShapeStream {
  subscribers: Subscriber[] = [];

  subscribe(callback: Subscriber): () => void {
    this.subscribers.push(callback);
    return () => {
      this.subscribers = this.subscribers.filter((s) => s !== callback);
    };
  }

  emit(messages: ShapeMessage[]): void {
    for (const sub of this.subscribers) sub(messages);
  }

  emitInsert(key: string, value: Record<string, unknown>): void {
    this.emit([{ headers: { operation: 'insert' }, key, value }]);
  }

  emitUpdate(key: string, value: Record<string, unknown>): void {
    this.emit([{ headers: { operation: 'update' }, key, value }]);
  }

  emitDelete(key: string): void {
    this.emit([{ headers: { operation: 'delete' }, key }]);
  }

  emitUpToDate(): void {
    this.emit([{ headers: { control: 'up-to-date' } }]);
  }
}

class MockShape<T extends Record<string, unknown>> {
  private data = new Map<string, T>();
  private listeners: Array<(data: Map<string, T>) => void> = [];

  constructor(stream: MockShapeStream) {
    stream.subscribe((messages) => {
      for (const msg of messages) {
        if (
          msg.headers.operation === 'insert' ||
          msg.headers.operation === 'update'
        ) {
          this.data.set(msg.key!, msg.value as T);
        }
        if (msg.headers.operation === 'delete') this.data.delete(msg.key!);
      }
      for (const listener of this.listeners) listener(new Map(this.data));
    });
  }

  subscribe(callback: (data: Map<string, T>) => void): () => void {
    this.listeners.push(callback);
    return () => {
      this.listeners = this.listeners.filter((l) => l !== callback);
    };
  }

  get currentValue(): Map<string, T> {
    return new Map(this.data);
  }
}

Using Mocks in Tests

import { beforeEach, describe, expect, it } from 'vitest';

describe('todo sync', () => {
  let stream: MockShapeStream;
  let shape: MockShape<{ id: string; title: string; completed: boolean }>;

  beforeEach(() => {
    stream = new MockShapeStream();
    shape = new MockShape(stream);
  });

  it('materializes inserts into current state', () => {
    stream.emitInsert('"todos"/"1"', {
      id: '1',
      title: 'Buy milk',
      completed: false,
    });
    stream.emitInsert('"todos"/"2"', {
      id: '2',
      title: 'Walk dog',
      completed: false,
    });
    expect(shape.currentValue.size).toBe(2);
  });

  it('applies updates to existing records', () => {
    stream.emitInsert('"todos"/"1"', {
      id: '1',
      title: 'Buy milk',
      completed: false,
    });
    stream.emitUpdate('"todos"/"1"', {
      id: '1',
      title: 'Buy milk',
      completed: true,
    });
    expect(shape.currentValue.get('"todos"/"1"')?.completed).toBe(true);
  });

  it('removes deleted records', () => {
    stream.emitInsert('"todos"/"1"', {
      id: '1',
      title: 'Buy milk',
      completed: false,
    });
    stream.emitDelete('"todos"/"1"');
    expect(shape.currentValue.size).toBe(0);
  });
});

Testing Offline Scenarios

Write Queue with Offline Simulation

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

type QueuedWrite = {
  id: string;
  url: string;
  method: string;
  body: string;
  retries: number;
};

class WriteQueue {
  private queue: QueuedWrite[] = [];
  private online = true;

  setOnline(online: boolean): void {
    this.online = online;
    if (online) void this.drain();
  }

  async enqueue(write: Omit<QueuedWrite, 'retries'>): Promise<void> {
    this.queue.push({ ...write, retries: 0 });
    if (this.online) await this.drain();
  }

  async drain(): Promise<void> {
    const pending = [...this.queue];
    this.queue = [];
    for (const write of pending) {
      try {
        const res = await fetch(write.url, {
          method: write.method,
          body: write.body,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
      } catch {
        if (write.retries < 3)
          this.queue.push({ ...write, retries: write.retries + 1 });
      }
    }
  }

  get pending(): number {
    return this.queue.length;
  }
}

describe('WriteQueue', () => {
  let queue: WriteQueue;

  beforeEach(() => {
    queue = new WriteQueue();
    vi.stubGlobal('fetch', vi.fn());
  });

  afterEach(() => vi.restoreAllMocks());

  it('queues writes while offline', async () => {
    queue.setOnline(false);
    await queue.enqueue({
      id: '1',
      url: '/api/todos',
      method: 'POST',
      body: '{}',
    });
    expect(queue.pending).toBe(1);
    expect(fetch).not.toHaveBeenCalled();
  });

  it('drains queue on reconnect', async () => {
    vi.mocked(fetch).mockResolvedValue(new Response('ok', { status: 200 }));
    queue.setOnline(false);
    await queue.enqueue({
      id: '1',
      url: '/api/todos',
      method: 'POST',
      body: '{}',
    });
    queue.setOnline(true);
    await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
    expect(queue.pending).toBe(0);
  });

  it('drops writes after 3 retries', async () => {
    vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
    await queue.enqueue({
      id: '1',
      url: '/api/todos',
      method: 'POST',
      body: '{}',
    });
    await queue.drain();
    await queue.drain();
    await queue.drain();
    expect(queue.pending).toBe(0);
  });
});

Multi-Client Convergence Testing

import { describe, expect, it } from 'vitest';

type Todo = { id: string; title: string; completed: boolean };
type LWWRecord<T> = { value: T; updatedAt: number };

class MockClient {
  state = new Map<string, LWWRecord<Todo>>();

  write(id: string, value: Todo, timestamp: number): void {
    this.state.set(id, { value, updatedAt: timestamp });
  }

  sync(remote: MockClient): void {
    for (const [id, remoteRecord] of remote.state) {
      const local = this.state.get(id);
      if (!local || remoteRecord.updatedAt > local.updatedAt) {
        this.state.set(id, remoteRecord);
      }
    }
  }

  get(id: string): Todo | undefined {
    return this.state.get(id)?.value;
  }
}

describe('multi-client convergence', () => {
  it('converges after concurrent edits', () => {
    const clientA = new MockClient();
    const clientB = new MockClient();
    clientA.write('1', { id: '1', title: 'Buy milk', completed: false }, 100);
    clientB.write(
      '1',
      { id: '1', title: 'Buy almond milk', completed: false },
      200,
    );
    clientA.sync(clientB);
    clientB.sync(clientA);
    expect(clientA.get('1')).toEqual(clientB.get('1'));
    expect(clientA.get('1')?.title).toBe('Buy almond milk');
  });

  it('preserves independent writes from both clients', () => {
    const clientA = new MockClient();
    const clientB = new MockClient();
    clientA.write('1', { id: '1', title: 'Task A', completed: true }, 300);
    clientB.write('2', { id: '2', title: 'Task B', completed: false }, 250);
    clientA.sync(clientB);
    clientB.sync(clientA);
    expect(clientA.get('1')).toEqual(clientB.get('1'));
    expect(clientA.get('2')).toEqual(clientB.get('2'));
  });
});

Testing React Components with useShape

import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';

vi.mock('@electric-sql/react', () => ({ useShape: vi.fn() }));
import { useShape } from '@electric-sql/react';

type Todo = { id: string; title: string; completed: boolean };

function TodoList() {
  const { data, isLoading, isError, error } = useShape<Todo>({
    url: 'http://localhost:3000/v1/shape',
    params: { table: 'todos' },
  });
  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error: {error?.message}</div>;
  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

describe('TodoList', () => {
  afterEach(cleanup);

  it('shows loading state during initial sync', () => {
    vi.mocked(useShape).mockReturnValue({
      data: [],
      isLoading: true,
      isError: false,
      error: undefined,
      lastSyncedAt: undefined,
    });
    render(<TodoList />);
    expect(screen.getByText('Loading...')).toBeDefined();
  });

  it('renders synced todos', () => {
    vi.mocked(useShape).mockReturnValue({
      data: [
        { id: '1', title: 'Buy milk', completed: false },
        { id: '2', title: 'Walk dog', completed: true },
      ],
      isLoading: false,
      isError: false,
      error: undefined,
      lastSyncedAt: new Date(),
    });
    render(<TodoList />);
    expect(screen.getByText('Buy milk')).toBeDefined();
    expect(screen.getByText('Walk dog')).toBeDefined();
  });

  it('shows error on sync failure', () => {
    vi.mocked(useShape).mockReturnValue({
      data: [],
      isLoading: false,
      isError: true,
      error: new Error('Connection refused'),
      lastSyncedAt: undefined,
    });
    render(<TodoList />);
    expect(screen.getByText('Error: Connection refused')).toBeDefined();
  });
});

Vitest Patterns

IndexedDB Setup with fake-indexeddb

import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

class TodoStore {
  private dbPromise: Promise<IDBDatabase>;

  constructor(dbName: string) {
    this.dbPromise = new Promise((resolve, reject) => {
      const req = indexedDB.open(dbName, 1);
      req.onupgradeneeded = () =>
        req.result.createObjectStore('todos', { keyPath: 'id' });
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
  }

  async put(todo: { id: string; title: string }): Promise<void> {
    const db = await this.dbPromise;
    return new Promise((resolve, reject) => {
      const tx = db.transaction('todos', 'readwrite');
      tx.objectStore('todos').put(todo);
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }

  async getAll(): Promise<Array<{ id: string; title: string }>> {
    const db = await this.dbPromise;
    return new Promise((resolve, reject) => {
      const req = db
        .transaction('todos', 'readonly')
        .objectStore('todos')
        .getAll();
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
  }

  close(): void {
    this.dbPromise.then((db) => db.close());
  }
}

describe('TodoStore', () => {
  let store: TodoStore;

  beforeEach(() => {
    store = new TodoStore(`test-${Date.now()}`);
  });

  afterEach(() => store.close());

  it('persists and retrieves todos', async () => {
    await store.put({ id: '1', title: 'Buy milk' });
    await store.put({ id: '2', title: 'Walk dog' });
    const todos = await store.getAll();
    expect(todos).toHaveLength(2);
  });
});

Time Control for Retry Logic

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

function retryWithBackoff(
  fn: () => Promise<void>,
  maxRetries: number,
): Promise<void> {
  let attempt = 0;
  async function execute(): Promise<void> {
    try {
      await fn();
    } catch {
      attempt++;
      if (attempt >= maxRetries) throw new Error('Max retries exceeded');
      await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
      return execute();
    }
  }
  return execute();
}

describe('retryWithBackoff', () => {
  beforeEach(() => vi.useFakeTimers());
  afterEach(() => vi.useRealTimers());

  it('retries with exponential backoff', async () => {
    let calls = 0;
    const fn = vi.fn(async () => {
      calls++;
      if (calls < 3) throw new Error('Fail');
    });
    const promise = retryWithBackoff(fn, 5);
    await vi.advanceTimersByTimeAsync(2000);
    await vi.advanceTimersByTimeAsync(4000);
    await promise;
    expect(fn).toHaveBeenCalledTimes(3);
  });
});

Snapshot Testing CRDT State

import { describe, expect, it } from 'vitest';

type GCounter = Record<string, number>;

function increment(counter: GCounter, nodeId: string): GCounter {
  return { ...counter, [nodeId]: (counter[nodeId] ?? 0) + 1 };
}

function merge(a: GCounter, b: GCounter): GCounter {
  const result: GCounter = { ...a };
  for (const [node, count] of Object.entries(b)) {
    result[node] = Math.max(result[node] ?? 0, count);
  }
  return result;
}

describe('GCounter snapshot', () => {
  it('matches expected state after concurrent increments', () => {
    let nodeA: GCounter = {};
    let nodeB: GCounter = {};
    nodeA = increment(increment(nodeA, 'A'), 'A');
    nodeB = increment(increment(increment(nodeB, 'B'), 'B'), 'B');
    expect(merge(nodeA, nodeB)).toMatchInlineSnapshot(`
      {
        "A": 2,
        "B": 3,
      }
    `);
  });

  it('merge is idempotent', () => {
    const a: GCounter = { A: 3, B: 1 };
    const b: GCounter = { A: 1, B: 4 };
    expect(merge(a, b)).toEqual(merge(merge(a, b), b));
  });
});