返回網誌
測試2025年2月10日安安科技團隊閱讀時間 12 分鐘

FLUTTER 測試
策略

全面講解 Flutter 的 unit test、widget test 與 integration test。以穩健的測試策略,為你的程式碼建立十足信心。

Flutter 測試策略
// 簡報

測試不只是一種良好習慣——它是構建可靠 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%
  • 端到端流程
  • 真實用戶場景
  • 完整應用程式測試
  • 執行速度較慢
// 覆蓋率

測試需要幫手?

我們的團隊可以協助你為 Flutter 應用程式建立全面的測試策略。由測試環境設置到 CI/CD 整合,我們一手包辦!

聯絡我們
// 訊號放大

分享本指南