Shard ships its test utilities behind a separate entry point so they never get linked into production builds. The main library (package:shard/shard.dart) does not import this file—you opt in only from your tests.
import 'package:shard/shard_test.dart';
This gives you ShardTester, the declarative shardTest helper, in-memory fakes (FakeStateStorage, FakeCacheService), and MockShardObserver—plus the error types they throw.
ShardTester<T> wraps a shard and records every state it emits, so you can assert against the emission history. Construct it with the shard under test, drive the shard, then assert.
test('counter increments', () async {
final tester = ShardTester(CounterShard());
tester.shard.increment();
tester.shard.increment();
await tester.expectStates([1, 2]);
await tester.expectNoMoreStates();
await tester.dispose();
});
shard.state at construction is not recorded—only emissions that happen after you create the tester. Build the tester first, then trigger changes.The surface:
recordedStates – every state emitted since construction, in order (unmodifiable).lastState – the most recent emission (or null), and hasStates for whether anything was recorded.expectStates(expected, {timeout, exactMatch}) – async, must be awaited. Prefix-match by default (checks the first N emissions); pass exactMatch: true to also require the total count to equal expected.length. Waits up to timeout for enough states, then throws ShardAssertionError on mismatch.expectNoMoreStates({window}) – async; asserts no new states arrive within window (default 100 ms).waitForNext({timeout}) – waits for and returns the next emission (not the current state).waitFor(predicate, {timeout}) – returns the current state if it already matches, otherwise the first future emission that matches. Throws ShardTimeoutError on timeout.clear() – empties recordedStates without unsubscribing.dispose() – async; removes the listener. Safe to call multiple times, or after the shard itself is disposed.To avoid forgetting dispose(), use the static scope helper—it creates a tester, runs your body, and disposes the tester in a finally:
test('counter increments (scoped)', () async {
await ShardTester.scope(CounterShard(), (tester) async {
tester.shard.increment();
await tester.expectStates([1]);
});
});
shardTest is a declarative helper for the common "build, act, assert" flow. It is a plain function—wrap it in your own test(...). It builds the shard, runs act, asserts the emissions (prefix-match), then disposes both the tester and the shard for you.
test('increments by 1', () async {
await shardTest<CounterShard, int>(
build: () => CounterShard(),
act: (shard) async => shard.increment(),
expect: [1],
);
});
For shards that persist or cache, swap in the in-memory fakes. Both support failure injection, latency simulation, and call inspection, so you can exercise error paths and assert exactly what was read or written.
// In-memory StateStorage with failure injection, latency, and inspection:
final storage = FakeStateStorage();
await storage.save('user', '{"id":1}');
storage.rawValue('user'); // '{"id":1}'
await storage.delete('user');
storage.rawValue('user'); // null
// Force the next load to fail, to exercise onLoadError paths:
storage.loadError = Exception('disk read failed');
// In-memory CacheService for cache-dependent code:
final cache = FakeCacheService()..seed('product_1', product);
cache.readError = Exception('cache offline'); // exercise error fallbacks
loadError / saveError / deleteError on FakeStateStorage, or readError / writeError / deleteError / clearError on FakeCacheService, to make that operation throw.*Delay fields (e.g. loadDelay, saveDelay, readDelay, writeDelay) inject a Duration so you can test loading states and races.loadCount/saveCount/deleteCount, readCount/writeCount/deleteCount) plus recorded keys (savedKeys/deletedKeys, readKeys/writeKeys) let you assert behavior. FakeStateStorage also exposes rawValue, hasKey, and an unmodifiable data map; seed without counting a save via seed(key, value).FakeStateStorage implements the 2.0-required delete operation, alongside save and load. Use clear() to wipe data or reset() to wipe everything (data, counts, and injected errors).MockShardObserver records global lifecycle events—state changes and errors—across all shards. Install it for the duration of a test with the static scope helper, which sets it as Shard.observer and restores the previous observer (including null) in a finally block, so tests never pollute each other.
test('reports a change', () async {
await MockShardObserver.scope((observer) async {
final shard = CounterShard();
addTearDown(shard.dispose);
shard.increment();
expect(observer.changesFor(shard), hasLength(1));
});
});
scope body is async and must be awaited. The recorded changes live on recordedChanges (or changesFor(shard))—there is no changes property.Inspection:
recordedChanges / recordedErrors – all observed changes and errors globally.changesFor(shard) / errorsFor(shard) – filter to a single shard; changesOfType<T>() / errorsOfType<S>() filter by type.ObservedChange (with .shard, .previousState, .currentState) and ObservedError (with .shard, .error, .stackTrace).clear() resets the recorded lists.The ShardTester assertions throw ShardAssertionError on a failed expectation and ShardTimeoutError when an async wait (waitFor, expectStates) times out.
For debounce, throttle, or any timer-driven logic, the package's own tests use the fake_async package for deterministic time control—add it as a dev dependency. It pairs well with expectNoMoreStates and waitFor for advancing virtual time and asserting what did (or didn't) emit.
fake_async does not ship with shard. Add it to your own dev_dependencies if you need it.Next: FutureShard.