Flutter 测试策略与自动化

Flutter 测试金字塔:单元测试、Widget 测试、集成测试、Golden 测试的完整实践与覆盖率分析。

开篇:为什么 Flutter 测试比想象中重要

Flutter 的声明式 UI 架构有一个天然优势:UI 是状态的纯函数(UI = f(State))。这意味着只要给定固定的状态输入,UI 输出就是确定的——这使得 Widget 测试不仅可行,而且高度可靠。相比之下,传统的命令式 UI 框架需要模拟复杂的视图层级和生命周期,测试成本成倍增加。

Flutter 测试遵循经典的三层金字塔:底层是大量的单元测试(运行最快、成本最低),中层是 Widget 测试(验证 UI 行为),顶层是少量的端到端集成测试(验证完整用户流程)。本章将在每一层提供完整的代码示例和最佳实践。


一、测试概览与运行

# 运行所有测试
flutter test

# 运行特定文件
flutter test test/user_repository_test.dart

# 带覆盖率报告
flutter test --coverage

genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html  # 查看覆盖率报告

# Widget 测试(带可视化调试)
flutter test --update-goldens  # 更新 Golden 文件
flutter test --plain-name "Counter increments"  # 按名称过滤

二、单元测试

2.1 测试纯函数

// lib/calculator.dart
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('除数不能为零');
    return a / b;
  }
}

// test/calculator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/calculator.dart';

void main() {
  group('Calculator', () {
    late Calculator calculator;
    
    setUp(() {
      calculator = Calculator();
    });
    
    test('add 返回两数之和', () {
      expect(calculator.add(2, 3), equals(5));
      expect(calculator.add(-1, 1), equals(0));
      expect(calculator.add(0, 0), equals(0));
    });
    
    test('subtract 返回两数之差', () {
      expect(calculator.subtract(5, 3), equals(2));
      expect(calculator.subtract(3, 5), equals(-2));
    });
    
    test('divide 返回商', () {
      expect(calculator.divide(6, 2), equals(3.0));
      expect(calculator.divide(5, 2), equals(2.5));
    });
    
    test('divide 零除时抛出异常', () {
      expect(
        () => calculator.divide(5, 0),
        throwsArgumentError,
      );
    });
  });
}

2.2 使用 Mockito 模拟依赖

// lib/todo_repository.dart
abstract class TodoApi {
  Future<List<Todo>> fetchTodos();
  Future<void> createTodo(String title);
}

class TodoRepository {
  final TodoApi api;
  TodoRepository(this.api);
  
  Future<List<Todo>> getTodos() async {
    return await api.fetchTodos();
  }
  
  Future<void> addTodo(String title) async {
    if (title.isEmpty) throw ValidationError('标题不能为空');
    await api.createTodo(title);
  }
}

// test/todo_repository_test.dart
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'todo_repository_test.mocks.dart';

@GenerateMocks([TodoApi])
void main() {
  group('TodoRepository', () {
    late MockTodoApi mockApi;
    late TodoRepository repository;
    
    setUp(() {
      mockApi = MockTodoApi();
      repository = TodoRepository(mockApi);
    });
    
    test('getTodos 返回 API 数据', () async {
      // Arrange
      final todos = [Todo(id: '1', title: '测试')];
      when(mockApi.fetchTodos()).thenAnswer((_) async => todos);
      
      // Act
      final result = await repository.getTodos();
      
      // Assert
      expect(result, equals(todos));
      verify(mockApi.fetchTodos()).called(1);
    });
    
    test('addTodo 空标题抛出异常', () async {
      expect(
        () => repository.addTodo(''),
        throwsA(isA<ValidationError>()),
      );
      verifyNever(mockApi.createTodo(any));
    });
    
    test('addTodo 非空标题调用 API', () async {
      when(mockApi.createTodo('新任务')).thenAnswer((_) async {});
      
      await repository.addTodo('新任务');
      
      verify(mockApi.createTodo('新任务')).called(1);
    });
  });
}

2.3 异步代码测试

group('异步测试', () {
  test('Future completes with value', () async {
    final result = await fetchData();
    expect(result, isNotNull);
  });
  
  test('Stream  emits  expected  values', () {
    final stream = Stream.fromIterable([1, 2, 3]);
    
    expect(
      stream,
      emitsInOrder([1, 2, 3, emitsDone]),
    );
  });
  
  test('Stream periodic', () async {
    final stream = Stream.periodic(
      Duration(milliseconds: 100),
      (i) => i,
    ).take(3);
    
    await expectLater(
      stream,
      emitsInOrder([0, 1, 2, emitsDone]),
    );
  });
  
  test('Timeout test', () async {
    await expectLater(
      slowOperation().timeout(Duration(seconds: 1)),
      throwsA(isA<TimeoutException>()),
    );
  });
});

一句话总结:单元测试是测试金字塔的基石,Mockito 让依赖隔离测试变得简单,而 expect(emitsInOrder) 让 Stream 测试像列表断言一样直观。


三、Widget 测试

3.1 基础 Widget 测试

// lib/counter_page.dart
class CounterPage extends StatefulWidget {
  @override
  _CounterPageState createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _counter = 0;
  
  void _increment() => setState(() => _counter++);
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('Counter')),
        body: Center(child: Text('$_counter', key: Key('counter'))),
        floatingActionButton: FloatingActionButton(
          key: Key('increment'),
          onPressed: _increment,
          child: Icon(Icons.add),
        ),
      ),
    );
  }
}

// test/counter_page_test.dart
void main() {
  testWidgets('Counter increments when button is tapped',
      (WidgetTester tester) async {
    // 构建 Widget
    await tester.pumpWidget(CounterPage());
    
    // 查找初始计数器值
    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsNothing);
    
    // 点击按钮
    await tester.tap(find.byKey(Key('increment')));
    await tester.pump();  // 重建 Widget
    
    // 验证
    expect(find.text('0'), findsNothing);
    expect(find.text('1'), findsOneWidget);
  });
}

3.2 测试 Provider/Riverpod 集成

// 测试带有状态管理的 Widget
testWidgets('购物车添加商品', (WidgetTester tester) async {
  await tester.pumpWidget(
    ProviderScope(
      child: MaterialApp(home: ProductList()),
    ),
  );
  
  // 查找第一个商品
  expect(find.text('iPhone 15'), findsOneWidget);
  
  // 点击"加入购物车"
  await tester.tap(find.byIcon(Icons.add_shopping_cart).first);
  await tester.pumpAndSettle();
  
  // 验证购物车徽标
  expect(find.text('1'), findsOneWidget);
});

3.3 表单验证测试

testWidgets('登录表单验证', (WidgetTester tester) async {
  await tester.pumpWidget(MaterialApp(home: LoginPage()));
  
  // 不填表单直接提交
  await tester.tap(find.byType(ElevatedButton));
  await tester.pump();
  
  // 验证错误提示
  expect(find.text('请输入邮箱'), findsOneWidget);
  expect(find.text('请输入密码'), findsOneWidget);
  
  // 输入无效邮箱
  await tester.enterText(find.byKey(Key('email')), 'invalid');
  await tester.tap(find.byType(ElevatedButton));
  await tester.pump();
  
  expect(find.text('邮箱格式不正确'), findsOneWidget);
  
  // 输入正确信息
  await tester.enterText(find.byKey(Key('email')), 'test@example.com');
  await tester.enterText(find.byKey(Key('password')), 'password123');
  await tester.tap(find.byType(ElevatedButton));
  await tester.pump();
  
  // 错误提示消失
  expect(find.text('请输入邮箱'), findsNothing);
});

一句话总结:Widget 测试通过纯 Dart 代码模拟用户交互,无需启动真机或模拟器,运行速度比集成测试快两个数量级。


四、集成测试

// integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:myapp/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
  
  group('端到端测试', () {
    testWidgets('完整登录流程', (WidgetTester tester) async {
      app.main();
      await tester.pumpAndSettle();
      
      // 在登录页
      expect(find.text('登录'), findsOneWidget);
      
      // 输入凭证
      await tester.enterText(
        find.byKey(Key('email_field')),
        'user@example.com',
      );
      await tester.enterText(
        find.byKey(Key('password_field')),
        'password123',
      );
      
      // 点击登录
      await tester.tap(find.byKey(Key('login_button')));
      await tester.pumpAndSettle(Duration(seconds: 2));
      
      // 验证登录成功
      expect(find.text('首页'), findsOneWidget);
      expect(find.byKey(Key('welcome_message')), findsOneWidget);
    });
    
    testWidgets('添加任务流程', (WidgetTester tester) async {
      app.main();
      await tester.pumpAndSettle();
      
      // 导航到任务页
      await tester.tap(find.byIcon(Icons.task));
      await tester.pumpAndSettle();
      
      // 点击添加按钮
      await tester.tap(find.byType(FloatingActionButton));
      await tester.pumpAndSettle();
      
      // 输入任务
      await tester.enterText(
        find.byType(TextField).first,
        '编写 Flutter 集成测试',
      );
      
      // 保存
      await tester.tap(find.text('保存'));
      await tester.pumpAndSettle();
      
      // 验证任务出现在列表
      expect(find.text('编写 Flutter 集成测试'), findsOneWidget);
    });
  });
}

一句话总结:集成测试运行在真机或模拟器上,验证完整用户流程,虽然运行较慢,但对核心路径的保障不可或缺。


五、Golden 测试(UI 回归)

testWidgets('登录页面 Golden 测试', (WidgetTester tester) async {
  await tester.pumpWidget(MaterialApp(home: LoginPage()));
  await tester.pumpAndSettle();
  
  await expectLater(
    find.byType(LoginPage),
    matchesGoldenFile('goldens/login_page.png'),
  );
});

一句话总结:Golden 测试通过像素级对比防止 UI 非预期变更,是设计系统维护的重要工具。


FAQ

Q1: 测试运行很慢怎么办?

  • 单元测试应该在毫秒级完成
  • Widget 测试避免 pumpAndSettle(使用 pump 指定帧数)
  • 使用 setUp 而非 setUpAll 避免状态污染

Q2: 如何处理平台通道测试?

setUp(() {
  const channel = MethodChannel('com.example.app/device');
  TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
      .setMockMethodCallHandler(channel, (call) async {
    if (call.method == 'getBatteryLevel') return '85%';
    return null;
  });
});

Q3: 覆盖率目标应该是多少?

  • 核心业务逻辑:> 80%
  • UI 层:> 50%(关键交互路径)
  • 避免追求 100% 覆盖率(边际收益递减)

相关阅读

  • https://plumephp.com/flutter-state-management/ — 状态管理(含测试策略)

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Flutter」更多文章

  1. Widget 体系与布局系统
  2. Flutter 状态管理全解析
  3. Flutter 架构模式与最佳实践