Flutter: 测试 — 单元/Widget/集成测试

没有测试的代码是定时炸弹——你不知道它什么时候炸,只知道一定会炸。

📋 前置知识:需要先掌握以下内容

1. 你将学到


2. 一个线上 bug 的真实故事

(1) 痛点:手动测试覆盖不了

Bob 的 ShopApp 上线了一个优惠券功能,QA 手动测试了"正常流程"通过,但没测到"优惠券过期后使用"的边界情况。上线后 500 个用户用过期券下单,订单金额变成负数,数据库报错。修复用了 4 小时,损失 10 thousand USD。每个月类似问题至少发生 2 次。

(2) 自动化测试的解法

自动化测试可以在每次代码提交时运行,覆盖所有边界情况,确保新代码不破坏旧功能。

DART
import 'package:flutter_test/flutter_test.dart';

// ⚙️ 依赖:mocktail: ^1.0.0(添加到 dev_dependencies)
// CartNotifier / Coupon / Product 来自 ShopApp 项目,完整定义见第27课

class Coupon {
  final String code;
  final DateTime expiresAt;
  const Coupon({required this.code, required this.expiresAt});
}

test('coupon expired should show error', () {
  final cart = CartNotifier();
  cart.addItem(product);
  final result = cart.applyCoupon(Coupon(code: 'SAVE20', expiresAt: DateTime(2020)));
  expect(result, isFalse);
  expect(cart.error, contains('expired'));
});
TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

(3) 收益:bug 率降低 80%

Bob 建立测试体系后,每次提交自动跑 200+ 测试,上线 bug 率从 5 个/月降到 1 个/月。


3. 测试金字塔

100%
graph TD
    TP[Testing Pyramid] --> UT[Unit Tests: 70%]
    TP --> WT[Widget Tests: 20%]
    TP --> IT[Integration Tests: 10%]
    UT --> |mocktail| CN3[CartNotifier]
    WT --> |testWidgets| PV3[ProductListView]
    IT --> |integration_test| CK2[Checkout Flow]
TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
层级 数量占比 速度 依赖 测试范围
单元测试 70% 毫秒 Mock 纯逻辑(Notifier/Repository)
Widget 测试 20% 测试环境 UI 渲染 + 交互
集成测试 10% 分钟 真机/模拟器 完整用户流

4. 单元测试

(1) 项目配置

YAML
# pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter
  mocktail: ^1.0.0
  build_runner: ^2.4.0

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

:CartNotifier 单元测试

DART
// test/notifiers/cart_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

// ⚙️ 依赖:mocktail: ^1.0.0(添加到 dev_dependencies)
// ProductRepository / CartNotifier 来自 ShopApp 项目,完整定义见第27课

// 简化版 Product(完整定义见第26课 domain/entities/product.dart)
class Product {
  final int id;
  final String name;
  final double price;
  final String imageUrl;
  final String category;
  const Product({required this.id, required this.name, required this.price,
    required this.imageUrl, this.category = 'General'});
}

// 简化版 CartNotifier(完整定义见第27课)
class CartNotifier {
  List<_CartItem> state = [];
  String? error;

  void addItem(Product product) {
    final idx = state.indexWhere((i) => i.product.id == product.id);
    if (idx >= 0) {
      state = [...state]..[idx] = _CartItem(product: state[idx].product, quantity: state[idx].quantity + 1);
    } else {
      state = [...state, _CartItem(product: product, quantity: 1)];
    }
  }

  void updateQuantity(int productId, int quantity) {
    if (quantity <= 0) { removeItem(productId); return; }
    state = [for (final i in state) i.product.id == productId ? _CartItem(product: i.product, quantity: quantity) : i];
  }

  void removeItem(int productId) {
    state = state.where((i) => i.product.id != productId).toList();
  }

  void clear() { state = []; }

  double get total => state.fold(0.0, (sum, i) => sum + i.product.price * i.quantity);
}

class _CartItem {
  final Product product;
  final int quantity;
  _CartItem({required this.product, required this.quantity});
}

class MockProductRepository extends Mock implements ProductRepository {}

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

    setUp(() {
      cart = CartNotifier();
    });

    test('initial state is empty', () {
      expect(cart.state, isEmpty);
    });

    test('addItem adds product to cart', () {
      cart.addItem(testProduct);
      expect(cart.state.length, 1);
      expect(cart.state.first.product, testProduct);
      expect(cart.state.first.quantity, 1);
    });

    test('addItem increments quantity if product exists', () {
      cart.addItem(testProduct);
      cart.addItem(testProduct);
      expect(cart.state.length, 1);
      expect(cart.state.first.quantity, 2);
    });

    test('updateQuantity updates item quantity', () {
      cart.addItem(testProduct);
      cart.updateQuantity(testProduct.id, 5);
      expect(cart.state.first.quantity, 5);
    });

    test('updateQuantity removes item when quantity is 0', () {
      cart.addItem(testProduct);
      cart.updateQuantity(testProduct.id, 0);
      expect(cart.state, isEmpty);
    });

    test('removeItem removes product from cart', () {
      cart.addItem(testProduct);
      cart.removeItem(testProduct.id);
      expect(cart.state, isEmpty);
    });

    test('clear empties the cart', () {
      cart.addItem(testProduct);
      cart.addItem(anotherProduct);
      cart.clear();
      expect(cart.state, isEmpty);
    });

    test('total calculates correct sum', () {
      cart.addItem(testProduct);  // price: 99.99
      cart.addItem(anotherProduct);  // price: 49.99
      expect(cart.total, closeTo(149.98, 0.01));
    });
  });
}

const testProduct = Product(id: 1, name: 'Laptop', price: 99.99, imageUrl: '', category: 'Electronics');
const anotherProduct = Product(id: 2, name: 'Mouse', price: 49.99, imageUrl: '', category: 'Electronics');
TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

:Repository 单元测试(带 Mock)

DART
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:dio/dio.dart';

// ⚙️ 依赖:mocktail: ^1.0.0, dio: ^5.0.0(添加到 dev_dependencies / dependencies)
// ProductRepository / DioClient 来自 ShopApp 项目,完整定义见第27课

class MockDioClient extends Mock implements DioClient {}

void main() {
  group('ProductRepository', () {
    late ProductRepository repo;
    late MockDioClient mockClient;

    setUp(() {
      mockClient = MockDioClient();
      repo = ProductRepository(mockClient);
    });

    test('getProducts returns list of products', () async {
      // Arrange
      when(() => mockClient.get('/products', queryParams: any(named: 'queryParams')))
          .thenAnswer((_) async => Response(
            data: {'items': [{'id': 1, 'name': 'Laptop', 'price': 1299.99, 'category': 'Electronics'}]},
            statusCode: 200, requestOptions: RequestOptions(path: '/products'),
          ));

      // Act
      final products = await repo.getProducts();

      // Assert
      expect(products.length, 1);
      expect(products.first.name, 'Laptop');
      verify(() => mockClient.get('/products', queryParams: any(named: 'queryParams'))).called(1);
    });

    test('getProducts throws on network error', () async {
      when(() => mockClient.get('/products', queryParams: any(named: 'queryParams')))
          .thenThrow(DioException(requestOptions: RequestOptions(path: '/products')));

      expect(() => repo.getProducts(), throwsA(isA<DioException>()));
    });
  });
}
TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

5. Widget 测试

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

:商品卡片 Widget 测试

DART
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

// Product / ProductCard 来自 ShopApp 项目,完整定义见第26/27课

// 简化版 Product
class Product {
  final int id;
  final String name;
  final double price;
  final String imageUrl;
  final String category;
  const Product({required this.id, required this.name, required this.price,
    required this.imageUrl, this.category = 'General'});
}

// 简化版 ProductCard
class ProductCard extends StatelessWidget {
  final Product product;
  final VoidCallback onAddToCart;
  const ProductCard({super.key, required this.product, required this.onAddToCart});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Column(children: [
        Text(product.name),
        Text('\$${product.price.toStringAsFixed(2)}'),
        if (product.price < 50) const Text('SALE'),
        IconButton(icon: const Icon(Icons.add_shopping_cart), onPressed: onAddToCart),
      ]),
    );
  }
}

const testProduct = Product(id: 1, name: 'Laptop', price: 99.99, imageUrl: '', category: 'Electronics');

void main() {
  group('ProductCard', () {
    testWidgets('displays product name and price', (tester) async {
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(body: ProductCard(
          product: testProduct,
          onAddToCart: () {},
        )),
      ));

      expect(find.text('Laptop'), findsOneWidget);
      expect(find.text('\$99.99'), findsOneWidget);
    });

    testWidgets('calls onAddToCart when button tapped', (tester) async {
      var tapped = false;
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(body: ProductCard(
          product: testProduct,
          onAddToCart: () => tapped = true,
        )),
      ));

      await tester.tap(find.byIcon(Icons.add_shopping_cart));
      expect(tapped, isTrue);
    });

    testWidgets('shows SALE badge when price < 50', (tester) async {
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(body: ProductCard(
          product: const Product(id: 1, name: 'Cheap Item', price: 29.99, imageUrl: '', category: 'All'),
          onAddToCart: () {},
        )),
      ));

      expect(find.text('SALE'), findsOneWidget);
    });
  });
}
TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

:购物车页面 Widget 测试

DART
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// ⚙️ 依赖:flutter_riverpod: ^2.0.0(添加到 dependencies)
// CartPage / cartProvider / EmptyCartNotifier / PrefilledCartNotifier 来自 ShopApp 项目

void main() {
  group('CartPage', () {
    testWidgets('shows empty state when cart is empty', (tester) async {
      await tester.pumpWidget(ProviderScope(
        overrides: [cartProvider.overrideWith(() => EmptyCartNotifier())],
        child: const MaterialApp(home: CartPage()),
      ));

      expect(find.text('Cart is empty'), findsOneWidget);
    });

    testWidgets('shows items and total when cart has items', (tester) async {
      await tester.pumpWidget(ProviderScope(
        overrides: [cartProvider.overrideWith(() => PrefilledCartNotifier())],
        child: const MaterialApp(home: CartPage()),
      ));

      await tester.pumpAndSettle();

      expect(find.text('Laptop'), findsOneWidget);
      expect(find.text('Mouse'), findsOneWidget);
      expect(find.textContaining('\$149.98'), findsOneWidget);
    });

    testWidgets('increments quantity on plus tap', (tester) async {
      await tester.pumpWidget(ProviderScope(
        overrides: [cartProvider.overrideWith(() => PrefilledCartNotifier())],
        child: const MaterialApp(home: CartPage()),
      ));

      await tester.pumpAndSettle();

      // Find first increment button and tap
      await tester.tap(find.byIcon(Icons.add).first);
      await tester.pumpAndSettle();

      // Verify quantity changed
      expect(find.text('2'), findsOneWidget);
    });
  });
}
TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

6. 集成测试

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

:结账流程端到端测试

DART
// integration_test/checkout_test.dart
import 'package:flutter/material.dart';
import 'package:integration_test/integration_test.dart';

// ⚙️ 依赖:integration_test SDK(添加到 dev_dependencies)
// app(ShopApp 主入口)来自项目 lib/main.dart

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('Checkout Flow', () {
    testWidgets('complete purchase end-to-end', (tester) async {
      app.main();
      await tester.pumpAndSettle();

      // 1. Browse products
      expect(find.text('ShopApp'), findsOneWidget);

      // 2. Add product to cart
      await tester.tap(find.byIcon(Icons.add_shopping_cart).first);
      await tester.pumpAndSettle();

      // 3. Navigate to cart
      await tester.tap(find.byIcon(Icons.shopping_cart));
      await tester.pumpAndSettle();

      // 4. Verify cart has item
      expect(find.textContaining('\$'), findsWidgets);

      // 5. Tap checkout
      await tester.tap(find.text('Checkout'));
      await tester.pumpAndSettle();

      // 6. Fill checkout form
      await tester.enterText(find.byType(TextFormField).at(0), 'Alice Smith');
      await tester.enterText(find.byType(TextFormField).at(1), '123 Main St');
      await tester.pumpAndSettle();

      // 7. Submit order
      await tester.tap(find.textContaining('Pay'));
      await tester.pumpAndSettle();

      // 8. Verify order confirmation
      expect(find.text('Order Confirmed'), findsOneWidget);
    });
  });
}
TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

7. 测试最佳实践

实践 说明
AAA 模式 Arrange → Act → Assert
每个测试独立 setUp/setUpEach 初始化,不依赖其他测试
Mock 外部依赖 网络/数据库/平台 API 用 Mock 替代
测试命名描述性强 test('addItem increments quantity when product exists')
Widget 测试用 pumpAndSettle 等待所有动画和异步操作完成

❓ 常见问题

Q 单元测试和 Widget 测试怎么区分?
A 单元测试不渲染 UI,只测逻辑;Widget 测试在测试环境渲染 Widget,验证 UI 和交互。
Q mocktail 和 mockito 有什么区别?
A mocktail 是 mockito 的 Dart 原生版本,不需要代码生成,API 更简洁。推荐 mocktail。
Q pump 和 pumpAndSettle 有什么区别?
A pump 执行一帧;pumpAndSettle 执行所有帧直到没有新帧(动画结束/异步完成)。
Q 集成测试怎么在 CI 跑?
Aflutter test integration_test/ 在 Android 模拟器上跑。iOS 需要在 macOS runner 上。
Q Golden 测试怎么维护?
A 首次运行生成 golden 文件,后续运行对比。UI 变化时用 --update-goldens 更新。注意不同平台渲染差异。
Q Riverpod 的 Provider 怎么在测试中 override?
AProviderScope(overrides: [myProvider.overrideWith(...)]) 替换真实 Provider 为测试版本。

📖 小节


📝 作业

  1. 基础题(难度⭐):为 CartNotifier 写 5 个单元测试:初始空、添加、重复添加、删除、清空。
  2. 进阶题(难度⭐⭐):为 ProductCard 写 Widget 测试:显示商品名/价格、点击加购按钮触发回调、显示 SALE 徽标。
  3. 挑战题(难度⭐⭐⭐):实现完整的测试套件:CartNotifier 单元测试(8+用例) + CartPage Widget 测试(5+用例) + 结账集成测试(完整用户流)。

← 上一课 | 下一课 →

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏