← Back to blog
Offline-First Flutter · Part 11 of 12
October 7, 202611 min read

Offline-First Flutter, Part 11: Testing the Sync Engine

FlutterDartOffline-First

Testing the Sync Engine

This is Part 11 of the Offline-First Flutter series — the final content part before the mastery bank. We've built a complete sync engine. Now we make it trustworthy, because of all the code in this series, the sync engine is the one you cannot afford to get wrong:

Sync bugs are the worst kind: they're rare, timing-dependent, and destroy real user data that may exist nowhere else (Part 1). They almost never reproduce on your machine and almost always show up in production, days later, as "my notes disappeared." The only defense is to simulate the chaos deliberately, in tests — conflicts, timeouts, crashes mid-sync, two devices racing — before shipping.

The good news: we designed the engine to be testable (interfaces, idempotency, a local DB you can fake). Let's exploit that.

Stack: Drift (in-memory) + flutter_test/test + Riverpod, Flutter 3.38 / Dart 3.12. Connects to Flutter testing habits and the whole engine.


The analogy: the flight simulator

Analogy — pilots train in a simulator. You don't teach a pilot to handle engine failure by failing an engine mid-flight with passengers aboard. You use a simulator: reproduce the emergency safely, on the ground, as many times as you like. Testing a sync engine is building that simulator — you make the network fail, force a conflict, kill the process mid-push, and assert the engine recovers — all without risking a single real user's notes.

The mindset shift: don't hope conflicts and failures are handled — manufacture them on demand and prove it. Every edge case from Parts 6–10 becomes a test you can run in milliseconds.


Foundation 1: an in-memory database

Drift runs entirely in memory for tests — fast, isolated, no files:

import 'package:drift/native.dart';

AppDatabase newTestDb() => AppDatabase(NativeDatabase.memory());

test('createNote marks the row dirty', () async {
  final db = newTestDb();
  final repo = DriftNoteRepository(db);

  final note = await repo.createNote(title: 'Test');

  final row = await (db.select(db.notes)..where((n) => n.id.equals(note.id)))
      .getSingle();
  expect(row.isDirty, isTrue);        // pending push
  expect(row.version, 0);
  addTearDown(db.close);
});

A real SQLite engine in memory means your tests exercise the actual queries, constraints, and reactive streams — not a mock of them. Every test gets a fresh DB, so they're isolated and parallel-safe.


Foundation 2: a fake API you fully control

The other half is a fake server implementing your NoteApi interface (Part 6) — an in-memory store you can poke to simulate anything: conflicts, failures, latency, other devices' edits.

class FakeNoteApi implements NoteApi {
  final Map<String, RemoteNote> _server = {};   // the "server" you control
  bool failNextPush = false;                     // simulate failures on demand
  Duration latency = Duration.zero;

  @override
  Future<PushResult> pushNote(Note note) async {
    await Future.delayed(latency);
    if (failNextPush) { failNextPush = false; throw const SocketException('boom'); }

    final existing = _server[note.id];
    if (existing != null && existing.version != note.version) {
      // client edited from a stale base → conflict (optimistic concurrency, Part 8)
      return PushResult(existing.version, conflict: true);
    }
    final newVersion = (existing?.version ?? -1) + 1;
    _server[note.id] = note.toRemote(version: newVersion);
    return PushResult(newVersion);
  }

  @override
  Future<PullResponse> pullChanges({String? cursor, int limit = 200}) async {
    // return everything after `cursor`, plus deletions — your call to script
    ...
  }

  // Test helpers to act as "another device":
  void serverEdit(String id, {String? title, String? body}) { ... }
  void serverDelete(String id) { ... }
}

Why a hand-written fake beats a mock library here: the fake is a real, stateful model of the server, so it can enforce version checks and remember state across calls — letting you test genuine multi-step scenarios (push, then "another device" edits, then pull → conflict). Mocks that just return canned values can't model the behavior sync depends on.


Testing push

The push invariants from Part 6, each a test:

test('successful push clears the dirty flag', () async {
  final note = await repo.createNote(title: 'A');
  await engine.pushDirtyNotes();
  final row = await repo.getById(note.id);
  expect(row.isDirty, isFalse);              // cleared on ack
});

test('failed push leaves the row dirty for retry', () async {
  await repo.createNote(title: 'A');
  api.failNextPush = true;
  await engine.pushDirtyNotes();             // throws internally, caught
  final dirty = await repo.dirtyRows();
  expect(dirty, hasLength(1));               // still pending
});

test('push is idempotent (retry does not duplicate)', () async {
  final note = await repo.createNote(title: 'A');
  await engine.pushDirtyNotes();
  await engine.pushDirtyNotes();             // run again
  expect(api.serverNoteCount, 1);            // one note, not two
});

test('edit during push keeps the row dirty (compare-and-clear)', () async {
  final note = await repo.createNote(title: 'A');
  api.latency = const Duration(milliseconds: 50);
  final pushing = engine.pushDirtyNotes();    // starts pushing version 0
  await repo.updateNote(note.id, title: 'A2'); // bumps to version 1 mid-push
  await pushing;
  final row = await repo.getById(note.id);
  expect(row.isDirty, isTrue);               // v1 never synced → stays dirty
});

That last test is gold: it reproduces the dirty-during-push race (Part 6) deterministically by injecting latency, then asserts no edit is lost. This is the kind of bug you can't catch by clicking around — but it's trivial to pin down in a simulator.


Testing pull and merge

test('pull applies remote changes to a clean local row', () async {
  final note = await repo.createNote(title: 'A');
  await engine.pushDirtyNotes();             // now clean & on server
  api.serverEdit(note.id, title: 'A-from-laptop');
  await engine.pull();
  expect((await repo.getById(note.id)).title, 'A-from-laptop'); // overwritten safely
});

test('pull does NOT clobber a dirty local row (conflict)', () async {
  final note = await repo.createNote(title: 'A');
  await engine.pushDirtyNotes();
  await repo.updateNote(note.id, body: 'local edit'); // now dirty again
  api.serverEdit(note.id, title: 'remote title');     // server also changed
  await engine.pull();
  final row = await repo.getById(note.id);
  expect(row.body, 'local edit');            // local edit preserved (merged, Part 8)
});

test('remote deletion removes a clean local row', () async {
  final note = await repo.createNote(title: 'A');
  await engine.pushDirtyNotes();
  api.serverDelete(note.id);
  await engine.pull();
  expect(await repo.getById(note.id), isNull);
});

test('pull advances the cursor', () async {
  await engine.pull();
  expect(await repo.cursor(), isNotNull);
});

The second test is the most important test in the suite: it proves the merge's isDirty guard protects un-synced offline work. If this test ever goes red, you're about to ship silent data loss.


Testing conflict resolution

Drive the strategies from Part 8 directly:

test('field-level merge keeps both non-overlapping edits', () async {
  // base: {title: 'T', body: 'B'} synced on both sides
  // local changes body, remote changes title
  final merged = resolveFieldMerge(
    base:   note(title: 'T', body: 'B'),
    local:  note(title: 'T', body: 'B-local'),
    remote: note(title: 'T-remote', body: 'B'),
  );
  expect(merged.title, 'T-remote');          // only remote changed title
  expect(merged.body, 'B-local');            // only local changed body
});

test('delete-vs-edit: edit wins (resurrect)', () async {
  final note = await repo.createNote(title: 'A');
  await engine.pushDirtyNotes();
  await repo.updateNote(note.id, body: 'precious'); // edited locally
  api.serverDelete(note.id);                          // deleted remotely
  await engine.pull();
  final row = await repo.getById(note.id);
  expect(row, isNotNull);                    // resurrected
  expect(row!.body, 'precious');             // content preserved
});

Simulating flaky networks

Toggle failures and latency to prove resilience over a sequence of attempts:

test('recovers after transient failures', () async {
  await repo.createNote(title: 'A');
  api.failNextPush = true;
  await engine.syncNow();                    // attempt 1 fails
  expect(await repo.dirtyCount(), 1);
  await engine.syncNow();                    // attempt 2 succeeds
  expect(await repo.dirtyCount(), 0);        // converged
});

Model the whole failure lifecycle, not just one call: fail, retry, succeed, and assert the system converges. You can extend the fake to fail intermittently, time out, or return 400 vs 503 to test the transient-vs-permanent triage.


The crown jewel: two-device convergence

The real promise of offline-first is eventual consistency — any set of devices, after enough syncs, converges to the same state. Simulate two devices sharing one fake server:

test('two devices converge after syncing', () async {
  final server = FakeServer();
  final deviceA = engineFor(newTestDb(), server);
  final deviceB = engineFor(newTestDb(), server);

  // Both edit offline:
  final id = await deviceA.repo.createNote(title: 'from A');
  await deviceB.repo.createNote(title: 'from B');

  // Sync both, twice (push+pull each), to let changes propagate:
  for (var i = 0; i < 2; i++) { await deviceA.syncNow(); await deviceB.syncNow(); }

  final a = await deviceA.repo.allTitles();
  final b = await deviceB.repo.allTitles();
  expect(a.toSet(), equals(b.toSet()));      // identical state → converged
});

This single test validates the entire architecture's central promise. If two simulated devices, after a few sync rounds, hold identical data, your push/pull/conflict/idempotency machinery composes correctly. Make this test a permanent fixture — it's your guardrail against any future change quietly breaking convergence.


Don't forget migration tests

From Part 3: migrations touch irreplaceable data, so test them with Drift's schema verifier — especially the oldest → newest jump, asserting existing rows survive with sensible defaults. A migration test is cheap insurance against an expensive data-loss incident.


Practice Challenges

Challenge 1 — Fake vs mock. Why build a stateful FakeNoteApi instead of using a mock that returns canned values?

Show solution

Sync correctness depends on server behavior over multiple calls (version checks, remembering state, returning deltas/deletions). A stateful fake models that behavior, enabling real multi-step scenarios (push → other device edits → pull → conflict). A canned mock can't represent state across calls, so it can't test what actually matters.

Challenge 2 — The must-pass test. Which single test most directly guards against silent data loss, and what does it assert?

Show solution

The "pull does not clobber a dirty local row" test: after a local edit makes a row dirty and the server also changes it, pull must preserve the local edit (conflict-resolve, not overwrite). If it fails, un-synced offline work is being destroyed.

Challenge 3 — Reproduce the race. How do you deterministically test the dirty-during-push race?

Show solution

Inject latency into the fake's pushNote, start the push, then edit the row before the push resolves (bumping its version), and await the push. Assert the row is still dirty (compare-and-clear kept the un-pushed edit). The injected delay makes the race deterministic.

Challenge 4 — Convergence. What does a two-device convergence test prove that single-call tests don't?

Show solution

That the whole system reaches eventual consistency — push, pull, conflict resolution, and idempotency compose so that independent devices end with identical state after syncing. Single-call tests verify pieces; convergence verifies the architecture's central promise end to end.

Challenge 5 — In-memory DB. Why test against NativeDatabase.memory() rather than mocking the database?

Show solution

It runs the real SQLite engine (actual queries, constraints, transactions, reactive streams) in memory — fast and isolated — so tests exercise true database behavior instead of a mock's approximation. Bugs in queries/migrations surface in tests, not production.


Questions to test yourself

Q1 (basic). Why is the sync engine the most important thing to test in an offline-first app?

Show answer

Its bugs are rare, timing-dependent, and destroy irreplaceable user data, and they rarely reproduce manually. Deliberately simulating failures/conflicts/races in tests is the only reliable way to catch them before they hit production.

Q2 (basic). What two fakes form the foundation of sync tests?

Show answer

An in-memory Drift database (NativeDatabase.memory() — real SQLite, isolated) and a stateful fake API implementing NoteApi that you control to simulate conflicts, failures, latency, and other devices.

Q3 (intermediate). How do you test that a failed push retries successfully?

Show answer

Make the fake fail the next push, run syncNow() and assert the row stays dirty; then let the fake succeed, run syncNow() again, and assert it becomes clean (converged). This tests the whole transient-failure → retry → success lifecycle.

Q4 (intermediate). How do you test idempotency of push?

Show answer

Create a note, run pushDirtyNotes() twice, and assert the fake server holds one note (not two). The UUID idempotency key + upsert endpoint must make the repeat push a no-op/update rather than a duplicate.

Q5 (intermediate). What does the two-device convergence test validate, and why keep it permanently?

Show answer

That two independent devices sharing a server converge to identical state after syncing — the architecture's eventual-consistency promise, validating that push/pull/conflict/idempotency compose. Keep it as a permanent guardrail: if a future change breaks convergence, this test goes red immediately.

Q6 (advanced). Why are migration tests (the oldest→newest jump) essential in offline-first specifically?

Show answer

Because migrations transform the local source of truth, which may hold un-synced data that exists nowhere else (Part 3). A broken migration causes irreversible data loss. Testing the long jump with Drift's schema verifier proves existing rows survive with correct defaults across every supported upgrade path — cheap insurance against an expensive incident.


Wrapping up

  • The sync engine is the riskiest code in the app — test it like a flight simulator: manufacture conflicts, failures, races, and crashes on demand.
  • Build on two fakes: an in-memory Drift DB (real SQLite) and a stateful fake API you script.
  • Unit-test the invariants: push clears dirty on ack / stays dirty on failure / is idempotent / survives the dirty-during-push race; pull applies clean updates, never clobbers dirty rows, handles deletions, advances the cursor; conflict strategies merge/resurrect correctly.
  • Simulate flaky networks and assert convergence over a failure→retry→success lifecycle.
  • The two-device convergence test validates the whole architecture's eventual-consistency promise — keep it forever. And test migrations' long jump to prevent data loss.

In Part 12, the finale, comes the 100-question mastery bank — hints and solutions, 10 coding mini-exercises, and a capstone that assembles the entire FieldNotes sync engine from scratch.