Comprehensive testing approach for ReCursor, a Flutter app with WebSocket connections and Claude Code integrations.
/ E2E \ patrol - full user journeys on real devices
/----------\
/ Integration \ Local WS server + integration_test
/----------------\
/ Widget Tests \ flutter_test widget tester + mock providers
/----------------------\
/ Unit Tests \ flutter_test + mockito/mocktail
/--------------------------\
Tools: flutter_test, mockito or mocktail
// Create a StreamController to simulate server messages
final controller = StreamController<dynamic>();
final mockChannel = MockWebSocketChannel(controller.stream);
// Inject via Riverpod override
final container = ProviderContainer(overrides: [
webSocketProvider.overrideWithValue(mockChannel),
]);
// Simulate server messages
controller.add('{"type": "response", "data": "Hello"}');
// Assert with stream matchers
expectLater(
service.messages,
emitsInOrder([isA<AgentResponse>()]),
);- Mock WebSocket with
StreamController<dynamic>, not Mockito directly on streams. - Use
thenAnswer(notthenReturn) for anything returning a Future or Stream. - Use
expectLaterwithemitsInOrder/emits/emitsDonefor async stream assertions. - Call
expectLaterbefore the stream emits to avoid missing events.
- WebSocket service (connect, disconnect, reconnect, message parsing)
- Auth provider state transitions (unauthenticated -> authenticating -> authenticated -> error)
- Git command serialization/deserialization
- Notification payload parsing
- Diff parsing logic
- Sync queue operations and conflict resolution
- Claude Code Hook event parsing
Tools: flutter_test widget tester
testWidgets('shows connected status', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
connectionStateProvider.overrideWith((_) => ConnectionState.connected),
],
child: const MaterialApp(home: ChatScreen()),
),
);
expect(find.text('Connected'), findsOneWidget);
});- Chat UI with mock message streams
- Login screen form validation and submission
- OpenCode-style Tool Cards with sample data
- Diff viewer with sample diff data
- Approval UI approve/reject/modify interactions
- Connection state indicators (connected, disconnected, reconnecting)
- Repository list and file browser
- Session timeline rendering
testWidgets('renders tool card with correct status', (tester) async {
final toolUse = ToolUse(
tool: 'edit_file',
params: {'file_path': 'test.dart'},
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ToolCard(
tool: toolUse,
status: ToolStatus.completed,
),
),
),
);
expect(find.byIcon(Icons.check_circle), findsOneWidget);
expect(find.text('edit_file'), findsOneWidget);
});Tool: alchemist
- Capture baseline screenshots for key screens and states.
- Connection states: connected, disconnected, reconnecting.
- Chat: empty, loading, with messages, with streaming response.
- Diff viewer: added lines, removed lines, modified files.
- Tool cards: pending, running, completed, error states.
- Run on CI to catch unintended visual changes.
Tools: integration_test package + local Dart WebSocket server
setUpAll(() async {
// Start a local WebSocket server that replays scripted messages
testServer = await TestBridgeServer.start(port: 8765);
});
testWidgets('full chat flow', (tester) async {
await tester.pumpWidget(const MyApp());
// Connect to local bridge
await tester.tap(find.byKey(Key('connect_button')));
await tester.pumpAndSettle();
// Send a message
await tester.enterText(find.byType(TextField), 'Fix the bug');
await tester.tap(find.byKey(Key('send_button')));
// Wait for streamed response
await tester.pumpAndSettle(Duration(seconds: 2));
expect(find.textContaining('Fixed'), findsOneWidget);
});- Auth -> connect -> chat -> receive response
- Git operation flows (commit, push, pull)
- Approval flow (receive tool call -> approve -> agent continues)
- Offline -> reconnect -> sync
- Hook event flow (Claude Code -> Hooks -> Bridge -> Mobile)
// Local TypeScript server for integration tests
import { WebSocketServer } from 'ws';
class TestBridgeServer {
private wss: WebSocketServer;
private scenarios: Map<string, WebSocketMessage[]>;
start(port: number) {
this.wss = new WebSocketServer({ port });
this.wss.on('connection', (ws) => {
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
// Replay scripted responses
const responses = this.scenarios.get(msg.type) || [];
for (const response of responses) {
ws.send(JSON.stringify(response));
}
});
});
}
}Tool: patrol
- Complete user journeys on real or emulated devices.
- Includes system-level interactions (notifications, deep links).
- Run on
mainbranch merges (too slow for every PR).
- Full onboarding flow: install -> auth -> pair -> first message
- Background notification: receive approval request -> tap notification -> approve
- Multi-session: switch between agent sessions
- Offline workflow: actions while offline -> sync on reconnect
| Trigger | Tests Run |
|---|---|
| PR opened/updated | Unit + Widget + Golden + flutter analyze |
Push to main |
All above + Integration |
| Release tag | All above + E2E on physical devices |
class TestData {
static ToolUse sampleToolUse = ToolUse(
tool: 'edit_file',
params: {
'file_path': 'lib/main.dart',
'old_string': 'void main() {',
'new_string': 'void main() async {',
},
);
static DiffFile sampleDiffFile = DiffFile(
path: 'lib/main.dart',
status: FileChangeStatus.modified,
additions: 1,
deletions: 1,
hunks: [
DiffHunk(
header: '@@ -10,5 +10,5 @@',
oldStart: 10,
oldLines: 5,
newStart: 10,
newLines: 5,
lines: [
DiffLine(type: DiffLineType.context, content: ' class MyApp {'),
DiffLine(type: DiffLineType.removed, content: '- void main() {'),
DiffLine(type: DiffLineType.added, content: '+ void main() async {'),
DiffLine(type: DiffLineType.context, content: ' // ...'),
],
),
],
);
}// Helper to wait for Riverpod state changes
Future<void> pumpUntilFound(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 10),
}) async {
final endTime = DateTime.now().add(timeout);
while (DateTime.now().isBefore(endTime)) {
await tester.pumpAndSettle(const Duration(milliseconds: 100));
if (finder.evaluate().isNotEmpty) return;
}
throw TimeoutException('Finder not found within $timeout');
}test('parses PostToolUse hook event', () {
final json = {
'event_type': 'PostToolUse',
'session_id': 'sess-abc',
'timestamp': '2026-03-17T10:32:00Z',
'payload': {
'tool': 'edit_file',
'result': {'success': true},
},
};
final event = HookEvent.fromJson(json);
expect(event.eventType, 'PostToolUse');
expect(event.sessionId, 'sess-abc');
});testWidgets('displays Claude Code event from bridge', (tester) async {
final bridge = MockBridgeService();
when(bridge.eventStream).thenAnswer((_) => Stream.fromIterable([
HookEvent.postToolUse(
tool: 'edit_file',
result: ToolResult.success(),
),
]));
await tester.pumpWidget(
ProviderScope(
overrides: [
bridgeProvider.overrideWithValue(bridge),
],
child: const ChatScreen(),
),
);
await tester.pump();
expect(find.byType(ToolCard), findsOneWidget);
});- CI/CD Pipeline — CI/CD configuration
- Architecture Overview — System architecture
- Bridge Protocol — WebSocket message specification
Last updated: 2026-03-17