← Frontend / Flutter

15_测试

Flutter 测试框架和测试方法

单元测试、Widget 测试、集成测试,对应 Jest / Vitest 的测试体系。


测试类型

类型 测试对象 速度 类比
单元测试 函数 / 类 / 逻辑 ⚡ 最快 Jest unit test
Widget 测试 单个 Widget 的 UI 和交互 快 RTL / Vue Test Utils
集成测试 多个组件 + 真实设备 慢 Cypress / Playwright

单元测试

// test/models/cart_model_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/models/cart_model.dart';

void main() {
  group('CartModel', () {
    late CartModel cart;

    setUp(() {
      cart = CartModel(); // 每个测试前重建(类似 beforeEach)
    });

    tearDown(() {
      // 每个测试后清理(类似 afterEach)
    });

    test('初始状态为空', () {
      expect(cart.count, 0);
      expect(cart.items, isEmpty);
    });

    test('添加商品', () {
      cart.add('苹果');
      expect(cart.count, 1);
      expect(cart.items, contains('苹果'));
    });

    test('删除商品', () {
      cart.add('香蕉');
      cart.remove('香蕉');
      expect(cart.count, 0);
    });

    test('清空购物车', () {
      cart.add('苹果');
      cart.add('香蕉');
      cart.clear();
      expect(cart.items, isEmpty);
    });
  });

  group('ApiService', () {
    test('解析 Post 模型', () {
      final json = {'id': 1, 'title': '标题', 'body': '内容'};
      final post = Post.fromJson(json);

      expect(post.id, 1);
      expect(post.title, '标题');
    });
  });
}

常用 Matchers(断言方法):

expect(value, equals(expected))       // 相等
expect(value, isNull)                 // 为 null
expect(value, isNotNull)              // 不为 null
expect(value, isTrue / isFalse)       // 布尔值
expect(value, isEmpty)                // 空集合
expect(list, contains('item'))        // 包含元素
expect(list, hasLength(3))            // 长度
expect(value, greaterThan(0))         // 大于
expect(value, isA<String>())          // 类型检查
expect(() => fn(), throwsException)   // 抛出异常

Widget 测试

// test/widgets/user_card_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/widgets/user_card.dart';

void main() {
  testWidgets('UserCard 正确显示用户信息', (WidgetTester tester) async {
    // 构建 Widget
    await tester.pumpWidget(
      const MaterialApp(
        home: UserCard(name: 'Alice', followerCount: 1024),
      ),
    );

    // 查找并验证
    expect(find.text('Alice'), findsOneWidget);
    expect(find.text('1024 followers'), findsOneWidget);
    expect(find.byType(CircleAvatar), findsOneWidget);
    expect(find.byType(Card), findsOneWidget);
  });

  testWidgets('按钮点击触发回调', (WidgetTester tester) async {
    int tapCount = 0;
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: ElevatedButton(
            onPressed: () => tapCount++,
            child: const Text('点我'),
          ),
        ),
      ),
    );

    await tester.tap(find.text('点我'));
    await tester.pump();            // 触发重建

    expect(tapCount, 1);
  });

  testWidgets('表单验证', (WidgetTester tester) async {
    await tester.pumpWidget(const MaterialApp(home: LoginForm()));

    // 点击提交(不填写任何内容)
    await tester.tap(find.text('登录'));
    await tester.pump();

    // 应该显示验证错误
    expect(find.text('邮箱不能为空'), findsOneWidget);
    expect(find.text('密码不能为空'), findsOneWidget);

    // 填写内容
    await tester.enterText(find.byType(TextFormField).first, 'test@example.com');
    await tester.enterText(find.byType(TextFormField).last, '123456');
    await tester.tap(find.text('登录'));
    await tester.pump();

    // 验证错误应该消失
    expect(find.text('邮箱不能为空'), findsNothing);
  });

  testWidgets('加载异步数据', (WidgetTester tester) async {
    await tester.pumpWidget(const MaterialApp(home: PostsPage()));

    // 初始显示 loading
    expect(find.byType(CircularProgressIndicator), findsOneWidget);

    // 等待异步操作完成
    await tester.pumpAndSettle();

    // 数据加载后,loading 消失
    expect(find.byType(CircularProgressIndicator), findsNothing);
  });
}

常用 Finder:

find.text('文字')              // 通过文字查找
find.byType(ElevatedButton)   // 通过 Widget 类型
find.byKey(const Key('xxx'))  // 通过 Key(推荐用于测试)
find.byIcon(Icons.search)     // 通过图标
find.ancestor(of: ..., matching: ...) // 父节点查找
find.descendant(of: ..., matching: ...)// 子节点查找

// 验证数量
findsOneWidget                // 恰好一个
findsNothing                  // 不存在
findsNWidgets(3)               // 恰好 N 个
findsAtLeastNWidgets(2)        // 至少 N 个

Mock(模拟依赖)

dev_dependencies:
  mockito: ^5.4.0
  build_runner: ^2.4.0
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';

@GenerateMocks([ApiService])   // 自动生成 Mock 类
void main() {}

// 运行生成:dart run build_runner build

// test/services/api_service_test.dart
void main() {
  late MockApiService mockApi;

  setUp(() {
    mockApi = MockApiService();
  });

  test('fetchPosts 成功', () async {
    when(mockApi.get('/posts')).thenAnswer((_) async => [
      {'id': 1, 'title': '测试文章', 'body': '内容'},
    ]);

    final posts = await mockApi.get('/posts');
    expect(posts, isA<List>());
    verify(mockApi.get('/posts')).called(1);
  });

  test('fetchPosts 网络错误', () async {
    when(mockApi.get('/posts')).thenThrow(ApiException('网络错误'));

    expect(() => mockApi.get('/posts'), throwsA(isA<ApiException>()));
  });
}

运行测试

# 运行所有测试
flutter test

# 运行指定文件
flutter test test/models/cart_model_test.dart

# 运行指定目录
flutter test test/models/

# 带覆盖率(生成 coverage/ 目录)
flutter test --coverage

# 查看覆盖率报告(需要安装 lcov)
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html

# 监听模式(文件变化时自动重跑)
flutter test --watch