| 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));
});
});