Testing
Core principle: replace real I/O with deterministic fakes. Qora has no global state, and each QoraClient instance is isolated for deterministic test execution.
Setup
No extra packages are required for the core patterns. Add mocktail if you want to mock API classes:
dev_dependencies:
mocktail: ^0.3.0
flutter_test:
sdk: flutter
Unit Testing
For packages/qora tests (no Flutter), use QoraClient directly:
import 'package:qora/qora.dart';
import 'package:test/test.dart';
void main() {
late QoraClient client;
setUp(() {
client = QoraClient(
config: const QoraClientConfig(
defaultOptions: QoraOptions(
retryCount: 0, // disable retries for fail-fast tests
),
),
);
});
tearDown(() => client.dispose());
test('fetchQuery returns data on success', () async {
final user = await client.fetchQuery<User>(
key: const ['users', 1],
fetcher: () async => User(id: 1, name: 'Alice'),
);
expect(user.name, 'Alice');
});
test('fetchQuery throws on fetcher error', () {
expect(
() => client.fetchQuery<User>(
key: const ['users', 99],
fetcher: () async => throw Exception('not found'),
options: const QoraOptions(retryCount: 0),
),
throwsException,
);
});
test('second fetchQuery returns cached data without calling fetcher', () async {
var callCount = 0;
await client.fetchQuery<User>(
key: const ['users', 1],
fetcher: () async { callCount++; return User(id: 1, name: 'Alice'); },
options: const QoraOptions(staleTime: Duration(minutes: 5)),
);
await client.fetchQuery<User>(
key: const ['users', 1],
fetcher: () async { callCount++; return User(id: 1, name: 'Alice'); },
options: const QoraOptions(staleTime: Duration(minutes: 5)),
);
expect(callCount, 1); // cache hit on second call
});
}
Widget Testing: QoraScope Wrapper
Wrap pumpWidget with QoraScope. Pass a custom client so each test is isolated:
import 'package:flutter_test/flutter_test.dart';
import 'package:qora_flutter/qora_flutter.dart';
import 'package:qora/qora.dart';
Widget buildTestApp({
required QoraClient client,
required Widget child,
}) {
return QoraScope(
client: client,
child: MaterialApp(home: child),
);
}
void main() {
testWidgets('UserScreen shows name after fetch', (tester) async {
final client = QoraClient(
config: const QoraClientConfig(
defaultOptions: QoraOptions(retryCount: 0),
),
);
addTearDown(client.dispose);
await tester.pumpWidget(
buildTestApp(
client: client,
child: QoraBuilder<User>(
queryKey: const ['users', 1],
fetcher: () async => User(id: 1, name: 'Alice'),
builder: (context, state, _) => switch (state) {
Initial() || Loading() => const CircularProgressIndicator(),
Success(:final data) => Text(data.name),
Failure(:final error) => Text('Error: $error'),
},
),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pumpAndSettle(); // let the future resolve
expect(find.text('Alice'), findsOneWidget);
});
}
Pre-Seeding the Cache
Use setQueryData to pre-populate the cache before mounting the widget. The QoraBuilder receives Success immediately: no async needed:
testWidgets('UserScreen renders pre-seeded data without fetching', (tester) async {
final client = QoraClient();
addTearDown(client.dispose);
// Seed the cache directly: widget gets Success on first frame
client.setQueryData<User>(const ['users', 1], User(id: 1, name: 'Bob'));
await tester.pumpWidget(
buildTestApp(
client: client,
child: QoraBuilder<User>(
queryKey: const ['users', 1],
fetcher: () async => throw Exception('should not be called'),
options: const QoraOptions(staleTime: Duration(minutes: 5)),
builder: (_, state, __) =>
Text(state.dataOrNull?.name ?? 'loading'),
),
),
);
// No pump needed: data was already in cache
expect(find.text('Bob'), findsOneWidget);
});
Testing Error States
Pass a fetcher that throws and check the Failure branch:
testWidgets('shows error message when fetch fails', (tester) async {
final client = QoraClient(
config: const QoraClientConfig(
defaultOptions: QoraOptions(retryCount: 0), // fail immediately
),
);
addTearDown(client.dispose);
await tester.pumpWidget(
buildTestApp(
client: client,
child: QoraBuilder<User>(
queryKey: const ['users', 404],
fetcher: () async => throw Exception('not found'),
builder: (_, state, __) => switch (state) {
Failure(:final error) => Text('Error: $error'),
_ => const CircularProgressIndicator(),
},
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Error:'), findsOneWidget);
});
Testing PersistQoraClient
Use InMemoryStorageAdapter: it satisfies the StorageAdapter interface with no file I/O, making persistence tests deterministic and fast:
import 'package:qora/qora.dart';
import 'package:test/test.dart';
void main() {
test('data survives a simulated restart', () async {
final storage = InMemoryStorageAdapter();
await storage.init();
// ── First "launch" ────────────────────────────────────────────────────
final client1 = PersistQoraClient(
storage: storage,
persistDuration: const Duration(days: 7),
);
client1.registerSerializer<User>(
QoraSerializer(toJson: (u) => u.toJson(), fromJson: User.fromJson),
name: 'User',
);
await client1.hydrate();
await client1.fetchQuery<User>(
key: const ['user'],
fetcher: () async => User(id: 1, name: 'Alice'),
);
client1.dispose();
// ── Second "launch": same storage ────────────────────────────────────
final client2 = PersistQoraClient(
storage: storage,
persistDuration: const Duration(days: 7),
);
client2.registerSerializer<User>(
QoraSerializer(toJson: (u) => u.toJson(), fromJson: User.fromJson),
name: 'User',
);
await client2.hydrate();
// Data is in cache without any network call
final state = client2.getQueryState<User>(const ['user']);
expect(state, isA<Success<User>>());
expect((state as Success<User>).data.name, 'Alice');
client2.dispose();
});
}
Testing Optimistic Updates
Verify that rollback restores the exact pre-mutation state:
test('rollback restores previous state on failure', () async {
final client = QoraClient();
addTearDown(client.dispose);
// Seed initial data
client.setQueryData<User>(const ['user'], User(id: 1, name: 'Alice'));
final snapshot = client.getQueryData<User>(const ['user']);
// Apply optimistic update
client.setQueryData<User>(const ['user'], User(id: 1, name: 'Bob'));
expect(client.getQueryState<User>(const ['user']), isA<Success<User>>());
expect(
(client.getQueryState<User>(const ['user']) as Success<User>).data.name,
'Bob',
);
// Simulate failure: rollback
client.restoreQueryData<User>(const ['user'], snapshot);
expect(
(client.getQueryState<User>(const ['user']) as Success<User>).data.name,
'Alice',
);
});
Quick Reference
| What to test | How |
|---|---|
| Fetcher is called once (cache deduplication) | Count calls in a closure |
| Data is cached across two calls | staleTime > 0, call twice, check callCount == 1 |
| Error state reaches the widget | Fetcher throws, retryCount: 0, pumpAndSettle |
| Widget shows pre-seeded data | setQueryData before pumpWidget |
| Persistence survives restart | Two PersistQoraClient instances sharing an InMemoryStorageAdapter |
| Optimistic rollback | getQueryData → setQueryData → restoreQueryData → check state |
refetchOnWindowFocus | Don't wire FlutterLifecycleManager in tests: avoids unwanted side effects |
Related
- Persistence Guide:
PersistQoraClient,InMemoryStorageAdapter, serializers - Optimistic Updates Guide:
setQueryData,getQueryData,restoreQueryData - QoraClient API: full method reference