Flutter Verification and Testing
Comprehensive Flutter verification patterns covering widget tests, integration tests, golden tests, performance testing, platform verification, accessibility, build validation, asset verification, localization coverage, and code quality checks.
Widget Testing Fundamentals
Basic Widget Tests
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
void main() {
group('CounterWidget', () {
testWidgets('displays initial count', (WidgetTester tester) async {
// Build the widget
await tester.pumpWidget(
const MaterialApp(
home: CounterWidget(initialCount: 5),
),
);
// Verify initial state
expect(find.text('5'), findsOneWidget);
expect(find.text('6'), findsNothing);
});
testWidgets('increments counter on button tap', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: CounterWidget(initialCount: 0),
),
);
// Find and tap the increment button
final incrementButton = find.byIcon(Icons.add);
await tester.tap(incrementButton);
await tester.pump(); // Trigger rebuild
// Verify updated state
expect(find.text('1'), findsOneWidget);
});
testWidgets('enters text in input field', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: FormWidget(),
),
);
// Find text field
final textField = find.byType(TextField);
// Enter text
await tester.enterText(textField, 'Hello Flutter');
await tester.pump();
// Verify text was entered
expect(find.text('Hello Flutter'), findsOneWidget);
});
});
}
Advanced Widget Finders
void main() {
testWidgets('finds widgets using various finders', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Test App')),
body: Column(
children: [
const Text('Welcome', key: Key('welcome-text')),
ElevatedButton(
onPressed: () {},
child: const Text('Submit'),
),
const Icon(Icons.home),
],
),
),
),
);
// Find by text
expect(find.text('Welcome'), findsOneWidget);
expect(find.text('Submit'), findsOneWidget);
// Find by key
expect(find.byKey(const Key('welcome-text')), findsOneWidget);
// Find by type
expect(find.byType(ElevatedButton), findsOneWidget);
expect(find.byType(Icon), findsOneWidget);
// Find by icon
expect(find.byIcon(Icons.home), findsOneWidget);
// Find by widget
expect(find.byWidget(const Text('Welcome', key: Key('welcome-text'))), findsOneWidget);
// Find descendant
expect(
find.descendant(
of: find.byType(AppBar),
matching: find.text('Test App'),
),
findsOneWidget,
);
// Find ancestor
expect(
find.ancestor(
of: find.text('Welcome'),
matching: find.byType(Column),
),
findsOneWidget,
);
// Find with predicate
expect(
find.byWidgetPredicate(
(widget) => widget is Text && widget.data == 'Welcome',
),
findsOneWidget,
);
});
}
Testing User Interactions
void main() {
testWidgets('handles complex user interactions', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: TodoApp()));
// Enter text in todo input
await tester.enterText(find.byType(TextField), 'Buy groceries');
await tester.pump();
// Tap add button
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify todo was added
expect(find.text('Buy groceries'), findsOneWidget);
// Long press on todo
await tester.longPress(find.text('Buy groceries'));
await tester.pumpAndSettle(); // Wait for animations
// Verify context menu appeared
expect(find.text('Delete'), findsOneWidget);
// Tap delete
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
// Verify todo was deleted
expect(find.text('Buy groceries'), findsNothing);
});
testWidgets('handles scrolling', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) => ListTile(
title: Text('Item $index'),
),
),
),
),
);
// Verify first item is visible
expect(find.text('Item 0'), findsOneWidget);
expect(find.text('Item 50'), findsNothing);
// Scroll to item
await tester.scrollUntilVisible(
find.text('Item 50'),
500.0, // Scroll delta
);
await tester.pumpAndSettle();
// Verify item is now visible
expect(find.text('Item 50'), findsOneWidget);
// Drag to scroll
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pumpAndSettle();
});
testWidgets('handles gestures', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: GestureTestWidget()));
// Tap
await tester.tap(find.byType(GestureDetector));
await tester.pump();
// Double tap
await tester.tap(find.byType(GestureDetector));
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(find.byType(GestureDetector));
await tester.pumpAndSettle();
// Drag
await tester.drag(find.byType(GestureDetector), const Offset(100, 0));
await tester.pumpAndSettle();
// Fling
await tester.fling(find.byType(GestureDetector), const Offset(0, -200), 1000);
await tester.pumpAndSettle();
});
}
Testing Async Operations
void main() {
testWidgets('handles async data loading', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: AsyncDataWidget()));
// Initially shows loading indicator
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Data loaded'), findsNothing);
// Wait for async operation to complete
await tester.pumpAndSettle();
// Verify data is loaded
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Data loaded'), findsOneWidget);
});
testWidgets('handles delayed animations', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: AnimatedWidget()));
// Pump frames for specific duration
await tester.pump(const Duration(milliseconds: 100));
await tester.pump(const Duration(milliseconds: 200));
// Or pump until all animations complete
await tester.pumpAndSettle();
});
testWidgets('tests with fake async', (WidgetTester tester) async {
await tester.runAsync(() async {
// Code that uses real async operations
final response = await http.get(Uri.parse('https://api.example.com/data'));
expect(response.statusCode, 200);
});
});
}
Mocking Dependencies
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
// Generate mocks
@GenerateMocks([UserRepository, AuthService])
void main() {
late MockUserRepository mockUserRepository;
late MockAuthService mockAuthService;
setUp(() {
mockUserRepository = MockUserRepository();
mockAuthService = MockAuthService();
});
testWidgets('displays user data from repository', (WidgetTester tester) async {
// Setup mock
when(mockUserRepository.getUser('123'))
.thenAnswer((_) async => User(id: '123', name: 'John Doe'));
// Build widget with mock
await tester.pumpWidget(
MaterialApp(
home: UserProfilePage(
userId: '123',
repository: mockUserRepository,
),
),
);
// Wait for async operation
await tester.pumpAndSettle();
// Verify user data is displayed
expect(find.text('John Doe'), findsOneWidget);
// Verify repository was called
verify(mockUserRepository.getUser('123')).called(1);
});
testWidgets('handles authentication errors', (WidgetTester tester) async {
// Setup mock to throw error
when(mockAuthService.login(any, any))
.thenThrow(AuthException('Invalid credentials'));
await tester.pumpWidget(
MaterialApp(
home: LoginPage(authService: mockAuthService),
),
);
// Enter credentials
await tester.enterText(find.byKey(const Key('email')), 'test@example.com');
await tester.enterText(find.byKey(const Key('password')), 'wrong_password');
// Tap login
await tester.tap(find.text('Login'));
await tester.pumpAndSettle();
// Verify error message is shown
expect(find.text('Invalid credentials'), findsOneWidget);
// Verify login was attempted
verify(mockAuthService.login('test@example.com', 'wrong_password')).called(1);
});
}
Integration Testing
Full App Integration Tests
// integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('end-to-end test', () {
testWidgets('complete user flow', (WidgetTester tester) async {
// Start the app
app.main();
await tester.pumpAndSettle();
// Verify splash screen or initial page
expect(find.text('Welcome'), findsOneWidget);
// Navigate to login
await tester.tap(find.text('Sign In'));
await tester.pumpAndSettle();
// Enter credentials
await tester.enterText(find.byType(TextField).first, 'test@example.com');
await tester.enterText(find.byType(TextField).at(1), 'password123');
// Submit login
await tester.tap(find.text('Login'));
await tester.pumpAndSettle(const Duration(seconds: 3));
// Verify home page
expect(find.text('Home'), findsOneWidget);
// Navigate through app
await tester.tap(find.byIcon(Icons.settings));
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
// Logout
await tester.tap(find.text('Logout'));
await tester.pumpAndSettle();
// Verify back at welcome screen
expect(find.text('Welcome'), findsOneWidget);
});
testWidgets('data persistence across app restarts', (WidgetTester tester) async {
app.main();
await tester.pumpAndSettle();
// Add data
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Test item');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
// Verify data exists
expect(find.text('Test item'), findsOneWidget);
// Restart app
await tester.restartAndRestore();
// Verify data persisted
expect(find.text('Test item'), findsOneWidget);
});
});
group('navigation flow', () {
testWidgets('deep linking works correctly', (WidgetTester tester) async {
app.main();
await tester.pumpAndSettle();
// Simulate deep link
// This depends on your routing setup
await tester.tap(find.text('Open Product'));
await tester.pumpAndSettle();
expect(find.text('Product Details'), findsOneWidget);
});
testWidgets('back button navigation', (WidgetTester tester) async {
app.main();
await tester.pumpAndSettle();
// Navigate forward
await tester.tap(find.text('Next Page'));
await tester.pumpAndSettle();
// Press back
await tester.pageBack();
await tester.pumpAndSettle();
// Verify returned to previous page
expect(find.text('Home'), findsOneWidget);
});
});
}
Multi-Screen Integration Tests
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('e-commerce checkout flow', (WidgetTester tester) async {
app.main();
await tester.pumpAndSettle();
// Browse products
expect(find.text('Products'), findsOneWidget);
// Add item to cart
await tester.tap(find.text('Add to Cart').first);
await tester.pumpAndSettle();
// Verify cart badge
expect(find.text('1'), findsOneWidget);
// Go to cart
await tester.tap(find.byIcon(Icons.shopping_cart));
await tester.pumpAndSettle();
// Verify item in cart
expect(find.text('Cart'), findsOneWidget);
expect(find.text('Product Name'), findsOneWidget);
// Proceed to checkout
await tester.tap(find.text('Checkout'));
await tester.pumpAndSettle();
// Fill shipping info
await tester.enterText(find.byKey(const Key('address')), '123 Main St');
await tester.enterText(find.byKey(const Key('city')), 'Springfield');
await tester.enterText(find.byKey(const Key('zip')), '12345');
// Continue to payment
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
// Enter payment details
await tester.enterText(find.byKey(const Key('card_number')), '4242424242424242');
await tester.enterText(find.byKey(const Key('expiry')), '12/25');
await tester.enterText(find.byKey(const Key('cvv')), '123');
// Complete purchase
await tester.tap(find.text('Place Order'));
await tester.pumpAndSettle(const Duration(seconds: 5));
// Verify order confirmation
expect(find.text('Order Confirmed'), findsOneWidget);
expect(find.textContaining('Order #'), findsOneWidget);
});
}
Testing with Real APIs (Optional)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('API integration tests', () {
testWidgets('fetches and displays real data', (WidgetTester tester) async {
app.main();
await tester.pumpAndSettle();
// Wait for API call to complete
await tester.pumpAndSettle(const Duration(seconds: 5));
// Verify data from API is displayed
expect(find.byType(ListTile), findsWidgets);
});
testWidgets('handles network errors gracefully', (WidgetTester tester) async {
// Configure app to use invalid API endpoint
app.main(apiUrl: 'https://invalid-url-that-will-fail.com');
await tester.pumpAndSettle();
// Wait for error state
await tester.pumpAndSettle(const Duration(seconds: 5));
// Verify error message is shown
expect(find.text('Unable to load data'), findsOneWidget);
// Test retry functionality
await tester.tap(find.text('Retry'));
await tester.pumpAndSettle(const Duration(seconds: 5));
});
});
}
Golden Tests (Visual Regression Testing)
Basic Golden Tests
void main() {
testWidgets('matches golden file', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: Center(
child: Text('Hello World'),
),
),
),
);
// Compare against golden file
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('golden/hello_world.png'),
);
});
testWidgets('matches golden with custom size', (WidgetTester tester) async {
// Set specific screen size
await tester.binding.setSurfaceSize(const Size(400, 600));
await tester.pumpWidget(
MaterialApp(
home: ProductCard(
title: 'Test Product',
price: '\$99.99',
imageUrl: 'https://example.com/image.jpg',
),
),
);
await expectLater(
find.byType(ProductCard),
matchesGoldenFile('golden/product_card.png'),
);
// Reset size
await tester.binding.setSurfaceSize(null);
});
}
Golden Tests for Different States
void main() {
group('button states golden tests', () {
testWidgets('default state', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: ElevatedButton(
onPressed: () {},
child: const Text('Click Me'),
),
),
);
await expectLater(
find.byType(ElevatedButton),
matchesGoldenFile('golden/button_default.png'),
);
});
testWidgets('disabled state', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: ElevatedButton(
onPressed: null,
child: Text('Click Me'),
),
),
);
await expectLater(
find.byType(ElevatedButton),
matchesGoldenFile('golden/button_disabled.png'),
);
});
testWidgets('hover state', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: ElevatedButton(
onPressed: () {},
child: const Text('Click Me'),
),
),
);
// Simulate hover
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(tester.getCenter(find.byType(ElevatedButton)));
await tester.pumpAndSettle();
await expectLater(
find.byType(ElevatedButton),
matchesGoldenFile('golden/button_hover.png'),
);
});
});
}
Golden Tests for Themes
void main() {
group('theme golden tests', () {
testWidgets('light theme', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.light(),
home: const HomePage(),
),
);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('golden/home_page_light.png'),
);
});
testWidgets('dark theme', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.dark(),
home: const HomePage(),
),
);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('golden/home_page_dark.png'),
);
});
});
}
Golden Tests for Responsive Layouts
void main() {
group('responsive layout golden tests', () {
testWidgets('mobile layout', (WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(375, 667)); // iPhone SE
await tester.pumpWidget(
const MaterialApp(home: ResponsivePage()),
);
await expectLater(
find.byType(ResponsivePage),
matchesGoldenFile('golden/responsive_mobile.png'),
);
});
testWidgets('tablet layout', (WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(768, 1024)); // iPad
await tester.pumpWidget(
const MaterialApp(home: ResponsivePage()),
);
await expectLater(
find.byType(ResponsivePage),
matchesGoldenFile('golden/responsive_tablet.png'),
);
});
testWidgets('desktop layout', (WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(1920, 1080));
await tester.pumpWidget(
const MaterialApp(home: ResponsivePage()),
);
await expectLater(
find.byType(ResponsivePage),
matchesGoldenFile('golden/responsive_desktop.png'),
);
});
tearDown(() async {
await tester.binding.setSurfaceSize(null);
});
});
}
Performance Testing
Frame Rendering Performance
import 'package:flutter/scheduler.dart';
void main() {
testWidgets('measures frame rendering performance', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: PerformanceTestPage()));
// Start performance tracking
final timeline = await tester.binding.traceAction(() async {
// Perform action that triggers animations
await tester.tap(find.byIcon(Icons.play_arrow));
// Let animation run for 2 seconds
await tester.pumpAndSettle(const Duration(seconds: 2));
});
// Analyze timeline
final summary = TimelineSummary.summarize(timeline);
// Assert performance metrics
expect(summary.countFrames(), greaterThan(0));
// Check for frame drops (frames taking longer than 16ms)
final frameTimes = summary.computeFrameTimes();
final droppedFrames = frameTimes.where((time) => time > const Duration(milliseconds: 16));
expect(droppedFrames.length, lessThan(frameTimes.length * 0.1)); // Less than 10% dropped frames
});
testWidgets('tests scrolling performance', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ListView.builder(
itemCount: 10000,
itemBuilder: (context, index) => ListTile(
title: Text('Item $index'),
leading: const CircleAvatar(),
subtitle: Text('Subtitle $index'),
),
),
),
),
);
final timeline = await tester.binding.traceAction(() async {
// Perform fast scroll
await tester.fling(
find.byType(ListView),
const Offset(0, -5000),
5000,
);
await tester.pumpAndSettle();
});
final summary = TimelineSummary.summarize(timeline);
// Verify smooth scrolling
expect(summary.countFrames(), greaterThan(30)); // At least 30 frames
final avgFrameTime = summary.computeAverageFrameTime();
expect(avgFrameTime.inMilliseconds, lessThan(16)); // Under 60fps threshold
});
}
Memory Profiling
void main() {
testWidgets('monitors memory usage', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: MemoryTestPage()));
// Get initial memory
final initialMemory = tester.binding.window.physicalSize.width *
tester.binding.window.physicalSize.height * 4;
// Perform memory-intensive operations
for (int i = 0; i < 100; i++) {
await tester.tap(find.text('Add Image'));
await tester.pump();
}
await tester.pumpAndSettle();
// Check for memory leaks by ensuring widgets are properly disposed
// This is a simplified check - use DevTools for detailed memory profiling
await tester.pumpWidget(Container());
await tester.pump();
// Verify no lingering state
});
testWidgets('checks for widget rebuild efficiency', (WidgetTester tester) async {
int buildCount = 0;
await tester.pumpWidget(
MaterialApp(
home: StatefulBuilder(
builder: (context, setState) {
buildCount++;
return Column(
children: [
const Text('Static Widget'),
ElevatedButton(
onPressed: () => setState(() {}),
child: const Text('Rebuild'),
),
],
);
},
),
),
);
final initialBuildCount = buildCount;
// Trigger rebuild
await tester.tap(find.text('Rebuild'));
await tester.pump();
// Should only rebuild once
expect(buildCount, equals(initialBuildCount + 1));
});
}
Timeline Analysis
import 'dart:convert';
import 'dart:io';
void main() {
testWidgets('generates performance report', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: TestPage()));
final timeline = await tester.binding.traceAction(() async {
// Complex UI operations
await tester.tap(find.text('Load Data'));
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
});
final summary = TimelineSummary.summarize(timeline);
// Write summary to file
await summary.writeSummaryToFile('performance_summary', pretty: true);
await summary.writeTimelineToFile('timeline', pretty: true);
// Analyze specific events
final buildEvents = timeline.events!
.where((event) => event.name == 'Frame')
.toList();
print('Total frames: ${buildEvents.length}');
// Check for expensive operations
final expensiveEvents = timeline.events!
.where((event) => event.duration != null && event.duration!.inMilliseconds > 100)
.toList();
expect(expensiveEvents, isEmpty, reason: 'Found expensive operations');
});
}
Platform Testing
iOS and Android Build Verification
# In your test script or CI/CD pipeline
# Android build verification
flutter build apk --debug
flutter build apk --release --no-shrink
flutter build appbundle --release
# iOS build verification (requires macOS)
flutter build ios --debug --no-codesign
flutter build ios --release --no-codesign
# Verify build outputs exist
test -f build/app/outputs/flutter-apk/app-release.apk
test -f build/app/outputs/bundle/release/app-release.aab
test -d build/ios/iphoneos/Runner.app
Platform-Specific Tests
import 'dart:io';
void main() {
group('platform-specific tests', () {
testWidgets('iOS specific behavior', (WidgetTester tester) async {
// Skip on non-iOS platforms
if (!Platform.isIOS) {
return;
}
await tester.pumpWidget(const MaterialApp(home: PlatformTestPage()));
// Test iOS-specific features
expect(find.byType(CupertinoNavigationBar), findsOneWidget);
}, skip: !Platform.isIOS);
testWidgets('Android specific behavior', (WidgetTester tester) async {
if (!Platform.isAndroid) {
return;
}
await tester.pumpWidget(const MaterialApp(home: PlatformTestPage()));
// Test Android-specific features
expect(find.byType(AppBar), findsOneWidget);
}, skip: !Platform.isAndroid);
});
testWidgets('tests platform adaptive widgets', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: AdaptiveWidget()));
if (Platform.isIOS) {
expect(find.byType(CupertinoButton), findsOneWidget);
} else {
expect(find.byType(ElevatedButton), findsOneWidget);
}
});
}
Testing Platform Channels
import 'package:flutter/services.dart';
void main() {
const channel = MethodChannel('com.example.app/native');
setUp(() {
// Mock method channel
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (MethodCall call) async {
switch (call.method) {
case 'getBatteryLevel':
return 85;
case 'getDeviceInfo':
return {
'model': 'Test Device',
'os': 'Test OS',
'version': '1.0.0',
};
default:
throw MissingPluginException();
}
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
testWidgets('calls native platform method', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: BatteryWidget()));
// Trigger platform method call
await tester.tap(find.text('Get Battery Level'));
await tester.pumpAndSettle();
// Verify result
expect(find.text('Battery: 85%'), findsOneWidget);
});
test('handles platform exceptions', () async {
expect(
() async => await channel.invokeMethod('invalidMethod'),
throwsA(isA<MissingPluginException>()),
);
});
}
Accessibility Verification
Semantic Labels
void main() {
testWidgets('verifies semantic labels', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Test')),
body: Column(
children: [
IconButton(
icon: const Icon(Icons.home),
onPressed: () {},
tooltip: 'Go to home',
),
const Text('Welcome'),
Image.asset(
'assets/logo.png',
semanticLabel: 'Company logo',
),
],
),
),
),
);
// Verify semantic labels exist
expect(
tester.getSemantics(find.byType(IconButton)),
matchesSemantics(
label: 'Go to home',
isButton: true,
),
);
expect(
tester.getSemantics(find.byType(Image)),
matchesSemantics(label: 'Company logo'),
);
});
testWidgets('ensures all interactive elements are accessible', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: AccessibilityTestPage()));
// Find all interactive widgets
final buttons = find.byType(ElevatedButton);
final iconButtons = find.byType(IconButton);
final textFields = find.byType(TextField);
// Verify each has semantic label or tooltip
for (final finder in [buttons, iconButtons, textFields]) {
final count = tester.widgetList(finder).length;
for (int i = 0; i < count; i++) {
final semantics = tester.getSemantics(finder.at(i));
expect(
semantics.label,
isNotEmpty,
reason: 'Interactive element at index $i missing semantic label',
);
}
}
});
}
Screen Reader Testing
void main() {
testWidgets('tests screen reader navigation order', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Semantics(
sortKey: const OrdinalSortKey(0),
child: Column(
children: [
Semantics(
sortKey: const OrdinalSortKey(1),
child: const Text('First'),
),
Semantics(
sortKey: const OrdinalSortKey(2),
child: const Text('Second'),
),
Semantics(
sortKey: const OrdinalSortKey(3),
child: const Text('Third'),
),
],
),
),
),
);
// Verify semantic order
final semantics = tester.getSemantics(find.byType(Column));
// Verify reading order matches intended order
});
testWidgets('verifies live regions for dynamic content', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Semantics(
liveRegion: true,
child: const DynamicContentWidget(),
),
),
);
// Trigger content update
await tester.tap(find.text('Update'));
await tester.pumpAndSettle();
// Verify screen reader is notified
final semantics = tester.getSemantics(find.byType(DynamicContentWidget));
expect(semantics.hasFlag(SemanticsFlag.isLiveRegion), isTrue);
});
}
Contrast and Text Scaling
void main() {
testWidgets('verifies text scales correctly', (WidgetTester tester) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(textScaleFactor: 1.0),
child: MaterialApp(
home: const Text('Normal Text'),
),
),
);
final normalSize = tester.getSize(find.text('Normal Text'));
// Test with large text
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(textScaleFactor: 2.0),
child: MaterialApp(
home: const Text('Normal Text'),
),
),
);
final largeSize = tester.getSize(find.text('Normal Text'));
// Verify text scales
expect(largeSize.height, greaterThan(normalSize.height));
});
testWidgets('verifies sufficient color contrast', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Container(
color: Colors.black,
child: const Text(
'White on Black',
style: TextStyle(color: Colors.white),
),
),
),
);
// Manual contrast checking (in real tests, use contrast calculation)
// WCAG AA requires 4.5:1 for normal text, 3:1 for large text
// White on black has 21:1 ratio - excellent contrast
});
}
Build Verification
Build Configuration Tests
// test/build_config_test.dart
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:yaml/yaml.dart';
void main() {
test('pubspec.yaml has correct version', () {
final file = File('pubspec.yaml');
final yaml = loadYaml(file.readAsStringSync());
expect(yaml['version'], matches(RegExp(r'^\d+\.\d+\.\d+\+\d+$')));
});
test('android build.gradle has correct settings', () {
final file = File('android/app/build.gradle');
final content = file.readAsStringSync();
expect(content, contains('minSdkVersion 21'));
expect(content, contains('targetSdkVersion 33'));
expect(content, contains('compileSdkVersion 33'));
});
test('iOS Podfile has correct platform version', () {
final file = File('ios/Podfile');
final content = file.readAsStringSync();
expect(content, contains("platform :ios, '12.0'"));
});
test('release builds have correct signing config', () {
final androidFile = File('android/app/build.gradle');
final androidContent = androidFile.readAsStringSync();
expect(androidContent, contains('signingConfig signingConfigs.release'));
// For iOS, check in project.pbxproj or export options
final iosFile = File('ios/Runner.xcodeproj/project.pbxproj');
if (iosFile.existsSync()) {
final iosContent = iosFile.readAsStringSync();
expect(iosContent, contains('CODE_SIGN_IDENTITY'));
}
});
}
App Signing Verification
#!/bin/bash
# verify_signing.sh
# Android APK verification
echo "Verifying Android APK signing..."
apksigner verify --verbose build/app/outputs/flutter-apk/app-release.apk
if [ $? -eq 0 ]; then
echo "Android APK signing verified"
else
echo "Android APK signing failed"
exit 1
fi
# Android Bundle verification
echo "Verifying Android Bundle signing..."
jarsigner -verify -verbose -certs build/app/outputs/bundle/release/app-release.aab
if [ $? -eq 0 ]; then
echo "Android Bundle signing verified"
else
echo "Android Bundle signing failed"
exit 1
fi
# iOS IPA verification (requires macOS)
if [[ "$OSTYPE" == "darwin"* ]]; then
echo "Verifying iOS IPA signing..."
codesign -dv --verbose=4 build/ios/iphoneos/Runner.app
if [ $? -eq 0 ]; then
echo "iOS app signing verified"
else
echo "iOS app signing failed"
exit 1
fi
fi
Asset Verification
Asset Existence Tests
// test/asset_test.dart
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:yaml/yaml.dart';
void main() {
group('asset verification', () {
test('all declared assets exist', () {
final pubspec = File('pubspec.yaml');
final yaml = loadYaml(pubspec.readAsStringSync());
final assets = yaml['flutter']?['assets'] as List?;
if (assets != null) {
for (final asset in assets) {
final assetPath = asset.toString();
if (assetPath.endsWith('/')) {
// Directory - check it exists
final dir = Directory(assetPath);
expect(dir.existsSync(), isTrue, reason: 'Asset directory $assetPath does not exist');
} else {
// File - check it exists
final file = File(assetPath);
expect(file.existsSync(), isTrue, reason: 'Asset file $assetPath does not exist');
}
}
}
});
test('no unused assets in project', () {
// Find all asset references in code
final assetReferences = <String>{};
// Scan lib directory for asset references
final libDir = Directory('lib');
libDir.listSync(recursive: true).forEach((entity) {
if (entity is File && entity.path.endsWith('.dart')) {
final content = entity.readAsStringSync();
final regex = RegExp(r"['\"]assets/[^'\"]+['\"]");
final matches = regex.allMatches(content);
for (final match in matches) {
assetReferences.add(match.group(0)!.replaceAll("'", '').replaceAll('"', ''));
}
}
});
// Check all assets are referenced
final pubspec = File('pubspec.yaml');
final yaml = loadYaml(pubspec.readAsStringSync());
final assets = yaml['flutter']?['assets'] as List?;
if (assets != null) {
for (final asset in assets) {
if (!asset.toString().endsWith('/')) {
expect(
assetReferences.contains(asset.toString()),
isTrue,
reason: 'Asset $asset is declared but never used',
);
}
}
}
});
test('images have appropriate sizes', () {
final imageDir = Directory('assets/images');
if (!imageDir.existsSync()) return;
imageDir.listSync(recursive: true).forEach((entity) {
if (entity is File &&
(entity.path.endsWith('.png') ||
entity.path.endsWith('.jpg') ||
entity.path.endsWith('.jpeg'))) {
final size = entity.lengthSync();
// Warn if image is larger than 1MB
expect(
size,
lessThan(1024 * 1024),
reason: 'Image ${entity.path} is larger than 1MB (${size} bytes). Consider compressing.',
);
}
});
});
});
}
Font Verification
void main() {
test('all declared fonts exist', () {
final pubspec = File('pubspec.yaml');
final yaml = loadYaml(pubspec.readAsStringSync());
final fonts = yaml['flutter']?['fonts'] as List?;
if (fonts != null) {
for (final fontFamily in fonts) {
final fontList = fontFamily['fonts'] as List;
for (final font in fontList) {
final fontPath = font['asset'].toString();
final file = File(fontPath);
expect(
file.existsSync(),
isTrue,
reason: 'Font file $fontPath does not exist',
);
}
}
}
});
testWidgets('custom fonts load correctly', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: const Text(
'Custom Font',
style: TextStyle(fontFamily: 'CustomFont'),
),
),
);
// Verify text renders without errors
expect(find.text('Custom Font'), findsOneWidget);
});
}
Localization Coverage
ARB File Completeness
// test/localization_test.dart
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('localization coverage', () {
test('all locales have same keys as template', () {
final l10nDir = Directory('lib/l10n');
if (!l10nDir.existsSync()) return;
// Load template ARB (usually English)
final templateFile = File('lib/l10n/app_en.arb');
final templateContent = json.decode(templateFile.readAsStringSync()) as Map<String, dynamic>;
// Get all translation keys (excluding metadata keys starting with @)
final templateKeys = templateContent.keys
.where((key) => !key.startsWith('@'))
.toSet();
// Check each locale
l10nDir.listSync().forEach((entity) {
if (entity is File &&
entity.path.endsWith('.arb') &&
!entity.path.endsWith('app_en.arb')) {
final localeContent = json.decode(entity.readAsStringSync()) as Map<String, dynamic>;
final localeKeys = localeContent.keys
.where((key) => !key.startsWith('@'))
.toSet();
// Check for missing keys
final missingKeys = templateKeys.difference(localeKeys);
expect(
…(truncated)