Testing a local-first app when the bugs live between sessions
A server app's test suite owns the database. A local-first app's worst bugs need an old file, a second device, a lying clock or a store that vanished overnight - and each of those can be put in a test, except one.
A server-backed app tests against a world it controls. The database starts empty, the schema is the current one, the clock is the server’s clock, and there is exactly one copy of the truth. A test sets up rows, calls the code, checks the rows. When the suite is green, the interesting failures that remain are mostly about load and networks, and those belong to somebody else’s dashboard.
Local-first gives that world away. The data lives on the user’s device, in a format your app wrote months ago, next to a second copy on another device that has not synced since Tuesday, under a browser that may delete it without asking. None of that exists in a fresh test run. A suite that only ever starts from an empty store, with one replica and the current version, tests the one situation your users are least likely to be in.
The bugs that cost the most in this architecture are not inside a function. They live between sessions: between the version that wrote the file and the version reading it, between two devices, between yesterday’s store and today’s. Most of them can be pulled into an ordinary test runner. One of them cannot, and it is worth knowing which.
Keep every file you ever wrote
The first test is the cheapest and the one most often missing. Every time the storage format changes, commit a real file written by the outgoing version - a backup, an export, a serialised working copy - into the test fixtures, and never delete it. Then assert that the current code opens every one of them and reads the same records out.
A migration tested only against data the migration itself just produced proves very little. The file in the fixtures directory was written by code that no longer exists, which is exactly the situation of a user who comes back after six months. When that test fails, it fails in CI instead of on their device, where the only symptom would be an empty screen.
The mirror test matters as much: hand the current build a file stamped with a newer format version than it knows, and assert that it refuses, loudly, without writing anything. An old tab that half-reads a newer file and saves it back is a data-loss bug that no amount of forward migration can repair.
Two replicas, every order
A sync design makes one promise above all others: devices that have seen the same operations end up in the same state, whatever order they saw them in. That is a property, not an example, and it should be tested as one.
You do not need two browsers for this. Build two in-memory replicas, generate a batch of random edits on each, deliver them to each other in shuffled orders, with duplicates and with some delayed until the end, and assert that the final states are byte-for-byte equal. A property-based library such as fast-check will generate the batches and, when an order breaks convergence, shrink it down to the three or four operations that actually disagree. That shrunk case is usually the first time anyone sees the bug clearly.
Two details make this test honest. Include deletes, and include an edit to a record the other replica deleted, because that pair is where most merge rules quietly pick a winner nobody chose. And run the same check across a simulated upgrade - one replica on the old format, one on the new - since that is a normal state for a user with two devices.
Inject the clock
Any merge that consults time can be broken by time. Make the clock a dependency
rather than a call to Date.now(), then give each test replica its own: one
running ten minutes fast, one that jumps backwards, one stuck. The assertion is
the same convergence check as before. A design that is meant to survive skewed
wall clocks either does, in a test, or does not, and finding out on a laptop
whose clock was set by hand is the expensive way.
Make the store disappear
Browsers evict storage under disk pressure, after long inactivity on some platforms, and whenever the user clears site data. Your app will one day start with an empty working copy and a user who believes their data is still there.
In Node, an in-memory IndexedDB implementation such as fake-indexeddb lets you script that day: populate the store, delete the database between two steps, and start the app again. The test should assert what the user sees next - that the app notices the store is gone, says so, and restores from wherever the durable copy lives - rather than greeting them with an empty, cheerful first-run screen. The same harness can open two connections at once and check that a schema upgrade blocked by the other one is handled instead of hanging.
What still needs a real browser
Some behaviour only exists in the engine: quota errors, the real event order of
a versionchange against an open tab, what a service worker serves while the
network is down. A browser automation tool covers most of it. Playwright, for
example, can open two isolated contexts as two devices, take one of them offline
with setOffline(true), make edits on both, bring it back and wait for the two
to agree. These tests are slower and fewer, and they should check outcomes the
user would notice, not internal state.
Where the honesty line sits
One thing will not go into any suite: the browser’s own decision to evict your data. Safari’s inactivity rule, a phone’s storage cleaner, a user clearing history on a shared computer - you can simulate the result, but not the trigger, the timing or how often it happens to real people. Tests can prove the app recovers well. They cannot prove recovery is rarely needed.
That is not a reason to test less. It is the reason the durable copy outside the browser exists, and the reason the recovery path deserves the same fixtures, properties and injected clocks as the happy path. In a server app, the database team carries the hard cases. In a local-first app, the test suite is the only place they can be rehearsed before a user meets them alone.