Flutter测试实战:从单元测试到集成测试

Flutter测试实战:从单元测试到集成测试

Flutter 项目的测试是个容易被忽视的环节。项目赶进度的时候,测试代码总是被第一个砍掉;项目稳定后,又觉得反正功能都正常,没必要补测试。

结果是:代码改多了心里没底,重构不敢动,bug 越加越多。

整理一下 Flutter 项目的测试实践,包括单元测试、Widget 测试和集成测试,以及怎么在实际项目中落地。

Flutter 测试框架

Flutter 自带 flutter_test 库,基于 Dart 的 test 包。测试文件放在 test/ 目录下:

test/
├── unit/           # 单元测试
├── widget/         # Widget 测试
├── integration/    # 集成测试
└── helpers/        # 测试辅助工具

单元测试:测试业务逻辑

单元测试的对象是纯 Dart 类,和 Flutter UI 无关。适合测试:

  • Service 类的业务逻辑
  • Repository 类的数据处理
  • Model 类的序列化/反序列化
  • 工具函数

示例:测试 ProductService

// lib/features/product/services/product_service.dart
class ProductService {
  final ProductRepository _repository;

  ProductService(this._repository);

  Future<List<Product>> getProducts({
    required int page,
    int pageSize = 20,
    String? categoryId,
  }) async {
    if (page < 1) {
      throw ArgumentError('page must be >= 1');
    }

    final products = await _repository.getProducts(
      page: page,
      pageSize: pageSize,
    );

    if (categoryId != null) {
      return products.where((p) => p.categoryId == categoryId).toList();
    }
    return products;
  }

  double calculateDiscount(Product product, UserLevel level) {
    switch (level) {
      case UserLevel.vip:
        return product.price * 0.8;
      case UserLevel.svip:
        return product.price * 0.7;
      case UserLevel.normal:
        return product.price;
    }
  }
}

对应的单元测试:

// test/unit/product_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:my_app/features/product/services/product_service.dart';
import 'package:my_app/features/product/models/product.dart';
import 'package:my_app/features/product/repositories/product_repository.dart';

import 'product_service_test.mocks.dart';

([ProductRepository])
void main() {
  late ProductService service;
  late MockProductRepository mockRepository;

  setUp(() {
    mockRepository = MockProductRepository();
    service = ProductService(mockRepository);
  });

  group('getProducts', () {
    test('page < 1 时抛出 ArgumentError', () {
      expect(
        () => service.getProducts(page: 0),
        throwsA(isA<ArgumentError>()),
      );
    });

    test('page = 1 时调用 repository 的正确参数', () async {
      when(mockRepository.getProducts(page: 1, pageSize: 20))
          .thenAnswer((_) async => []);

      await service.getProducts(page: 1);

      verify(mockRepository.getProducts(page: 1, pageSize: 20)).called(1);
    });

    test('传入 categoryId 时过滤结果', () async {
      final products = [
        Product(id: '1', name: 'A', price: 100, categoryId: 'cat1'),
        Product(id: '2', name: 'B', price: 200, categoryId: 'cat2'),
      ];
      when(mockRepository.getProducts(page: 1, pageSize: 20))
          .thenAnswer((_) async => products);

      final result = await service.getProducts(
        page: 1,
        categoryId: 'cat1',
      );

      expect(result.length, 1);
      expect(result.first.id, '1');
    });
  });

  group('calculateDiscount', () {
    test('VIP 用户 8 折', () {
      final product = Product(id: '1', name: 'A', price: 100);
      expect(service.calculateDiscount(product, UserLevel.vip), 80);
    });

    test('SVIP 用户 7 折', () {
      final product = Product(id: '1', name: 'A', price: 100);
      expect(service.calculateDiscount(product, UserLevel.svip), 70);
    });

    test('普通用户不打折', () {
      final product = Product(id: '1', name: 'A', price: 100);
      expect(service.calculateDiscount(product, UserLevel.normal), 100);
    });
  });
}

mockito 生成 mock 类:

flutter pub run build_runner build --delete-conflicting-outputs

单元测试的命名规范

测试方法的命名我倾向用 method_condition_expectedResult 的格式:

test('getProducts_page小于1时抛出ArgumentError')
test('getProducts_传入categoryId时返回过滤后结果')
test('calculateDiscount_VIP用户返回8折价格')

这种命名的好处是测试失败时,从名字就能知道是哪个场景出问题。

Widget 测试:测试 UI 组件

Widget 测试测试的是 Flutter Widget 的渲染和交互。适合测试:

  • 自定义组件的渲染逻辑
  • 用户交互(点击、输入)的响应
  • 状态变化时的 UI 更新

示例:测试 ProductCard Widget

// lib/shared/widgets/product_card.dart
class ProductCard extends StatelessWidget {
  final Product product;
  final VoidCallback? onTap;

  const ProductCard({
    super.key,
    required this.product,
    this.onTap,
  });

  
  Widget build(BuildContext context) {
    return InkWell(
      onTap: onTap,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          CachedNetworkImage(imageUrl: product.imageUrl),
          SizedBox(height: 8),
          Text(product.name),
          Text(
            ${product.price}',
            style: TextStyle(fontWeight: FontWeight.bold),
          ),
        ],
      ),
    );
  }
}

对应的 Widget 测试:

// test/widget/product_card_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/shared/widgets/product_card.dart';
import 'package:my_app/features/product/models/product.dart';

void main() {
  group('ProductCard', () {
    testWidgets('显示商品名称和价格', (tester) async {
      final product = Product(
        id: '1',
        name: '测试商品',
        price: 99.9,
        imageUrl: 'https://example.com/image.jpg',
      );

      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            body: ProductCard(product: product),
          ),
        ),
      );

      expect(find.text('测试商品'), findsOneWidget);
      expect(find.text('¥99.9'), findsOneWidget);
    });

    testWidgets('点击时调用 onTap', (tester) async {
      bool tapped = false;
      final product = Product(
        id: '1',
        name: '测试商品',
        price: 99.9,
        imageUrl: 'https://example.com/image.jpg',
      );

      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            body: ProductCard(
              product: product,
              onTap: () => tapped = true,
            ),
          ),
        ),
      );

      await tester.tap(find.byType(InkWell));
      await tester.pump();

      expect(tapped, true);
    });

    testWidgets('没有 onTap 时点击不报错', (tester) async {
      final product = Product(
        id: '1',
        name: '测试商品',
        price: 99.9,
        imageUrl: 'https://example.com/image.jpg',
      );

      await tester.pumpWidget(
        MaterialApp(
          home: Scaffold(
            body: ProductCard(product: product),
          ),
        ),
      );

      // 不应该抛出任何异常
      await tester.tap(find.byType(InkWell));
      await tester.pump();
    });
  });
}

Widget 测试的关键点

  1. pump() vs pumpAndSettle():- pump():让测试框架推进一帧

    • pumpAndSettle():持续 pump 直到所有动画完成(超时则失败)

    对于有动画的 Widget,用 pumpAndSettle() 会等动画结束,但容易超时;用 pump(Duration) 可以精确控制时间。

  2. find.byType vs find.text:优先用 find.byType 定位,因为文本可能重复出现。

  3. 模拟网络图片CachedNetworkImage 会发起真实网络请求,测试时需要 mock:

testWidgets('显示占位图时展示正确内容', (tester) async {
  // 用 NetworkImage 而不是 CachedNetworkImage 测试
  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: Image.network('https://example.com/test.jpg'),
      ),
    ),
  );

  // pump 一次让 Image 进入 loading 状态
  await tester.pump();

  // 验证 loading 状态(如果有占位图逻辑)
}

集成测试:测试完整流程

集成测试测试的是完整的用户流程,从启动 App 到完成某个操作。Flutter 的集成测试会启动完整的应用实例。

示例:测试登录流程

// test_integration/login_flow_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('登录流程', () {
    testWidgets('正确账号密码登录成功', (tester) async {
      // 启动 App
      app.main();
      await tester.pumpAndSettle();

      // 输入手机号
      await tester.enterText(
        find.byKey(Key('phone_input')),
        '13800138000',
      );

      // 输入验证码
      await tester.enterText(
        find.byKey(Key('code_input')),
        '123456',
      );

      // 点击登录按钮
      await tester.tap(find.byKey(Key('login_button')));
      await tester.pumpAndSettle();

      // 验证跳转到首页
      expect(find.byType(AppHomePage), findsOneWidget);
    });

    testWidgets('错误验证码登录失败并显示错误提示', (tester) async {
      app.main();
      await tester.pumpAndSettle();

      await tester.enterText(
        find.byKey(Key('phone_input')),
        '13800138000',
      );
      await tester.enterText(
        find.byKey(Key('code_input')),
        '000000', // 错误验证码
      );
      await tester.tap(find.byKey(Key('login_button')));
      await tester.pumpAndSettle();

      // 验证错误提示出现
      expect(find.text('验证码错误'), findsOneWidget);
    });
  });
}

运行集成测试:

flutter test integration_test/login_flow_test.dart

注意:集成测试需要真实设备或模拟器,不能在 headless 环境下运行。

Mock 最佳实践

测试的核心是用 Mock 替换真实依赖,让测试稳定且快速。

http Mock

如果 Service 层依赖 HTTP 接口,用 mockito + http 的 MockClient:

import 'package:http/http.dart' as http;
import 'package:mockito/httputils.dart';

test('getProducts 返回正确数据', () async {
  final mockClient = MockClient();

  when(mockClient.get(Uri.parse('https://api.example.com/products')))
      .thenAnswer((_) async => http.Response(
            '[{"id":"1","name":"A","price":100}]',
            200,
          ));

  final service = ProductService(ApiClient(client: mockClient));
  final products = await service.getProducts();

  expect(products.length, 1);
  expect(products.first.name, 'A');
}

Repository Mock

对于 Repository 接口,用 mockito 生成 Mock:

([ProductRepository])
void main() {
  late MockProductRepository mockRepository;

  setUp(() {
    mockRepository = MockProductRepository();
  });

  test('repository 返回空列表时 service 也返回空列表', () async {
    when(mockRepository.getProducts(page: 1, pageSize: 20))
        .thenAnswer((_) async => []);

    final service = ProductService(mockRepository);
    final products = await service.getProducts(page: 1);

    expect(products.isEmpty, true);
  });
}

测试覆盖率

Flutter 项目可以用 coverage 工具生成测试覆盖率报告:

flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html

关注几个指标:

  • 行覆盖率:有多少行代码被执行
  • 分支覆盖率:有多少条件分支被覆盖

通常要求核心业务逻辑的行覆盖率在 80% 以上。

总结

Flutter 测试的几个要点:

  1. 单元测试:测试 Service、Repository、Model 等纯 Dart 类,用 mockito 隔离依赖
  2. Widget 测试:测试自定义组件的渲染和交互,确保 UI 按预期工作
  3. 集成测试:测试完整用户流程,在真实设备或模拟器上运行
  4. Mock 策略:依赖什么就 mock 什么,确保测试稳定且快速
  5. 覆盖率:核心逻辑要有覆盖率要求,避免测试盲区

测试写得好不好,有一个简单标准:当你改了一行代码后,测试能告诉你这行代码影响了什么功能。如果测试只是跑得过去但没有价值,那还不如不写。

最后更新 4/20/2026, 4:48:48 AM