
// 簡報
測試不只是一種良好習慣——它是構建可靠 Flutter 應用程式的必要條件。妥善的測試能節省時間、防止錯誤流入生產環境,更讓你有信心重構程式碼、加入新功能,而不怕破壞現有功能。
Flutter 本身已內置出色的測試工具。這份完整指南涵蓋 unit test、widget test、integration test,以及成功 Flutter 團隊採用的進階測試模式。你將學會編寫易於維護、快速而可靠的測試。
UNIT TEST:測試你的邏輯
Unit test 用於獨立驗證個別函數、方法及類別。它們速度快、容易編寫,是測試金字塔的基石。
calculator_test.dart
// Model class to test
class Calculator {
int add(int a, int b) => a + b;
int subtract(int a, int b) => a - b;
double divide(int a, int b) {
if (b == 0) throw ArgumentError('Cannot divide by zero');
return a / b;
}
}
// Unit tests
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/calculator.dart';
void main() {
group('Calculator', () {
late Calculator calculator;
setUp(() {
calculator = Calculator();
});
test('should add two numbers correctly', () {
// Arrange
const a = 5;
const b = 3;
// Act
final result = calculator.add(a, b);
// Assert
expect(result, equals(8));
});
test('should subtract two numbers correctly', () {
expect(calculator.subtract(10, 3), equals(7));
});
test('should divide two numbers correctly', () {
expect(calculator.divide(10, 2), equals(5.0));
});
test('should throw error when dividing by zero', () {
expect(
() => calculator.divide(10, 0),
throwsA(isA<ArgumentError>()),
);
});
});
group('Calculator Edge Cases', () {
test('should handle negative numbers', () {
final calculator = Calculator();
expect(calculator.add(-5, 3), equals(-2));
expect(calculator.subtract(-5, -3), equals(-2));
});
test('should handle large numbers', () {
final calculator = Calculator();
expect(calculator.add(999999, 1), equals(1000000));
});
});
}Unit Test 最佳實踐:
- 遵循 AAA 模式:Arrange(準備)、Act(執行)、Assert(斷言)
- 使用能清楚描述行為的測試名稱
- 每次只測試一件事
- 以 setUp() 和 tearDown() 處理共用的初始化
- 將相關測試分組管理
WIDGET TEST:測試你的 UI
Widget test 用於驗證 UI 組件是否運作正常。它們在受控環境下測試用戶互動、狀態變化及 widget 行為。
widget_test.dart
// Widget to test
class CounterWidget extends StatefulWidget {
@override
_CounterWidgetState createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _counter = 0;
void _increment() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Counter')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Count: $_counter', key: Key('counter-text')),
ElevatedButton(
key: Key('increment-button'),
onPressed: _increment,
child: Text('Increment'),
),
],
),
),
);
}
}
// Widget tests
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/counter_widget.dart';
void main() {
group('CounterWidget', () {
testWidgets('should display initial counter value', (WidgetTester tester) async {
// Arrange & Act
await tester.pumpWidget(MaterialApp(home: CounterWidget()));
// Assert
expect(find.text('Count: 0'), findsOneWidget);
expect(find.text('Increment'), findsOneWidget);
});
testWidgets('should increment counter when button is pressed', (WidgetTester tester) async {
// Arrange
await tester.pumpWidget(MaterialApp(home: CounterWidget()));
// Act
await tester.tap(find.byKey(Key('increment-button')));
await tester.pump(); // Trigger rebuild
// Assert
expect(find.text('Count: 1'), findsOneWidget);
expect(find.text('Count: 0'), findsNothing);
});
testWidgets('should increment multiple times', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(home: CounterWidget()));
// Tap button 3 times
for (int i = 0; i < 3; i++) {
await tester.tap(find.byKey(Key('increment-button')));
await tester.pump();
}
expect(find.text('Count: 3'), findsOneWidget);
});
});
group('CounterWidget Accessibility', () {
testWidgets('should be accessible', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(home: CounterWidget()));
// Check semantic labels
expect(tester.getSemantics(find.byKey(Key('counter-text'))),
matchesSemantics(label: 'Count: 0'));
});
});
}Widget Test 貼士:
- 使用 key 準確地定位 widget
- 狀態變更後記得呼叫 pump()
- 測試點按、捲動等用戶互動
- 驗證 widget 的屬性與行為
- 測試無障礙功能
INTEGRATION TEST:端到端
Integration test 用於驗證應用程式各部分能否正確協作。它們在裝置或模擬器上測試完整的用戶流程和真實應用行為。
integration_test/app_test.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 'package:your_app/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('App Integration Tests', () {
testWidgets('complete user flow test', (WidgetTester tester) async {
// Start the app
app.main();
await tester.pumpAndSettle();
// Test login flow
await tester.enterText(find.byKey(Key('email-field')), 'test@example.com');
await tester.enterText(find.byKey(Key('password-field')), 'password123');
await tester.tap(find.byKey(Key('login-button')));
await tester.pumpAndSettle();
// Verify successful login
expect(find.text('Welcome Back!'), findsOneWidget);
// Test navigation to profile
await tester.tap(find.byKey(Key('profile-tab')));
await tester.pumpAndSettle();
// Verify profile screen
expect(find.text('Profile'), findsOneWidget);
expect(find.text('test@example.com'), findsOneWidget);
// Test logout
await tester.tap(find.byKey(Key('logout-button')));
await tester.pumpAndSettle();
// Verify back to login screen
expect(find.byKey(Key('login-button')), findsOneWidget);
});
testWidgets('offline functionality test', (WidgetTester tester) async {
app.main();
await tester.pumpAndSettle();
// Simulate offline mode
await tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('connectivity_plus'),
(MethodCall methodCall) async {
if (methodCall.method == 'check') {
return 'none'; // No connectivity
}
return null;
},
);
// Test app behavior when offline
await tester.tap(find.byKey(Key('refresh-button')));
await tester.pumpAndSettle();
expect(find.text('No internet connection'), findsOneWidget);
});
});
group('Performance Tests', () {
testWidgets('scroll performance test', (WidgetTester tester) async {
app.main();
await tester.pumpAndSettle();
// Navigate to list screen
await tester.tap(find.byKey(Key('list-tab')));
await tester.pumpAndSettle();
// Measure scroll performance
final Stopwatch stopwatch = Stopwatch()..start();
await tester.fling(find.byType(ListView), const Offset(0, -500), 1000);
await tester.pumpAndSettle();
stopwatch.stop();
// Assert reasonable performance (adjust threshold as needed)
expect(stopwatch.elapsedMilliseconds, lessThan(1000));
});
});
}Integration Test 最佳實踐:
- 對關鍵用戶流程進行端到端測試
- 以 pumpAndSettle() 處理異步操作
- 盡可能在真實裝置上測試
- 適當地 mock 外部依賴
- 量度並測試效能
進階測試模式
透過 mocking、golden test 及各種測試模式,讓你的測試更易維護、更可靠,把測試水平提升到另一層次。
user_repository_test.dart
// Mocking with Mockito
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
@GenerateMocks([ApiService, DatabaseService])
import 'test_file.mocks.dart';
class UserRepository {
final ApiService apiService;
final DatabaseService dbService;
UserRepository(this.apiService, this.dbService);
Future<User> getUser(String id) async {
try {
final user = await apiService.fetchUser(id);
await dbService.saveUser(user);
return user;
} catch (e) {
return await dbService.getUser(id);
}
}
}
// Test with mocks
void main() {
group('UserRepository', () {
late MockApiService mockApiService;
late MockDatabaseService mockDbService;
late UserRepository userRepository;
setUp(() {
mockApiService = MockApiService();
mockDbService = MockDatabaseService();
userRepository = UserRepository(mockApiService, mockDbService);
});
test('should return user from API and save to database', () async {
// Arrange
const userId = '123';
final expectedUser = User(id: userId, name: 'John Doe');
when(mockApiService.fetchUser(userId))
.thenAnswer((_) async => expectedUser);
when(mockDbService.saveUser(expectedUser))
.thenAnswer((_) async => {});
// Act
final result = await userRepository.getUser(userId);
// Assert
expect(result, equals(expectedUser));
verify(mockApiService.fetchUser(userId)).called(1);
verify(mockDbService.saveUser(expectedUser)).called(1);
});
test('should fallback to database when API fails', () async {
// Arrange
const userId = '123';
final expectedUser = User(id: userId, name: 'John Doe');
when(mockApiService.fetchUser(userId))
.thenThrow(Exception('Network error'));
when(mockDbService.getUser(userId))
.thenAnswer((_) async => expectedUser);
// Act
final result = await userRepository.getUser(userId);
// Assert
expect(result, equals(expectedUser));
verify(mockApiService.fetchUser(userId)).called(1);
verify(mockDbService.getUser(userId)).called(1);
verifyNever(mockDbService.saveUser(any));
});
});
}
// Golden Tests for UI consistency
testWidgets('login screen golden test', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(home: LoginScreen()));
await expectLater(
find.byType(LoginScreen),
matchesGoldenFile('golden/login_screen.png'),
);
});
// Custom matchers
Matcher hasErrorMessage(String message) {
return predicate<Widget>((widget) {
if (widget is Text) {
return widget.data == message &&
widget.style?.color == Colors.red;
}
return false;
}, 'has error message: $message');
}進階測試貼士:
- 使用依賴注入,讓測試更容易
- Mock 外部服務與 API
- 以 golden test 確保 UI 一致性
- 為特定領域的斷言編寫自訂 matcher
- 以 test data builder 構建複雜物件
測試金字塔
UNIT TESTS
佔測試的 70%
- 執行速度快
- 組件相互隔離
- 容易除錯
- 覆蓋率高
WIDGET TESTS
佔測試的 20%
- UI 互動
- 狀態管理
- Widget 行為
- 速度適中
INTEGRATION TESTS
佔測試的 10%
- 端到端流程
- 真實用戶場景
- 完整應用程式測試
- 執行速度較慢


