Dart: Dart 测试 — 从单元测试到集成测试
没有测试的代码是定时炸弹 — 测试是给未来自己的保险单。
1. 你将学到
- test 包:test() / group() / expect() 与 Matchers
- 单元测试:函数与类的隔离测试
- 集成测试:多模块协作验证
- Mock 与 fake:mockito / fake_async
- Bob 场景:DataPipeline 核心逻辑的测试覆盖
2. 一个开发者的真实故事
(1) 痛点:重构后功能静默崩溃
Charlie 重构了 DataPipeline 的金额计算逻辑,将税率从固定值改为按地区查询。本地测试通过后部署,但重构意外影响了折扣叠加计算 — 折扣金额变成了负数,导致 5,000 笔订单的退款金额计算错误,直接损失 25,000 USD。如果有自动化测试,这个 bug 会在部署前被捕获。
(2) 测试体系的解法
Dart 的 test 包提供完善的测试框架,group 组织测试结构,expect + Matchers 提供丰富的断言,setUp/tearDown 管理测试环境。
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));
});
});
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。
(3) 收益
- 重构后自动运行测试,5 分钟覆盖 200+ 测试用例
- 边界条件(负金额、空列表、null 值)不再遗漏
- 测试覆盖率从 0% 提升到 85%,回归 bug 减少 90%
3. test 包基础
(1) 核心语法
▶ 示例
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。
:基本测试
// 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);
});
}
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。
▶ 示例
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。
:group 组织测试
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));
});
});
}
> **输出:** 在本地 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
▶ 示例
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。
:测试环境管理
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);
});
});
}
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。
5. 单元测试
(1) 隔离测试原则
▶ 示例
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。
:函数单元测试
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'));
});
});
}
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。
6. Mock 与 fake
▶ 示例
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。
:手动 fake
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));
});
});
}
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。
7. Bob 场景:DataPipeline 测试覆盖
▶ 示例
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。
:核心逻辑测试
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);
});
});
}
> **输出:** 在本地 DartPad 或 `dart run` 执行。Dart 课程所有示例基于 Dart 3.x / Flutter 3.x,运行结果会因 SDK 版本略有差异。
8. 完整示例:DataPipeline 测试套件
// ============================================
// 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'));
});
});
}
> **输出:** 在本地 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)或更具体的throwsFormatException、throwsArgumentError等。
📖 小节
- test 包提供 test/group/expect 三大核心 API,搭配丰富的 Matchers
- group 组织测试结构,setUp/tearDown 管理测试环境
- 单元测试隔离单个函数/类,不依赖外部资源
- fake 实现替代真实依赖,让测试快速且可重复
- DataPipeline 测试覆盖:Order 验证、ReportGenerator 计算、边界条件
📝 作业
- 基础题(难度⭐):为一个
formatUSD(double amount)函数编写 5 个单元测试:正常金额、零、负数、极大数、小数精度。 - 进阶题(难度⭐⭐):创建一个
FakeApiService实现DataSource接口,用它测试OrderService的getTotalRevenue()和getOrderById()方法。包含成功和失败场景。 - 挑战题(难度⭐⭐⭐):为 DataPipeline 的 ReportGenerator 编写完整测试套件:使用 setUp 共享测试数据、group 按功能分组、测试边界条件(空列表、单条数据、全 pending 状态),覆盖率目标 ≥ 90%。