Unit Testing Concurrent Dart Logic Without Flaky Results

By Liam O'Brien · 15 August 20267,023 views
Unit Testing Concurrent Dart Logic Without Flaky Results

Introduction: The Non-Deterministic Nightmare

In the high-stakes environment of healthtech, where we handle patient data on devices that may or may not have a stable connection, our application state is rarely linear. We rely heavily on Dart’s event loop, Isolates for heavy computation, and complex Stream transformations to ensure that clinical records are eventually consistent. However, as an open-source contributor to go_router, I’ve seen firsthand how non-deterministic asynchronous code can turn a robust test suite into a source of constant frustration. Flaky tests aren't just an inconvenience; in our field, they erode trust in the very safety mechanisms we build to ensure patient data integrity.

When you are dealing with offline-first synchronization—where a clinician might save a vital sign reading while in a basement and expect it to propagate once they hit the ward’s Wi-Fi—your logic inevitably involves concurrent operations. If your unit tests rely on real-time execution, they will eventually flake due to race conditions or unexpected thread scheduling. To solve this, we need to treat time as a dependency, not an inherent property of the execution environment. This article explores how to architect and test concurrent Dart logic without resorting to Future.delayed hacks or race-prone integration tests.

1. Decoupling Time: The Clock Abstraction

The most common cause of flaky tests in asynchronous systems is the reliance on the system clock. When your sync engine needs to retry a failed API call after a backoff period, hardcoding await Future.delayed(Duration(seconds: 5)) is a recipe for disaster. Not only does this bloat your test execution time, but it introduces non-determinism based on the CPU load of your CI runner.

Instead, we inject a Clock provider. By abstracting time, we can advance it manually in our tests. I recommend using the clock package, which is the industry standard for this pattern in the Dart ecosystem. By injecting a clock provider into your repository or service layer, you can simulate the passage of time instantly.

// The production service
class SyncService {
  final Clock _clock;
  
  SyncService({Clock clock = const Clock()}) : _clock = clock;

  Future<void> performRetry(DateTime lastAttempt) async {
    final now = _clock.now();
    if (now.difference(lastAttempt) < Duration(seconds: 5)) {
      return;
    }
    // Proceed with sync logic...
  }
}

By using this pattern, your unit tests can simulate the exact moment a retry should occur without actually waiting for the system timer to trigger. This is the first step toward making your concurrent logic deterministic.

2. Managing the Event Loop: The Test Zone

Dart’s fake_async package is the secret weapon for developers who have spent nights debugging tests that only fail on the build server. fake_async replaces the global Zone's timer functions with a mock implementation that can be triggered synchronously. This allows you to 'flush' the event queue, forcing all pending Futures and Timers to execute before you make your assertions.

When writing clinical applications, we often have complex navigation stacks managed by go_router that interact with background sync streams. If a user navigates to a patient record while the background sync is processing, we need to ensure the UI updates in the correct order. fake_async allows us to simulate these overlapping events.

void main() {
  test('Sync completes before navigation finishes', () {
    fakeAsync((async) {
      final service = SyncService();
      service.triggerBackgroundSync();
      
      // Manually advance time to allow microtasks to settle
      async.flushMicrotasks();
      
      // Assertions here will now run against the settled state
      expect(service.isSynced, isTrue);
    });
  });
}

This approach removes the ambiguity of the event loop's scheduling, ensuring that your logic is tested against a predictable flow of events rather than competing with the operating system's task scheduler.

3. Dealing with Isolates and Data Concurrency

In clinical apps, we frequently push image processing or PDF generation to a separate Isolate. Testing code that spawns isolates is notoriously difficult. The key is to abstract the Isolate communication layer using a service provider pattern. Don't test the Isolate internals themselves in a unit test; instead, test the interface that consumes the messages.

When building offline-first systems, we often use Isolates to perform heavy database reconciliation (e.g., merging local SQLite changes with remote JSON payloads). If you try to spawn an actual isolate in your unit test, you’ll encounter environment-specific errors and significantly slower build times. Instead, define a protocol: a Request object and a Response object. In your tests, you can inject a mock Isolate runner that executes the provided function immediately on the main thread.

Pro-tips for Isolate testing:

  1. Use Protocol Objects: Never pass complex objects across isolates. Use simple, serializable data structures.
  2. Synchronous Fallback: In your service interface, allow for an optional useIsolate boolean flag. Set this to false in unit tests to execute logic synchronously and avoid the overhead of the Isolate messaging system.
  3. Interface Overrides: Always code to an IsolateRunner interface, allowing you to swap the real implementation for a SynchronousRunner during test setup.

4. Conflict Resolution Strategy: Testing the 'Merge'

Offline-first architecture lives or dies by its conflict resolution strategy. When a clinician edits a patient file offline, and another user updates it on the server, we need a deterministic way to decide the 'winner'. This logic is prone to race conditions if not tested properly. I advocate for pure, side-effect-free reconciliation functions.

Your merge function should accept the local state, the server state, and the clock. By keeping this function pure, you can write thousands of 'property-based' tests using the test and matcher packages to verify that your conflict resolution logic handles every edge case—such as missing timestamps, partial updates, or connectivity interruptions—without ever needing an emulator or a real backend.

Avoid testing the Stream directly if you can help it. Instead, test the state transformation function that the stream calls. If your stream simply maps data from the network, test the mapping logic in isolation. If it performs a complex database transaction, ensure that the transaction logic is decoupled from the Stream class, making it easily testable as a standalone piece of logic.

5. Troubleshooting: Why Your Tests Still Fail

If you've implemented fake_async and Clock injection but are still seeing flakes, investigate these three common culprits:

  1. Unclosed Streams: If a service emits events even after the test has concluded, these events can leak into subsequent tests, creating cross-test interference. Ensure you use tearDown to close all StreamControllers and Sinks.
  2. Floating Promises: Using an async function without awaiting it is a classic mistake. If you fire off an async operation without an await, the execution of that operation escapes your test's controlled Zone, leading to non-deterministic side effects.
  3. Static State: Clinical apps often use singletons for the database or the go_router configuration. Static state is the enemy of parallel test execution. Avoid singletons; use dependency injection (e.g., get_it) and recreate your state for every test case.

Conclusion: Building Confidence

Testing concurrent Dart code is a discipline of control. By using Clock injection to manipulate time, fake_async to deterministicize the event loop, and protocol-based interfaces to mock background Isolates, we can write test suites that are both fast and reliable. For those of us working on clinical software, this rigor is non-negotiable.

Remember, your tests are documentation. When a colleague joins your project to help with the offline-first sync engine, they will read your tests to understand the expected behavior under edge-case connectivity conditions. If those tests are flaky, your colleague will learn to ignore them, and that is how bugs enter production. Keep your logic pure, your dependencies injectable, and your event loops under your explicit control. Only then can we guarantee the safety and integrity of the clinical systems we build.

Comments

No comments yet. Be the first!

Sign in to leave a comment.