Appearance
Flutter测试策略
测试是保证Flutter应用质量的关键环节。Flutter提供了全面的测试支持,包括单元测试、小部件测试和集成测试。本章将详细介绍Flutter中的各种测试类型及其最佳实践。
测试概述
测试类型
Flutter应用测试主要包括以下几种类型:
- 单元测试:测试单个函数或类的功能
- 小部件测试:测试单个小部件的UI和交互
- 集成测试:测试多个组件协同工作的功能
- 端到端测试:测试完整应用流程
测试依赖
在pubspec.yaml中添加测试依赖:
yaml
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
integration_test:
sdk: flutter
mockito: ^5.4.0
bloc_test: ^9.1.0
fake_async: ^1.3.1
单元测试
单元测试用于测试应用程序的单个函数、方法或类。它们应该是隔离的,不依赖于外部系统。
基础单元测试
dart
// calculator.dart
class Calculator {
int add(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}
double divide(int dividend, int divisor) {
if (divisor == 0) {
throw ArgumentError('Division by zero');
}
return dividend / divisor;
}
bool isPrime(int number) {
if (number <= 1) return false;
if (number == 2) return true;
if (number.isEven) return false;
for (int i = 3; i * i <= number; i += 2) {
if (number % i == 0) return false;
}
return true;
}
}
// calculator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'calculator.dart';
void main() {
group('Calculator Tests', () {
late Calculator calculator;
setUp(() {
calculator = Calculator();
});
test('add should return correct sum', () {
// Given
const a = 5;
const b = 3;
// When
final result = calculator.add(a, b);
// Then
expect(result, equals(8));
});
test('subtract should return correct difference', () {
// Given
const a = 10;
const b = 4;
// When
final result = calculator.subtract(a, b);
// Then
expect(result, equals(6));
});
test('divide should return correct quotient', () {
// Given
const dividend = 10;
const divisor = 2;
// When
final result = calculator.divide(dividend, divisor);
// Then
expect(result, equals(5.0));
});
test('divide by zero should throw ArgumentError', () {
// Given
const dividend = 10;
const divisor = 0;
// When & Then
expect(
() => calculator.divide(dividend, divisor),
throwsA(const TypeMatcher<ArgumentError>()),
);
});
test('isPrime should return true for prime numbers', () {
// Given
const primeNumber = 7;
// When
final result = calculator.isPrime(primeNumber);
// Then
expect(result, isTrue);
});
test('isPrime should return false for non-prime numbers', () {
// Given
const nonPrimeNumber = 8;
// When
final result = calculator.isPrime(nonPrimeNumber);
// Then
expect(result, isFalse);
});
test('isPrime should return false for numbers <= 1', () {
// Given
const invalidNumber = -1;
// When
final result = calculator.isPrime(invalidNumber);
// Then
expect(result, isFalse);
});
});
}
异步单元测试
dart
// async_service.dart
class AsyncService {
Future<String> fetchData(String url) async {
// 模拟网络延迟
await Future.delayed(Duration(milliseconds: 100));
if (url.isEmpty) {
throw Exception('URL cannot be empty');
}
return 'Data from $url';
}
Future<int> calculateAsync(int a, int b) async {
await Future.delayed(Duration(milliseconds: 50));
return a + b;
}
Stream<int> countStream(int start, int end) async* {
for (int i = start; i <= end; i++) {
await Future.delayed(Duration(milliseconds: 10));
yield i;
}
}
}
// async_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'async_service.dart';
void main() {
group('AsyncService Tests', () {
late AsyncService service;
setUp(() {
service = AsyncService();
});
test('fetchData should return correct data', () async {
// Given
const url = 'https://api.example.com';
// When
final result = await service.fetchData(url);
// Then
expect(result, equals('Data from https://api.example.com'));
});
test('fetchData should throw exception for empty URL', () async {
// Given
const url = '';
// When & Then
await expectLater(
() => service.fetchData(url),
throwsA(const TypeMatcher<Exception>()),
);
});
test('calculateAsync should return correct sum', () async {
// Given
const a = 5;
const b = 3;
// When
final result = await service.calculateAsync(a, b);
// Then
expect(result, equals(8));
});
test('countStream should emit correct sequence', () async {
// Given
const start = 1;
const end = 3;
// When
final stream = service.countStream(start, end);
// Then
await expectLater(
stream,
emitsInOrder(<int>[1, 2, 3]),
);
});
test('countStream should emit nothing for invalid range', () async {
// Given
const start = 5;
const end = 3;
// When
final stream = service.countStream(start, end);
// Then
await expectLater(
stream,
emitsDone,
);
});
});
}
Mock测试
使用mockito库进行依赖模拟测试:
dart
// user_repository.dart
abstract class UserRepository {
Future<User> getUser(int id);
Future<List<User>> getAllUsers();
Future<void> createUser(User user);
Future<void> updateUser(User user);
Future<void> deleteUser(int id);
}
class User {
final int id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
};
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
// user_service.dart
class UserService {
final UserRepository _userRepository;
UserService(this._userRepository);
Future<User?> findUser(int id) async {
try {
return await _userRepository.getUser(id);
} catch (e) {
// 记录错误日志
print('Error fetching user: $e');
return null;
}
}
Future<List<User>> getAllUsers() async {
return await _userRepository.getAllUsers();
}
Future<bool> addUser(User user) async {
try {
await _userRepository.createUser(user);
return true;
} catch (e) {
print('Error adding user: $e');
return false;
}
}
}
// user_service_test.mocks.dart (Generated by build_runner)
// 注意:这部分通常是通过代码生成的
import 'package:mockito/mockito.dart';
import 'package:flutter_test/flutter_test.dart';
import 'user_repository.dart';
import 'user_service.dart';
class MockUserRepository extends Mock implements UserRepository {}
void main() {
group('UserService Tests', () {
late UserService userService;
late MockUserRepository mockRepository;
setUp(() {
mockRepository = MockUserRepository();
userService = UserService(mockRepository);
});
test('findUser should return user when repository succeeds', () async {
// Given
const userId = 1;
final mockUser = User(id: userId, name: 'John Doe', email: 'john@example.com');
when(mockRepository.getUser(userId)).thenAnswer((_) async => mockUser);
// When
final result = await userService.findUser(userId);
// Then
expect(result, equals(mockUser));
verify(mockRepository.getUser(userId)).called(1);
});
test('findUser should return null when repository throws', () async {
// Given
const userId = 1;
when(mockRepository.getUser(userId))
.thenThrow(Exception('User not found'));
// When
final result = await userService.findUser(userId);
// Then
expect(result, isNull);
verify(mockRepository.getUser(userId)).called(1);
});
test('addUser should return true when repository succeeds', () async {
// Given
final user = User(id: 1, name: 'Jane Doe', email: 'jane@example.com');
when(mockRepository.createUser(user)).thenAnswer((_) async => Future.value());
// When
final result = await userService.addUser(user);
// Then
expect(result, isTrue);
verify(mockRepository.createUser(user)).called(1);
});
test('addUser should return false when repository throws', () async {
// Given
final user = User(id: 1, name: 'Jane Doe', email: 'jane@example.com');
when(mockRepository.createUser(user))
.thenThrow(Exception('Failed to create user'));
// When
final result = await userService.addUser(user);
// Then
expect(result, isFalse);
verify(mockRepository.createUser(user)).called(1);
});
});
}
小部件测试
小部件测试用于测试Flutter小部件的UI表现和交互行为。
基础小部件测试
dart
// counter_widget.dart
import 'package:flutter/material.dart';
class CounterWidget extends StatefulWidget {
@override
_CounterWidgetState createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
void _decrementCounter() {
if (_counter > 0) {
setState(() {
_counter--;
});
}
}
void _resetCounter() {
setState(() {
_counter = 0;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Counter App'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Current Count:',
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
'$_counter',
style: Theme.of(context).textTheme.displayLarge,
),
SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FloatingActionButton(
onPressed: _decrementCounter,
child: Icon(Icons.remove),
heroTag: 'decrement',
),
SizedBox(width: 20),
FloatingActionButton(
onPressed: _incrementCounter,
child: Icon(Icons.add),
heroTag: 'increment',
),
],
),
SizedBox(height: 20),
ElevatedButton(
onPressed: _resetCounter,
child: Text('Reset'),
),
],
),
),
);
}
}
// counter_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'counter_widget.dart';
void main() {
group('CounterWidget Tests', () {
testWidgets('should start with count 0', (WidgetTester tester) async {
// Given
await tester.pumpWidget(MaterialApp(home: CounterWidget()));
// Then
expect(find.text('0'), findsOneWidget);
expect(find.text('Current Count:'), findsOneWidget);
});
testWidgets('should increment counter when "+" button is pressed',
(WidgetTester tester) async {
// Given
await tester.pumpWidget(MaterialApp(home: CounterWidget()));
// When
await tester.tap(find.byIcon(Icons.add));
await tester.pump(); // Rebuild after state change
// Then
expect(find.text('1'), findsOneWidget);
});
testWidgets('should decrement counter when "-" button is pressed',
(WidgetTester tester) async {
// Given
await tester.pumpWidget(MaterialApp(home: CounterWidget()));
// When
await tester.tap(find.byIcon(Icons.add)); // First increment to 1
await tester.pump();
await tester.tap(find.byIcon(Icons.remove)); // Then decrement to 0
await tester.pump();
// Then
expect(find.text('0'), findsOneWidget);
});
testWidgets('should not go below 0 when "-" button is pressed',
(WidgetTester tester) async {
// Given
await tester.pumpWidget(MaterialApp(home: CounterWidget()));
// When
await tester.tap(find.byIcon(Icons.remove)); // Try to decrement from 0
await tester.pump();
// Then
expect(find.text('0'), findsOneWidget);
});
testWidgets('should reset counter when reset button is pressed',
(WidgetTester tester) async {
// Given
await tester.pumpWidget(MaterialApp(home: CounterWidget()));
// When - increment to some value
await tester.tap(find.byIcon(Icons.add));
await tester.tap(find.byIcon(Icons.add));
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify it's not 0
expect(find.text('3'), findsOneWidget);
// Reset
await tester.tap(find.text('Reset'));
await tester.pump();
// Then
expect(find.text('0'), findsOneWidget);
});
testWidgets('should find all expected widgets', (WidgetTester tester) async {
// Given
await tester.pumpWidget(MaterialApp(home: CounterWidget()));
// Then
expect(find.byType(Scaffold), findsOneWidget);
expect(find.byType(AppBar), findsOneWidget);
expect(find.byType(FloatingActionButton), findsNWidgets(2));
expect(find.byType(ElevatedButton), findsOneWidget);
expect(find.byIcon(Icons.add), findsOneWidget);
expect(find.byIcon(Icons.remove), findsOneWidget);
});
});
}
小部件交互测试
dart
// form_widget.dart
import 'package:flutter/material.dart';
class FormWidget extends StatefulWidget {
@override
_FormWidgetState createState() => _FormWidgetState();
}
class _FormWidgetState extends State<FormWidget> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
String _submittedData = '';
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
setState(() {
_submittedData = 'Name: ${_nameController.text}\n'
'Email: ${_emailController.text}\n'
'Password: ${_passwordController.text}';
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Form Validation')),
body: Padding(
padding: EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Name is required';
}
if (value.length < 3) {
return 'Name must be at least 3 characters';
}
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
if (!value.contains('@')) {
return 'Invalid email format';
}
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
SizedBox(height: 16),
ElevatedButton(
onPressed: _submitForm,
child: Text('Submit'),
),
SizedBox(height: 16),
if (_submittedData.isNotEmpty)
Card(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text(_submittedData),
),
),
],
),
),
),
);
}
}
// form_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'form_widget.dart';
void main() {
group('FormWidget Tests', () {
testWidgets('should show validation errors when fields are empty',
(WidgetTester tester) async {
// Given
await tester.pumpWidget(MaterialApp(home: FormWidget()));
// When - tap submit without filling fields
await tester.tap(find.text('Submit'));
await tester.pump();
// Then - expect validation errors
expect(find.text('Name is required'), findsOneWidget);
expect(find.text('Email is required'), findsOneWidget);
expect(find.text('Password is required'), findsOneWidget);
});
testWidgets('should show specific validation errors',
(WidgetTester tester) async {
// Given
await tester.pumpWidget(MaterialApp(home: FormWidget()));
// When - enter invalid data
await tester.enterText(find.byWidgetPredicate(
(widget) => widget is TextFormField &&
widget.decoration?.labelText == 'Name',
), 'Jo'); // Too short name
await tester.enterText(find.byWidgetPredicate(
(widget) => widget is TextFormField &&
widget.decoration?.labelText == 'Email',
), 'invalid-email'); // Invalid email
await tester.enterText(find.byWidgetPredicate(
(widget) => widget is TextFormField &&
widget.decoration?.labelText == 'Password',
), '123'); // Too short password
// Submit
await tester.tap(find.text('Submit'));
await tester.pump();
// Then - expect specific validation errors
expect(find.text('Name must be at least 3 characters'), findsOneWidget);
expect(find.text('Invalid email format'), findsOneWidget);
expect(find.text('Password must be at least 6 characters'), findsOneWidget);
});
testWidgets('should submit successfully with valid data',
(WidgetTester tester) async {
// Given
await tester.pumpWidget(MaterialApp(home: FormWidget()));
// When - enter valid data
await tester.enterText(find.byWidgetPredicate(
(widget) => widget is TextFormField &&
widget.decoration?.labelText == 'Name',
), 'John Doe');
await tester.enterText(find.byWidgetPredicate(
(widget) => widget is TextFormField &&
widget.decoration?.labelText == 'Email',
), 'john@example.com');
await tester.enterText(find.byWidgetPredicate(
(widget) => widget is TextFormField &&
widget.decoration?.labelText == 'Password',
), 'password123');
// Submit
await tester.tap(find.text('Submit'));
await tester.pump();
// Then - expect successful submission
expect(find.text('Name: John Doe'), findsOneWidget);
expect(find.text('Email: john@example.com'), findsOneWidget);
expect(find.text('Password: password123'), findsOneWidget);
});
testWidgets('should update text fields correctly',
(WidgetTester tester) async {
// Given
await tester.pumpWidget(MaterialApp(home: FormWidget()));
// When - enter text into fields
await tester.enterText(find.byWidgetPredicate(
(widget) => widget is TextFormField &&
widget.decoration?.labelText == 'Name',
), 'Test User');
await tester.enterText(find.byWidgetPredicate(
(widget) => widget is TextFormField &&
widget.decoration?.labelText == 'Email',
), 'test@example.com');
// Then - expect text to be entered
expect(find.text('Test User'), findsOneWidget);
expect(find.text('test@example.com'), findsOneWidget);
});
});
}
小部件状态管理测试
dart
// state_management_widget.dart
import 'package:flutter/material.dart';
class TodoItem {
final String id;
final String title;
bool isCompleted;
TodoItem({
required this.id,
required this.title,
this.isCompleted = false,
});
}
class TodoListWidget extends StatefulWidget {
@override
_TodoListWidgetState createState() => _TodoListWidgetState();
}
class _TodoListWidgetState extends State<TodoListWidget> {
final List<TodoItem> _todos = [
TodoItem(id: '1', title: 'Task 1'),
TodoItem(id: '2', title: 'Task 2'),
TodoItem(id: '3', title: 'Task 3'),
];
void _addTodo(String title) {
setState(() {
_todos.add(TodoItem(id: DateTime.now().millisecondsSinceEpoch.toString(), title: title));
});
}
void _toggleTodo(String id) {
setState(() {
final todo = _todos.firstWhere((todo) => todo.id == id);
todo.isCompleted = !todo.isCompleted;
});
}
void _removeTodo(String id) {
setState(() {
_todos.removeWhere((todo) => todo.id == id);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Todo List')),
body: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return Dismissible(
key: Key(todo.id),
onDismissed: (_) {
_removeTodo(todo.id);
},
child: CheckboxListTile(
title: Text(todo.title),
value: todo.isCompleted,
onChanged: (bool? value) {
if (value != null) {
_toggleTodo(todo.id);
}
},
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_addTodo('New Task ${_todos.length + 1}');
},
child: Icon(Icons.add),
),
);
}
}
// state_management_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'state_management_widget.dart';
void main() {
group('TodoListWidget Tests', () {
testWidgets('should display initial todos', (WidgetTester tester) async {
// Given
await tester.pumpWidget(MaterialApp(home: TodoListWidget()));
// Then
expect(find.text('Task 1'), findsOneWidget);
expect(find.text('Task 2'), findsOneWidget);
expect(find.text('Task 3'), findsOneWidget);
expect(find.byType(CheckboxListTile), findsNWidgets(3));
});
testWidgets('should add new todo when FAB is pressed',
(WidgetTester tester) async {
// Given
await tester.pumpWidget(MaterialApp(home: TodoListWidget()));
// Record initial count
final initialCount = tester.widgetList<CheckboxListTile>(find.byType(CheckboxListTile)).length;
// When
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Then
final newCount = tester.widgetList<CheckboxListTile>(find.byType(CheckboxListTile)).length;
expect(newCount, equals(initialCount + 1));
expect(find.text('New Task 4'), findsOneWidget);
});
testWidgets('should toggle todo completion status',
(WidgetTester tester) async {
// Given
await tester.pumpWidget(MaterialApp(home: TodoListWidget()));
// When - tap the checkbox for first todo
await tester.tap(find.byType(Checkbox).first);
await tester.pump();
// Then - verify it's checked
expect(find.byWidgetPredicate(
(widget) => widget is Checkbox && widget.value == true,
), findsOneWidget);
});
testWidgets('should remove todo when dismissed',
(WidgetTester tester) async {
// Given
await tester.pumpWidget(MaterialApp(home: TodoListWidget()));
final initialCount = tester.widgetList<CheckboxListTile>(find.byType(CheckboxListTile)).length;
// When - swipe to dismiss first item
await tester.drag(find.byType(Dismissible).first, Offset(-500, 0));
await tester.pumpAndSettle(); // Allow dismissal animation to complete
// Then
final newCount = tester.widgetList<CheckboxListTile>(find.byType(CheckboxListTile)).length;
expect(newCount, equals(initialCount - 1));
});
});
}
集成测试
集成测试用于测试多个组件协同工作的功能。
基础集成测试
dart
// integration_test/app_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('App Integration Tests', () {
testWidgets('complete app workflow', (WidgetTester tester) async {
// Given - start the app
app.main();
await tester.pumpAndSettle();
// Then - verify initial state
expect(find.text('Welcome'), findsOneWidget);
expect(find.byType(TextField), findsOneWidget);
expect(find.byType(ElevatedButton), findsOneWidget);
// When - enter text and submit
await tester.enterText(find.byType(TextField), 'Test Input');
await tester.tap(find.byType(ElevatedButton));
await tester.pumpAndSettle();
// Then - verify state change
expect(find.text('Test Input'), findsOneWidget);
expect(find.text('Processed: Test Input'), findsOneWidget);
// When - navigate to another screen
await tester.tap(find.text('Go to Details'));
await tester.pumpAndSettle();
// Then - verify navigation
expect(find.text('Details Screen'), findsOneWidget);
expect(find.text('Back'), findsOneWidget);
// When - go back
await tester.tap(find.text('Back'));
await tester.pumpAndSettle();
// Then - verify back navigation
expect(find.text('Welcome'), findsOneWidget);
});
testWidgets('form validation workflow', (WidgetTester tester) async {
// Given
app.main();
await tester.pumpAndSettle();
// When - submit empty form
await tester.tap(find.text('Submit'));
await tester.pump();
// Then - expect validation errors
expect(find.text('Field is required'), findsOneWidget);
// When - fill valid data and submit
await tester.enterText(find.byType(TextField), 'Valid Input');
await tester.tap(find.text('Submit'));
await tester.pump();
// Then - expect success
expect(find.text('Success'), findsOneWidget);
});
});
}
网络集成测试
dart
// network_integration_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:http/http.dart' as http;
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
// Mock生成注解
@GenerateMocks([http.Client])
import 'network_integration_test.mocks.dart';
class ApiService {
final http.Client client;
ApiService(this.client);
Future<String> getData(String url) async {
final response = await client.get(Uri.parse(url));
if (response.statusCode == 200) {
return response.body;
} else {
throw Exception('Failed to load data');
}
}
}
class DataWidget extends StatefulWidget {
final ApiService apiService;
const DataWidget({Key? key, required this.apiService}) : super(key: key);
@override
_DataWidgetState createState() => _DataWidgetState();
}
class _DataWidgetState extends State<DataWidget> {
String _data = '';
bool _loading = false;
String _error = '';
Future<void> _fetchData() async {
setState(() {
_loading = true;
_error = '';
});
try {
final data = await widget.apiService.getData('https://api.example.com/data');
setState(() {
_data = data;
_loading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_loading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Data Fetching')),
body: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
children: [
ElevatedButton(
onPressed: _fetchData,
child: Text('Fetch Data'),
),
SizedBox(height: 20),
if (_loading)
CircularProgressIndicator()
else if (_error.isNotEmpty)
Text('Error: $_error', style: TextStyle(color: Colors.red))
else if (_data.isNotEmpty)
Text(_data),
],
),
),
);
}
}
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Network Integration Tests', () {
late MockClient mockClient;
late ApiService apiService;
setUp(() {
mockClient = MockClient();
apiService = ApiService(mockClient);
});
testWidgets('should display data when API call succeeds',
(WidgetTester tester) async {
// Given
const testData = '{"message": "Hello World"}';
when(mockClient.get(Uri.parse('https://api.example.com/data')))
.thenAnswer((_) async => http.Response(testData, 200));
await tester.pumpWidget(
MaterialApp(
home: DataWidget(apiService: apiService),
),
);
// When
await tester.tap(find.text('Fetch Data'));
await tester.pump(); // First pump for loading state
await tester.pump(Duration(milliseconds: 100)); // Wait for async call
// Then
verify(mockClient.get(Uri.parse('https://api.example.com/data'))).called(1);
expect(find.text(testData), findsOneWidget);
});
testWidgets('should display error when API call fails',
(WidgetTester tester) async {
// Given
when(mockClient.get(Uri.parse('https://api.example.com/data')))
.thenThrow(Exception('Network error'));
await tester.pumpWidget(
MaterialApp(
home: DataWidget(apiService: apiService),
),
);
// When
await tester.tap(find.text('Fetch Data'));
await tester.pump(); // First pump for loading state
await tester.pump(Duration(milliseconds: 100)); // Wait for async call
// Then
expect(find.textContaining('Exception: Network error'), findsOneWidget);
});
});
}
测试最佳实践
1. 测试结构组织
dart
// well_structured_test.dart
import 'package:flutter_test/flutter_test.dart';
void main() {
group('FeatureUnderTest', () {
// Setup code that runs before each test
setUp(() {
// Initialize dependencies, mocks, etc.
});
tearDown(() {
// Clean up after each test
});
group('when condition A', () {
setUp(() {
// Specific setup for condition A
});
test('should do X', () {
// Given
// When
// Then
});
test('should not do Y', () {
// Given
// When
// Then
});
});
group('when condition B', () {
setUp(() {
// Specific setup for condition B
});
test('should do Z', () {
// Given
// When
// Then
});
});
});
}
2. 测试数据生成
dart
// test_data_generator.dart
class TestDataGenerator {
static User createUser({
int? id,
String? name,
String? email,
}) {
return User(
id: id ?? DateTime.now().millisecondsSinceEpoch,
name: name ?? 'Test User ${DateTime.now().millisecond}',
email: email ?? 'test${DateTime.now().millisecond}@example.com',
);
}
static List<User> createUsers(int count) {
return List.generate(count, (index) => createUser(
id: index,
name: 'User $index',
email: 'user$index@example.com',
));
}
static Map<String, dynamic> createApiResponse({
bool success = true,
String? message,
dynamic data,
}) {
return {
'success': success,
'message': message ?? (success ? 'Success' : 'Error'),
'data': data,
};
}
}
3. 异步测试工具
dart
// async_test_utils.dart
import 'package:fake_async/fake_async.dart';
class AsyncTestUtils {
static void testWithFakeAsync(Function(FakeAsync) testFn) {
fakeAsync((async) {
testFn(async);
async.flushMicrotasks();
});
}
static Future<void> waitForDuration(Duration duration) {
return Future.delayed(duration);
}
static Future<void> waitForCondition(
bool Function() condition, {
Duration timeout = const Duration(seconds: 5),
Duration checkInterval = const Duration(milliseconds: 100),
}) async {
final stopwatch = Stopwatch()..start();
while (!condition() && stopwatch.elapsed < timeout) {
await Future.delayed(checkInterval);
}
if (stopwatch.elapsed >= timeout) {
throw TimeoutException('Condition not met within timeout', timeout);
}
}
}
4. 测试覆盖率
bash
# 运行测试并生成覆盖率报告
flutter test --coverage
# 生成HTML格式的覆盖率报告
genhtml coverage/lcov.info -o coverage/html
# 查看覆盖率摘要
lcov --summary coverage/lcov.info
5. 测试配置
yaml
# analysis_options.yaml
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- "lib/**/*.mocks.dart"
strong-mode:
implicit-dynamic: false
linter:
rules:
- prefer_const_constructors
- prefer_const_literals_to_create_immutables
- avoid_print
- prefer_single_quotes
6. CI/CD测试配置
yaml
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.x'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
- name: Run tests
run: flutter test --coverage
- name: Check formatting
run: flutter format --set-exit-if-changed .
- name: Analyze code
run: flutter analyze
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
Mock工具使用
使用Mockito进行高级模拟
dart
// advanced_mocking_test.dart
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:flutter_test/flutter_test.dart';
// 假设的服务类
abstract class PaymentService {
Future<bool> processPayment(double amount, String currency);
Future<String> getExchangeRate(String fromCurrency, String toCurrency);
}
// 生成Mock的注解
@GenerateMocks([PaymentService])
import 'advanced_mocking_test.mocks.dart';
void main() {
group('Advanced Mocking Tests', () {
late MockPaymentService mockPaymentService;
setUp(() {
mockPaymentService = MockPaymentService();
});
test('should handle different scenarios with argument matchers', () async {
// 给定 - 设置不同的返回值基于参数
when(mockPaymentService.processPayment(
any,
argThat(contains('USD'), named: 'currency'),
)).thenAnswer((_) async => true);
when(mockPaymentService.processPayment(
argThat(greaterThan(1000)),
anyNamed('currency'),
)).thenThrow(Exception('Amount too high'));
// 当 - 调用服务
final result1 = await mockPaymentService.processPayment(500, 'USD');
bool result2 = false;
try {
await mockPaymentService.processPayment(1500, 'EUR');
} catch (e) {
result2 = true; // Expected exception
}
// 那么 - 验证结果
expect(result1, isTrue);
expect(result2, isTrue);
// 验证调用次数和参数
verify(mockPaymentService.processPayment(500, 'USD')).called(1);
verify(mockPaymentService.processPayment(1500, 'EUR')).called(1);
});
test('should verify sequential calls', () async {
// 给定
when(mockPaymentService.getExchangeRate(any, any))
.thenAnswer((_) async => '1.2');
// 当
final rate1 = await mockPaymentService.getExchangeRate('USD', 'EUR');
final rate2 = await mockPaymentService.getExchangeRate('EUR', 'GBP');
// 那么
verifyInOrder([
mockPaymentService.getExchangeRate('USD', 'EUR'),
mockPaymentService.getExchangeRate('EUR', 'GBP'),
]);
verifyNoMoreInteractions(mockPaymentService);
});
});
}
性能测试
dart
// performance_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('measure widget build performance', (WidgetTester tester) async {
final stopwatch = Stopwatch()..start();
await tester.pumpWidget(
MaterialApp(
home: ListView.builder(
itemCount: 1000,
itemBuilder: (context, index) => ListTile(
title: Text('Item $index'),
),
),
),
);
stopwatch.stop();
print('Build time: ${stopwatch.elapsedMicroseconds} μs');
// 断言构建时间在可接受范围内(例如小于100ms)
expect(stopwatch.elapsedMilliseconds, lessThan(100));
});
test('measure algorithm performance', () {
final stopwatch = Stopwatch()..start();
// 测试算法性能
final result = List.generate(10000, (index) => index * 2)
.where((element) => element.isEven)
.toList();
stopwatch.stop();
print('Algorithm time: ${stopwatch.elapsedMicroseconds} μs');
expect(stopwatch.elapsedMilliseconds, lessThan(50));
expect(result.length, equals(10000)); // All numbers are even after *2
});
}
测试工具和技巧
Golden文件测试
dart
// golden_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('CounterWidget should match golden file', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: Text(
'Golden Test',
style: TextStyle(fontSize: 24),
),
),
),
),
);
await expectLater(
find.byType(Scaffold),
matchesGoldenFile('goldens/snapshot.png'),
);
});
}
测试驱动开发(TDD)示例
dart
// tdd_example_test.dart (First write the test)
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Calculator TDD', () {
test('multiply should return product of two numbers', () {
// First write failing test
final calculator = Calculator(); // This class doesn't exist yet
final result = calculator.multiply(3, 4); // This method doesn't exist yet
expect(result, equals(12));
});
});
}
// Then create the implementation
class Calculator {
int multiply(int a, int b) {
return a * b;
}
}
总结
Flutter测试策略包括:
- 单元测试:测试独立的功能单元
- 小部件测试:测试UI组件的表现和交互
- 集成测试:测试多组件协作
- 端到端测试:测试完整应用流程
测试最佳实践:
- 使用Given-When-Then模式编写测试
- 合理使用Mock进行依赖隔离
- 保持测试独立和可重复
- 使用有意义的测试名称
- 测试边界条件和异常情况
- 维护适当的测试覆盖率
通过全面的测试策略,可以确保Flutter应用的质量、稳定性和可维护性。