Dart: Dart 测试 — 从单元测试到集成测试

没有测试的代码是定时炸弹 — 测试是给未来自己的保险单。

1. 你将学到


2. 一个开发者的真实故事

(1) 痛点:重构后功能静默崩溃

Charlie 重构了 DataPipeline 的金额计算逻辑,将税率从固定值改为按地区查询。本地测试通过后部署,但重构意外影响了折扣叠加计算 — 折扣金额变成了负数,导致 5,000 笔订单的退款金额计算错误,直接损失 25,000 USD。如果有自动化测试,这个 bug 会在部署前被捕获。

(2) 测试体系的解法

Dart 的 test 包提供完善的测试框架,group 组织测试结构,expect + Matchers 提供丰富的断言,setUp/tearDown 管理测试环境。

DART
group('Order calculation', () {
  test('applies tax correctly', () {
    final order = Order(id: 'T1', amount: 1000.0);
    expect(order.calcTax(0.08), equals(80.0));
  });

  test('discount does not make total negative', () {
    final order = Order(id: 'T2', amount: 100.0, discountRate: 0.5);
    expect(order.total, greaterThan(0));
  });
});
TEXT 📖 仅展示
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。

(3) 收益


3. test 包基础

(1) 核心语法

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。

:基本测试

DART
// test/order_test.dart
import 'package:test/test.dart';

void main() {
  test('simple addition', () {
    expect(2 + 3, equals(5));
  });

  test('string contains', () {
    expect('DataPipeline v1.0', contains('Pipeline'));
  });

  test('list is not empty', () {
    expect([1, 2, 3], isNotEmpty);
  });
}
TEXT 📖 仅展示
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。

:group 组织测试

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

double calculateTax(double amount, double rate) => amount * rate;
double calculateTotal(double amount, double taxRate, double discountRate) {
  final discounted = amount * (1 - discountRate);
  return discounted * (1 + taxRate);
}

void main() {
  group('Tax calculation', () {
    test('standard rate', () {
      expect(calculateTax(1000.0, 0.08), equals(80.0));
    });

    test('zero rate', () {
      expect(calculateTax(1000.0, 0.0), equals(0.0));
    });

    test('high rate', () {
      expect(calculateTax(1000.0, 0.25), equals(250.0));
    });
  });

  group('Total calculation', () {
    test('no discount', () {
      expect(calculateTotal(1000.0, 0.08, 0.0), equals(1080.0));
    });

    test('with discount', () {
      expect(calculateTotal(1000.0, 0.08, 0.1), closeTo(972.0, 0.01));
    });

    test('full discount', () {
      expect(calculateTotal(1000.0, 0.08, 1.0), equals(0.0));
    });
  });
}
TEXT 📖 仅展示
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。

(2) 常用 Matchers

Matcher 含义 示例
equals(value) 相等 equals(5)
greaterThan(n) 大于 greaterThan(0)
lessThan(n) 小于 lessThan(100)
closeTo(num, delta) 近似相等 closeTo(3.14, 0.01)
contains(item) 包含 contains('key')
isEmpty 为空 isEmpty
isNotEmpty 非空 isNotEmpty
isNull 为 null isNull
isNotNull 不为 null isNotNull
isTrue / isFalse 布尔判断 isTrue
throwsException 抛异常 throwsException
isA``<Type>``() 类型检查 isA``<String>``()

4. setUp / tearDown

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。

:测试环境管理

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

class OrderBook {
  final List``<double>`` orders = [];

  void add(double amount) {
    if (amount <= 0) throw ArgumentError('Amount must be positive');
    orders.add(amount);
  }

  double get total => orders.fold(0.0, (a, b) => a + b);
  double get average => orders.isEmpty ? 0 : total / orders.length;
  int get count => orders.length;
}

void main() {
  late OrderBook book;

  setUp(() {
    // Runs before each test
    book = OrderBook();
  });

  tearDown(() {
    // Runs after each test
    // Cleanup if needed
  });

  group('OrderBook', () {
    test('starts empty', () {
      expect(book.count, equals(0));
      expect(book.total, equals(0.0));
    });

    test('add increases count', () {
      book.add(100.0);
      expect(book.count, equals(1));
      book.add(200.0);
      expect(book.count, equals(2));
    });

    test('total sums amounts', () {
      book.add(1500.0);
      book.add(3200.0);
      expect(book.total, equals(4700.0));
    });

    test('rejects negative amount', () {
      expect(() => book.add(-50.0), throwsArgumentError);
    });
  });
}
TEXT 📖 仅展示
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。

5. 单元测试

(1) 隔离测试原则

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。

:函数单元测试

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

// Functions to test
double parseAmount(String input) {
  final value = double.tryParse(input);
  if (value == null) throw FormatException('Invalid amount: $input');
  if (value <= 0) throw ArgumentError('Amount must be positive');
  return value;
}

String formatUSD(double amount) => '\$${amount.toStringAsFixed(2)} USD';

String classifyAmount(double amount) => switch (amount) {
  >= 10000 => 'Enterprise',
  >= 1000 => 'Premium',
  > 0 => 'Standard',
  _ => throw ArgumentError('Invalid amount'),
};

void main() {
  group('parseAmount', () {
    test('parses valid integer', () {
      expect(parseAmount('1500'), equals(1500.0));
    });

    test('parses valid decimal', () {
      expect(parseAmount('99.99'), closeTo(99.99, 0.001));
    });

    test('throws on invalid input', () {
      expect(() => parseAmount('abc'), throwsFormatException);
    });

    test('throws on negative', () {
      expect(() => parseAmount('-50'), throwsArgumentError);
    });

    test('throws on zero', () {
      expect(() => parseAmount('0'), throwsArgumentError);
    });
  });

  group('formatUSD', () {
    test('formats whole number', () {
      expect(formatUSD(1500), equals('\$1500.00 USD'));
    });

    test('formats decimal', () {
      expect(formatUSD(99.99), equals('\$99.99 USD'));
    });
  });

  group('classifyAmount', () {
    test('Enterprise tier', () {
      expect(classifyAmount(15000), equals('Enterprise'));
    });

    test('Premium tier', () {
      expect(classifyAmount(1500), equals('Premium'));
    });

    test('Standard tier', () {
      expect(classifyAmount(50), equals('Standard'));
    });
  });
}
TEXT 📖 仅展示
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。

6. Mock 与 fake

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。

:手动 fake

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

// Interface to mock
abstract class DataSource {
  Future<List``<String>``> fetchIds();
  Future``<double>`` fetchAmount(String id);
}

// Manual fake implementation
class FakeDataSource implements DataSource {
  final List``<String>`` _ids;
  final Map<String, double> _amounts;

  FakeDataSource({List``<String>``? ids, Map<String, double>? amounts})
      : _ids = ids ?? ['ORD-001', 'ORD-002'],
        _amounts = amounts ?? {'ORD-001': 1500.0, 'ORD-002': 3200.0};

  @override
  Future<List``<String>``> fetchIds() async => _ids;

  @override
  Future``<double>`` fetchAmount(String id) async =>
      _amounts[id] ?? throw Exception('Not found: $id');
}

// Service under test
class OrderService {
  final DataSource _source;

  OrderService(this._source);

  Future``<double>`` getTotalRevenue() async {
    final ids = await _source.fetchIds();
    double total = 0;
    for (final id in ids) {
      total += await _source.fetchAmount(id);
    }
    return total;
  }
}

void main() {
  group('OrderService', () {
    test('calculates total revenue', () async {
      final fakeSource = FakeDataSource(
        ids: ['ORD-001', 'ORD-002'],
        amounts: {'ORD-001': 1500.0, 'ORD-002': 3200.0},
      );
      final service = OrderService(fakeSource);

      expect(await service.getTotalRevenue(), equals(4700.0));
    });

    test('handles empty data source', () async {
      final fakeSource = FakeDataSource(ids: [], amounts: {});
      final service = OrderService(fakeSource);

      expect(await service.getTotalRevenue(), equals(0.0));
    });
  });
}
TEXT 📖 仅展示
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。

7. Bob 场景:DataPipeline 测试覆盖

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。

:核心逻辑测试

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

class Order {
  final String id;
  final double amount;
  final String status;
  final String category;
  final double? discountRate;

  Order({
    required this.id,
    required this.amount,
    required this.status,
    required this.category,
    this.discountRate,
  }) {
    if (amount <= 0) throw ArgumentError('Amount must be positive: $amount');
    if (id.isEmpty) throw ArgumentError('ID cannot be empty');
  }

  double get effectiveDiscount => discountRate ?? 0;
  double get discountedAmount => amount * (1 - effectiveDiscount);
  double get tax => discountedAmount * 0.08;
  double get total => discountedAmount + tax;
}

class OrderAnalyzer {
  static double totalRevenue(List``<Order>`` orders) =>
      orders.fold(0.0, (sum, o) => sum + o.total);

  static Map<String, double> revenueByCategory(List``<Order>`` orders) {
    final result = <String, double>{};
    for (final o in orders) {
      result.update(o.category, (v) => v + o.total, ifAbsent: () => o.total);
    }
    return result;
  }

  static List``<Order>`` filterCompleted(List``<Order>`` orders) =>
      orders.where((o) => o.status == 'completed').toList();
}

void main() {
  group('Order', () {
    test('calculates tax correctly', () {
      final order = Order(id: 'T1', amount: 1000.0, status: 'completed', category: 'E');
      expect(order.tax, closeTo(80.0, 0.01));
    });

    test('calculates total with discount', () {
      final order = Order(id: 'T2', amount: 1000.0, status: 'completed', category: 'E', discountRate: 0.1);
      expect(order.total, closeTo(972.0, 0.01));
    });

    test('rejects negative amount', () {
      expect(() => Order(id: 'T3', amount: -100, status: 'completed', category: 'E'),
          throwsArgumentError);
    });

    test('rejects empty ID', () {
      expect(() => Order(id: '', amount: 100, status: 'completed', category: 'E'),
          throwsArgumentError);
    });
  });

  group('OrderAnalyzer', () {
    late List``<Order>`` testOrders;

    setUp(() {
      testOrders = [
        Order(id: 'ORD-001', amount: 1500.0, status: 'completed', category: 'Electronics'),
        Order(id: 'ORD-002', amount: 3200.0, status: 'pending', category: 'Electronics'),
        Order(id: 'ORD-003', amount: 890.0, status: 'completed', category: 'Clothing'),
      ];
    });

    test('filters completed orders', () {
      final completed = OrderAnalyzer.filterCompleted(testOrders);
      expect(completed.length, equals(2));
      expect(completed.every((o) => o.status == 'completed'), isTrue);
    });

    test('calculates total revenue', () {
      final revenue = OrderAnalyzer.totalRevenue(testOrders);
      expect(revenue, greaterThan(0));
    });

    test('groups revenue by category', () {
      final byCategory = OrderAnalyzer.revenueByCategory(testOrders);
      expect(byCategory.containsKey('Electronics'), isTrue);
      expect(byCategory.containsKey('Clothing'), isTrue);
    });

    test('handles empty list', () {
      expect(OrderAnalyzer.totalRevenue([]), equals(0.0));
      expect(OrderAnalyzer.filterCompleted([]), isEmpty);
      expect(OrderAnalyzer.revenueByCategory([]), isEmpty);
    });
  });
}
TEXT 📖 仅展示
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。

8. 完整示例:DataPipeline 测试套件

DART
// ============================================
// DataPipeline Test Suite
// Comprehensive testing with groups, setup, and fakes
// ============================================

import 'package:test/test.dart';

// Production code
class PipelineConfig {
  final int batchSize;
  final double taxRate;
  final String outputFormat;

  PipelineConfig({
    this.batchSize = 10000,
    this.taxRate = 0.08,
    this.outputFormat = 'json',
  });

  PipelineConfig copyWith({int? batchSize, double? taxRate, String? outputFormat}) =>
      PipelineConfig(
        batchSize: batchSize ?? this.batchSize,
        taxRate: taxRate ?? this.taxRate,
        outputFormat: outputFormat ?? this.outputFormat,
      );
}

class Order {
  final String id;
  final double amount;
  final String status;
  final String category;

  Order({
    required this.id,
    required this.amount,
    required this.status,
    required this.category,
  }) {
    if (id.isEmpty) throw ArgumentError('Empty order ID');
    if (amount <= 0) throw ArgumentError('Amount must be positive');
  }
}

class ReportGenerator {
  final PipelineConfig config;

  ReportGenerator(this.config);

  Map<String, dynamic> generate(List``<Order>`` orders) {
    final completed = orders.where((o) => o.status == 'completed').toList();
    final revenue = completed.fold``<double>``(0, (s, o) => s + o.amount);
    final tax = revenue * config.taxRate;

    final byCategory = <String, int>{};
    for (final o in completed) {
      byCategory.update(o.category, (v) => v + 1, ifAbsent: () => 1);
    }

    return {
      'totalOrders': orders.length,
      'completedOrders': completed.length,
      'revenue': revenue,
      'tax': tax,
      'totalWithTax': revenue + tax,
      'byCategory': byCategory,
      'format': config.outputFormat,
    };
  }
}

// Tests
void main() {
  group('PipelineConfig', () {
    test('has default values', () {
      final config = PipelineConfig();
      expect(config.batchSize, equals(10000));
      expect(config.taxRate, equals(0.08));
      expect(config.outputFormat, equals('json'));
    });

    test('copyWith preserves unspecified fields', () {
      final original = PipelineConfig(batchSize: 50000);
      final modified = original.copyWith(taxRate: 0.10);
      expect(modified.batchSize, equals(50000));
      expect(modified.taxRate, equals(0.10));
    });
  });

  group('Order', () {
    test('creates valid order', () {
      final order = Order(id: 'ORD-001', amount: 1500.0, status: 'completed', category: 'E');
      expect(order.id, equals('ORD-001'));
      expect(order.amount, equals(1500.0));
    });

    test('rejects empty ID', () {
      expect(() => Order(id: '', amount: 100, status: 'ok', category: 'E'),
          throwsArgumentError);
    });

    test('rejects non-positive amount', () {
      expect(() => Order(id: 'X', amount: 0, status: 'ok', category: 'E'),
          throwsArgumentError);
      expect(() => Order(id: 'X', amount: -1, status: 'ok', category: 'E'),
          throwsArgumentError);
    });
  });

  group('ReportGenerator', () {
    late PipelineConfig config;
    late ReportGenerator generator;
    late List``<Order>`` testOrders;

    setUp(() {
      config = PipelineConfig(taxRate: 0.08);
      generator = ReportGenerator(config);
      testOrders = [
        Order(id: 'ORD-001', amount: 1500.0, status: 'completed', category: 'Electronics'),
        Order(id: 'ORD-002', amount: 50.0, status: 'completed', category: 'Books'),
        Order(id: 'ORD-003', amount: 3200.0, status: 'pending', category: 'Electronics'),
      ];
    });

    test('counts total and completed orders', () {
      final report = generator.generate(testOrders);
      expect(report['totalOrders'], equals(3));
      expect(report['completedOrders'], equals(2));
    });

    test('calculates revenue from completed only', () {
      final report = generator.generate(testOrders);
      expect(report['revenue'], equals(1550.0));
    });

    test('applies tax rate from config', () {
      final report = generator.generate(testOrders);
      expect(report['tax'], closeTo(124.0, 0.01));
    });

    test('groups by category', () {
      final report = generator.generate(testOrders);
      final byCategory = report['byCategory'] as Map<String, int>;
      expect(byCategory['Electronics'], equals(1));
      expect(byCategory['Books'], equals(1));
    });

    test('handles empty order list', () {
      final report = generator.generate([]);
      expect(report['totalOrders'], equals(0));
      expect(report['revenue'], equals(0.0));
    });

    test('respects output format config', () {
      final jsonGen = ReportGenerator(PipelineConfig(outputFormat: 'json'));
      final csvGen = ReportGenerator(PipelineConfig(outputFormat: 'csv'));
      expect(jsonGen.generate(testOrders)['format'], equals('json'));
      expect(csvGen.generate(testOrders)['format'], equals('csv'));
    });
  });
}
TEXT 📖 仅展示
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。

❓ 常见问题

Q:测试文件应该放在哪里? A:放在项目根目录的 test/ 文件夹下,文件名以 _test.dart 结尾。运行 dart test 自动发现所有测试。

Q:单元测试和集成测试有什么区别? A:单元测试隔离测试单个函数/类,不依赖外部资源;集成测试验证多个模块协作是否正确,可能涉及文件、数据库、网络。

Q:setUp 在 group 中可以嵌套吗? A:可以。外层 group 的 setUp 先执行,内层的后执行。适合共享基础设置 + 每组定制配置。

Q:mockito 和手动 fake 选哪个? A:简单接口用手动 fake(代码少、类型安全);复杂接口或需要验证调用次数时用 mockito。Dart 推荐优先手动 fake。

Q:如何测试异步代码? A:test 回调支持 async,直接用 test('...', () async { ... })。expect 也支持 Future。注意不要忘记 await。

Q:测试覆盖率怎么查看? A:运行 dart test --coverage=coverage,然后使用 coverage 包的 format_coverage 工具生成报告。

Q:如何测试抛出异常的代码? A:用 expect(() => someFunction(), throwsException) 或更具体的 throwsFormatExceptionthrowsArgumentError 等。


📖 小节


📝 作业

  1. 基础题(难度⭐):为一个 formatUSD(double amount) 函数编写 5 个单元测试:正常金额、零、负数、极大数、小数精度。
  2. 进阶题(难度⭐⭐):创建一个 FakeApiService 实现 DataSource 接口,用它测试 OrderServicegetTotalRevenue()getOrderById() 方法。包含成功和失败场景。
  3. 挑战题(难度⭐⭐⭐):为 DataPipeline 的 ReportGenerator 编写完整测试套件:使用 setUp 共享测试数据、group 按功能分组、测试边界条件(空列表、单条数据、全 pending 状态),覆盖率目标 ≥ 90%。

← 上一课 | 下一课 →

Web-Tutorial.com

Web-Tutorial 技术团队

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

100%

🙏 帮我们做得更好

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

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