Replicache Testing Guide: Mutations, Rebase, Conflict Resolution, and Offline Testing

Replicache Testing Guide: Mutations, Rebase, Conflict Resolution, and Offline Testing

Replicache is the local-first sync framework that made optimistic UI updates on top of server-authoritative state mainstream. Its core bet: every mutation applies optimistically on the client, gets rebased when the server responds, and conflict resolution happens through a deterministic rebase algorithm. Testing this correctly requires understanding the full mutation lifecycle — and writing tests that exercise the parts most developers skip.

This guide covers comprehensive Replicache testing from unit tests on mutators to integration tests for the full sync cycle.

The Replicache Mental Model

Before writing tests, internalize the key states:

  1. Pending mutations: Applied optimistically, not yet confirmed by server
  2. Committed mutations: Confirmed by server, part of canonical state
  3. Rebased mutations: Pending mutations re-applied on top of new server state after a pull
  4. Conflict: Divergence between optimistic and server state (Replicache resolves via rebase, not explicit conflict handling)

Every test you write should exercise at least one of these transitions.

Setting Up the Test Environment

npm install replicache @replicache/test-helper jest @testing-library/jest-dom

jest.config.js:

module.exports = {
  testEnvironment: "jsdom",
  setupFilesAfterFramework: ["@testing-library/jest-dom"],
  transform: {
    "^.+\\.(ts|tsx|js|jsx)$": "babel-jest"
  },
  moduleNameMapper: {
    "^replicache$": "<rootDir>/node_modules/replicache/out/replicache.js"
  }
};

test-setup.ts:

import { Replicache, MutatorDefs } from "replicache";

export function createTestReplicache<M extends MutatorDefs>(
  mutators: M,
  options: Partial<{
    licenseKey: string;
    name: string;
  }> = {}
) {
  return new Replicache({
    name: options.name ?? `test-${Math.random().toString(36).slice(2)}`,
    licenseKey: options.licenseKey ?? "test-license-key",
    mutators,
    // Use in-memory storage for tests
    pushURL: undefined,
    pullURL: undefined,
  });
}

export async function closeReplicache(rep: Replicache<any>) {
  await rep.close();
}

Testing Basic Mutators

Mutators are pure functions — they're the easiest part to test:

import { WriteTransaction } from "replicache";

// Define your mutators
const mutators = {
  async createTodo(tx: WriteTransaction, args: { id: string; text: string; completed: boolean }) {
    await tx.set(`todo/${args.id}`, args);
  },
  
  async updateTodo(tx: WriteTransaction, args: { id: string; text?: string; completed?: boolean }) {
    const existing = await tx.get(`todo/${args.id}`);
    if (!existing) throw new Error(`Todo ${args.id} not found`);
    await tx.set(`todo/${args.id}`, { ...existing, ...args });
  },
  
  async deleteTodo(tx: WriteTransaction, args: { id: string }) {
    await tx.del(`todo/${args.id}`);
  },
  
  async toggleTodo(tx: WriteTransaction, args: { id: string }) {
    const todo = await tx.get<{ id: string; text: string; completed: boolean }>(`todo/${args.id}`);
    if (!todo) throw new Error(`Todo ${args.id} not found`);
    await tx.set(`todo/${args.id}`, { ...todo, completed: !todo.completed });
  }
};

describe("Replicache Mutators", () => {
  let rep: Replicache<typeof mutators>;
  
  beforeEach(() => {
    rep = createTestReplicache(mutators);
  });
  
  afterEach(async () => {
    await closeReplicache(rep);
  });
  
  test("createTodo stores todo with correct key", async () => {
    await rep.mutate.createTodo({ id: "todo-1", text: "Buy milk", completed: false });
    
    const result = await rep.query(tx => tx.get("todo/todo-1"));
    
    expect(result).toEqual({ id: "todo-1", text: "Buy milk", completed: false });
  });
  
  test("updateTodo merges partial updates", async () => {
    await rep.mutate.createTodo({ id: "todo-2", text: "Read book", completed: false });
    await rep.mutate.updateTodo({ id: "todo-2", text: "Read two books" });
    
    const result = await rep.query(tx => tx.get<any>("todo/todo-2"));
    
    expect(result.text).toBe("Read two books");
    expect(result.completed).toBe(false);  // Unchanged
  });
  
  test("deleteTodo removes the entry", async () => {
    await rep.mutate.createTodo({ id: "todo-3", text: "Delete me", completed: false });
    await rep.mutate.deleteTodo({ id: "todo-3" });
    
    const result = await rep.query(tx => tx.get("todo/todo-3"));
    
    expect(result).toBeUndefined();
  });
  
  test("toggleTodo flips completed status", async () => {
    await rep.mutate.createTodo({ id: "todo-4", text: "Toggle me", completed: false });
    await rep.mutate.toggleTodo({ id: "todo-4" });
    
    const result = await rep.query(tx => tx.get<any>("todo/todo-4"));
    expect(result.completed).toBe(true);
    
    // Toggle back
    await rep.mutate.toggleTodo({ id: "todo-4" });
    const result2 = await rep.query(tx => tx.get<any>("todo/todo-4"));
    expect(result2.completed).toBe(false);
  });
  
  test("updateTodo throws when todo not found", async () => {
    await expect(
      rep.mutate.updateTodo({ id: "nonexistent", text: "Update" })
    ).rejects.toThrow("not found");
  });
});

Testing Optimistic Updates

The whole point of Replicache is instant UI response before server confirmation:

import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";

describe("Optimistic updates", () => {
  test("mutation is visible immediately before push completes", async () => {
    // Track when the subscription callback fires
    const updates: any[][] = [];
    
    const rep = createTestReplicache(mutators);
    
    // Subscribe to all todos
    const unsubscribe = rep.subscribe(
      tx => tx.scan({ prefix: "todo/" }).values().toArray(),
      (todos) => updates.push(todos)
    );
    
    // Fire mutation
    const mutationPromise = rep.mutate.createTodo({
      id: "opt-1",
      text: "Optimistic todo",
      completed: false
    });
    
    // The subscription should have fired with the new todo
    // BEFORE the push is complete
    await new Promise(resolve => setTimeout(resolve, 0));
    
    const hasOptimisticUpdate = updates.some(
      todos => todos.some((t: any) => t.id === "opt-1")
    );
    expect(hasOptimisticUpdate).toBe(true);
    
    await mutationPromise;
    unsubscribe();
    await closeReplicache(rep);
  });
  
  test("multiple mutations apply in order", async () => {
    const rep = createTestReplicache(mutators);
    
    // Apply several mutations without awaiting each
    await Promise.all([
      rep.mutate.createTodo({ id: "order-1", text: "First", completed: false }),
      rep.mutate.createTodo({ id: "order-2", text: "Second", completed: false }),
      rep.mutate.createTodo({ id: "order-3", text: "Third", completed: false }),
    ]);
    
    const todos = await rep.query(async tx => {
      const entries = await tx.scan({ prefix: "todo/" }).entries().toArray();
      return entries.map(([, v]) => v);
    });
    
    expect(todos).toHaveLength(3);
    const ids = todos.map((t: any) => t.id);
    expect(ids).toContain("order-1");
    expect(ids).toContain("order-2");
    expect(ids).toContain("order-3");
    
    await closeReplicache(rep);
  });
});

Testing the Puller (Pull Response Handling)

The puller is where rebase and conflict resolution happen. Test it by mocking your pull endpoint:

import { PullerResult, PullResponseV1 } from "replicache";

function makePullResponse(
  lastMutationID: number,
  patch: Array<{op: "put" | "del" | "clear", key?: string, value?: any}>
): PullResponseV1 {
  return {
    cookie: `cookie-${Date.now()}`,
    lastMutationIDChanges: { "client-1": lastMutationID },
    patch
  };
}

describe("Pull response handling", () => {
  test("server state overwrites optimistic state after pull", async () => {
    let pullCount = 0;
    
    const rep = new Replicache({
      name: `test-${Math.random().toString(36).slice(2)}`,
      licenseKey: "test-key",
      mutators,
      // Mock pull endpoint
      puller: async () => {
        pullCount++;
        return {
          response: makePullResponse(0, [
            {
              op: "put",
              key: "todo/server-todo",
              value: { id: "server-todo", text: "From server", completed: true }
            }
          ]),
          httpRequestInfo: { httpStatusCode: 200, errorMessage: "" }
        };
      }
    });
    
    // Trigger a pull
    await rep.pull();
    
    const serverTodo = await rep.query(tx => tx.get("todo/server-todo"));
    expect(serverTodo).toEqual({
      id: "server-todo",
      text: "From server",
      completed: true
    });
    
    await closeReplicache(rep);
  });
  
  test("pending mutations are rebased on server state", async () => {
    // Client creates todo-A optimistically
    // Server pull includes todo-B (from another client)
    // After pull: both todo-A (rebased) and todo-B should exist
    
    const serverData = new Map<string, any>();
    serverData.set("todo/from-server", {
      id: "from-server",
      text: "Added by another client",
      completed: false
    });
    
    const rep = new Replicache({
      name: `test-${Math.random().toString(36).slice(2)}`,
      licenseKey: "test-key",
      mutators,
      puller: async (req) => {
        const patch = Array.from(serverData.entries()).map(([key, value]) => ({
          op: "put" as const,
          key,
          value
        }));
        
        return {
          response: makePullResponse(0, patch),
          httpRequestInfo: { httpStatusCode: 200, errorMessage: "" }
        };
      }
    });
    
    // Create todo optimistically
    await rep.mutate.createTodo({
      id: "local-todo",
      text: "Created locally",
      completed: false
    });
    
    // Pull server state
    await rep.pull();
    
    // Both todos should exist
    const localTodo = await rep.query(tx => tx.get("todo/local-todo"));
    const serverTodo = await rep.query(tx => tx.get("todo/from-server"));
    
    expect(localTodo).toBeDefined();
    expect((localTodo as any).text).toBe("Created locally");
    expect(serverTodo).toBeDefined();
    expect((serverTodo as any).text).toBe("Added by another client");
    
    await closeReplicache(rep);
  });
  
  test("server wins over client on same key conflict", async () => {
    // Client and server both modify the same todo
    // Server state is authoritative — client's pending mutation gets rebased
    // but if mutation reads stale data, behavior depends on your mutator logic
    
    const serverVersion = {
      id: "conflict-todo",
      text: "Server version",
      completed: true
    };
    
    const rep = new Replicache({
      name: `test-${Math.random().toString(36).slice(2)}`,
      licenseKey: "test-key",
      mutators,
      puller: async () => ({
        response: makePullResponse(1, [  // lastMutationID=1 means server has seen our mutation
          {
            op: "put",
            key: "todo/conflict-todo",
            value: serverVersion
          }
        ]),
        httpRequestInfo: { httpStatusCode: 200, errorMessage: "" }
      })
    });
    
    // Both set the same todo
    await rep.mutate.createTodo({
      id: "conflict-todo",
      text: "Client version",
      completed: false
    });
    
    await rep.pull();
    
    // After pull with lastMutationID=1, our mutation is acknowledged
    // Server's value takes effect
    const result = await rep.query(tx => tx.get<any>("todo/conflict-todo"));
    
    // Server value should win since lastMutationID says server processed our mutation
    expect(result.text).toBe("Server version");
    
    await closeReplicache(rep);
  });
});

Testing Offline Scenarios

Local-first means offline-first. Test it explicitly:

describe("Offline behavior", () => {
  test("mutations work without network access", async () => {
    const rep = new Replicache({
      name: `test-offline-${Math.random().toString(36).slice(2)}`,
      licenseKey: "test-key",
      mutators,
      // No pushURL/pullURL — fully offline
    });
    
    // Should work offline
    await rep.mutate.createTodo({ id: "offline-1", text: "Offline todo", completed: false });
    await rep.mutate.createTodo({ id: "offline-2", text: "Another offline", completed: false });
    
    const todos = await rep.query(async tx => {
      const entries = await tx.scan({ prefix: "todo/" }).entries().toArray();
      return entries.map(([, v]) => v as any);
    });
    
    expect(todos).toHaveLength(2);
    expect(todos.map(t => t.id)).toContain("offline-1");
    
    await closeReplicache(rep);
  });
  
  test("mutations queue up while offline and apply when online", async () => {
    let pushCallCount = 0;
    const pushedMutations: any[] = [];
    
    const rep = new Replicache({
      name: `test-queue-${Math.random().toString(36).slice(2)}`,
      licenseKey: "test-key",
      mutators,
      pusher: async (req) => {
        pushCallCount++;
        pushedMutations.push(...req.mutations);
        return { httpRequestInfo: { httpStatusCode: 200, errorMessage: "" } };
      }
    });
    
    // Apply mutations "offline" (push will fail or not be called yet)
    await rep.mutate.createTodo({ id: "queued-1", text: "First", completed: false });
    await rep.mutate.createTodo({ id: "queued-2", text: "Second", completed: false });
    await rep.mutate.toggleTodo({ id: "queued-1" });
    
    // Trigger push
    await rep.push();
    
    expect(pushCallCount).toBeGreaterThan(0);
    
    await closeReplicache(rep);
  });
  
  test("data persists across Replicache instances (same name)", async () => {
    const instanceName = `persist-test-${Math.random().toString(36).slice(2)}`;
    
    // Instance 1: write data
    const rep1 = new Replicache({
      name: instanceName,
      licenseKey: "test-key",
      mutators,
    });
    
    await rep1.mutate.createTodo({ id: "persist-1", text: "Persisted todo", completed: false });
    await rep1.close();
    
    // Instance 2: read data (same name = same storage)
    const rep2 = new Replicache({
      name: instanceName,
      licenseKey: "test-key",
      mutators,
    });
    
    const result = await rep2.query(tx => tx.get("todo/persist-1"));
    expect(result).toBeDefined();
    expect((result as any).text).toBe("Persisted todo");
    
    await rep2.close();
  });
});

Testing Subscriptions and Reactive Queries

describe("Subscriptions", () => {
  test("subscription fires on relevant key changes", async () => {
    const rep = createTestReplicache(mutators);
    const receivedValues: any[] = [];
    
    const unsubscribe = rep.subscribe(
      tx => tx.get("todo/sub-test"),
      (value) => receivedValues.push(value)
    );
    
    // Wait for initial subscription callback (undefined for non-existent key)
    await new Promise(resolve => setTimeout(resolve, 10));
    
    await rep.mutate.createTodo({ id: "sub-test", text: "Subscribe me", completed: false });
    
    // Wait for subscription to fire
    await new Promise(resolve => setTimeout(resolve, 10));
    
    // Should have received: undefined (initial), then the todo
    expect(receivedValues.length).toBeGreaterThanOrEqual(2);
    const lastValue = receivedValues[receivedValues.length - 1];
    expect(lastValue).toMatchObject({ id: "sub-test", text: "Subscribe me" });
    
    unsubscribe();
    await closeReplicache(rep);
  });
  
  test("subscription does not fire for unrelated key changes", async () => {
    const rep = createTestReplicache(mutators);
    let callCount = 0;
    
    const unsubscribe = rep.subscribe(
      tx => tx.get("todo/specific-key"),
      () => callCount++
    );
    
    // Wait for initial
    await new Promise(resolve => setTimeout(resolve, 10));
    const initialCount = callCount;
    
    // Mutate a DIFFERENT key
    await rep.mutate.createTodo({ id: "different-key", text: "Different", completed: false });
    await new Promise(resolve => setTimeout(resolve, 10));
    
    // Should not have fired again for the unrelated change
    expect(callCount).toBe(initialCount);
    
    unsubscribe();
    await closeReplicache(rep);
  });
});

CI/CD Configuration

name: Replicache Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      
      - run: npm ci
      
      - name: Run Replicache unit tests
        run: npm test -- --testPathPattern="replicache"
        env:
          REPLICACHE_LICENSE_KEY: "test-key"
      
      - name: Run sync integration tests
        run: npm test -- --testPathPattern="sync" --testTimeout=30000

Monitoring Replicache in Production

Unit tests verify mutator correctness. But in production, sync issues manifest as subtle state divergence between clients — a todo that shows complete on one device but incomplete on another.

HelpMeTest lets you write end-to-end tests that simulate real multi-client scenarios and verify sync correctness. The 24/7 monitoring at 5-minute intervals catches sync regressions that only appear under real network conditions.

Summary

Replicache testing has four critical surfaces:

  1. Mutators — pure functions, easy to test, test all CRUD operations
  2. Optimistic updates — verify mutations visible before push confirmation
  3. Pull/rebase — mock the puller, test server-wins scenarios
  4. Offline — test mutation queuing, persistence across instances

The rebase behavior is the trickiest to test correctly. Focus on scenarios where client and server modify the same key — Replicache's server-authoritative model means server always wins when lastMutationID indicates the server has processed the client's mutations.

Read more

Start now free