05 测试与调试

C 程序员的测试工具箱通常只有 assert 和手工打印:gdb 打断点、printf 大法、写个 main 跑一遍。这套方法在单文件程序里够用,但在有异步、并发、UI 状态的应用里会迅速失效。Dart 提供了完整的工程化方案:package:test 做单元与集成测试,mocktail/mockito 做依赖替身,dart test --coverage 产出覆盖率报告,DevTools 提供断点、性能与内存视图。本章按”写测试 → 跑测试 → 调试 → 定位性能”的顺序讲清这套工具链,重点是那些让测试假通过的陷阱。

前置知识:09 异步编程(Future/Stream)、04 元编程与代码生成(mockito 需要 build_runner)。


一、测试金字塔在 Dart 中的落地

flowchart TB
    E2E["集成测试 integration_test<br/>真机/浏览器全流程,慢且脆,数量最少"]
    WIDGET["Widget 测试 flutter_test<br/>单页面渲染与交互,中速"]
    UNIT["单元测试 package:test<br/>纯函数与类,毫秒级,数量最多"]
    UNIT --> WIDGET --> E2E
层级依赖运行速度适合验证
单元测试package:test毫秒级算法、模型、服务逻辑
Widget 测试flutter_test百毫秒级组件渲染、点击、状态变化
集成测试integration_test秒级完整用户流程、平台交互

原则:能用单元测试验证的逻辑,不要用 UI 测试兜底。底层测试越快越稳定,反馈循环越短。

维度CDart
断言assertNDEBUG 下关闭)expect,失败带完整上下文与堆栈
测试组织手写 main 逐个调用test/group 自动发现、并发执行
依赖替身手写 stub 或用链接替换mocktail/mockito 打桩与验证
异步验证手工轮询/信号量expectLater + completion/emitsInOrder
覆盖率gcov + lcovdart test --coverage + format_coverage
调试gdb 断点与调用栈DevTools:断点、火焰图、内存快照
日志printf/stderrdeveloper.log 带级别、错误与堆栈

二、package:test 基础

2.1 目录与依赖

# pubspec.yaml
dev_dependencies:
  test: ^1.25.0

约定:测试放在与 lib/ 平行的 test/ 目录,文件名以 _test.dart 结尾,dart test 会自动发现。

flowchart TB
    ROOT["项目根/"] --> LIB["lib/"]
    ROOT --> TEST["test/"]
    ROOT --> PUB["pubspec.yaml"]
    LIB --> CALC["calculator.dart"]
    TEST --> CT["calculator_test.dart"]
    TEST --> WT["weather_test.dart"]

2.2 基本结构:testgroupexpect

// lib/calculator.dart
class Calculator {
  int add(int a, int b) => a + b;
 
  double divide(int a, int b) {
    if (b == 0) throw ArgumentError('除数不能为 0');
    return a / b;
  }
 
  Future<int> addAsync(int a, int b) async => a + b;
 
  Stream<int> countdown(int from) async* {
    for (var i = from; i > 0; i--) {
      yield i;
    }
  }
}
// test/calculator_test.dart
import 'package:test/test.dart';
import 'package:calculator/calculator.dart';
 
void main() {
  group('Calculator', () {           // group 把相关测试归类,输出更清晰
    late Calculator calc;
 
    setUp(() {                       // 每个 test 之前执行:隔离状态
      calc = Calculator();
    });
 
    test('两数相加', () {
      expect(calc.add(2, 3), equals(5));
    });
 
    test('除以零抛 ArgumentError', () {
      expect(() => calc.divide(1, 0), throwsA(isA<ArgumentError>()));
    });
 
    test('异步加法', () async {
      await expectLater(calc.addAsync(1, 2), completion(3));
    });
 
    test('倒计时流按序发射', () {
      expect(calc.countdown(3), emitsInOrder([3, 2, 1, emitsDone]));
    });
 
    test('浮点比较用 closeTo', () {
      expect(calc.divide(1, 3), closeTo(0.3333, 0.0001));
    });
  });
}

2.3 常用 matcher

Matcher语义示例
equals(x)值相等(调用 ==expect(result, equals(5))
isA<T>()类型判断expect(e, isA<ArgumentError>())
isNull / isNotNull空判断expect(value, isNull)
contains(x)集合/字符串包含expect(list, contains(3))
hasLength(n)长度expect(text, hasLength(5))
greaterThan / lessThan比较expect(age, greaterThan(18))
closeTo(v, d)浮点近似expect(pi, closeTo(3.14, 0.01))
throwsA(m)抛指定异常expect(fn, throwsA(isA<StateError>()))
returnsNormally不抛异常expect(fn, returnsNormally)
same(obj)同一对象(identical)expect(a, same(b))
unorderedEquals忽略顺序的集合相等expect(set, unorderedEquals([1, 2]))
everyElement(m)每个元素满足expect(list, everyElement(greaterThan(0)))
anyOf / allOf逻辑组合expect(x, anyOf(1, 2, 3))

2.4 生命周期与组织

钩子执行时机
setUp每个 test
tearDown每个 test 后(无论成败)
setUpAll整个 group 开始前一次
tearDownAll整个 group 结束后一次

命名建议:test('当余额不足时转账应失败', ...) 描述行为与预期,而不是 test('testTransfer', ...)。失败的测试名就是最好的 bug 报告。


三、异步测试

异步是测试假通过的重灾区。规则只有一条:测试函数必须 await 所有异步操作,否则 test 在回调执行前就结束了。

import 'package:test/test.dart';
 
Future<int> delayedValue() async {
  await Future<void>.delayed(const Duration(milliseconds: 10));
  return 42;
}
 
void main() {
  // 正确:await 等待 Future 完成
  test('Future 返回值', () async {
    expect(await delayedValue(), 42);
  });
 
  // 正确:expectLater 直接对 Future 断言(无需先 await 取值)
  test('Future 完成值', () async {
    await expectLater(delayedValue(), completion(42));
  });
 
  // 错误示范:没有 await,断言在 test 结束后才执行,失败也不会被报告
  // test('假通过', () {
  //   Future.delayed(const Duration(milliseconds: 10), () {
  //     expect(1, 2);
  //   });
  // });
}

异步 matcher 速查:

Matcher用于说明
completesFuture成功完成即可
completion(v)Future完成值为 v
throwsA(m)Future / 函数异步或同步异常
emitsInOrder([...])Stream按顺序发射指定值
emitsThrough(v)Stream至少发射到 v
emitsError(m)Stream发射错误
emitsDoneStream流正常关闭

四、Mock:给依赖做替身

单元测试要隔离外部依赖(网络、数据库、时间),用 mock 对象替代真实实现。Dart 两大方案:

维度mocktailmockito
代码生成不需要需要 build_runner 生成 .mocks.dart
上手成本低,直接继承 Mock需维护生成步骤
空安全支持原生@GenerateMocks
适用绝大多数项目(推荐)已有 mockito 生态的项目
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
 
class WeatherApi {
  Future<String> fetch(String city) async => '晴';
}
 
class MockWeatherApi extends Mock implements WeatherApi {}
 
class WeatherService {
  final WeatherApi _api;
  final Map<String, String> _cache = {};
  WeatherService(this._api);
 
  Future<String> get(String city) async =>
      _cache[city] ??= await _api.fetch(city);
}
 
void main() {
  test('缓存命中时不重复调用 API', () async {
    final api = MockWeatherApi();
    when(() => api.fetch('北京')).thenAnswer((_) async => '晴');
 
    final service = WeatherService(api);
    final first = await service.get('北京');
    final second = await service.get('北京');
 
    expect(first, '晴');
    expect(second, '晴');
    verify(() => api.fetch('北京')).called(1);   // 只真实请求了一次
  });
}

打桩 API:when(() => mock.method(arg)).thenReturn(v) / .thenAnswer((_) async => v) / .thenThrow(e);验证 API:verify(() => mock.method(any())).called(n)。用 any() 匹配自定义类型参数时,必须先 registerFallbackValue(自定义类型实例),否则运行时报错。


五、dart test 常用参数

参数作用示例
--name / -N只跑名称匹配正则的测试dart test -N '相加'
--plain-name精确子串匹配dart test --plain-name '两数相加'
--concurrency / -j并发测试文件数dart test -j 4
--coverage输出覆盖率数据目录dart test --coverage=coverage
--reporter输出格式expanded / compact / json / github
--platform / -p运行平台vm / chrome / node
--fail-fast首个失败即停止dart test --fail-fast
--chain-stack-traces显示完整异步调用链调试异步失败时用
dart test                                   # 跑全部
dart test test/calculator_test.dart         # 跑单个文件
dart test -N 'Calculator' -j 1              # 只跑匹配组,串行执行
dart test --reporter expanded               # 逐条输出,CI 里更易读

六、覆盖率报告

# 1. 跑测试并收集覆盖率数据(生成 coverage/ 目录下的 json)
dart test --coverage=coverage
 
# 2. 安装 coverage 工具(只需一次)
dart pub global activate coverage
 
# 3. 把 json 汇总为 lcov.info,--report-on 指定统计范围
dart run coverage:format_coverage \
  --lcov --in=coverage --out=coverage/lcov.info --report-on=lib
 
# 4. 生成 HTML 报告(需系统安装 lcov 工具)
genhtml coverage/lcov.info -o coverage/html
指标含义建议
行覆盖率执行过的代码行占比核心逻辑 80% 以上
分支覆盖率if/switch 各分支覆盖关注错误分支是否被测
文件覆盖率有测试的文件占比别追求 100%,生成代码排除

注意:覆盖率只说明”代码被执行过”,不代表断言有效。一个没有任何 expect 的测试也能刷满覆盖率。


七、DevTools 调试

7.1 启动调试

# 方式一:以调试模式运行程序,输出 DevTools 地址
dart run --observe bin/main.dart
 
# 方式二:先启动 DevTools 界面,再连接正在运行的应用
dart devtools

VS Code / Android Studio 中直接按 F5,调试器会自动接管。

7.2 核心能力

视图能做什么
Debugger断点、条件断点、单步(step over/into/out)、查看局部变量与调用栈
PerformanceCPU 火焰图,定位热点函数与卡顿帧
Memory堆快照、对象分配追踪,排查内存泄漏
Network查看 HTTP 请求与响应(Flutter)
App Size分析产物体积构成(Flutter)
Logging集中查看 print/log 输出

调试异步代码时,在 await 之后的行打断点,观察变量是否被意外重置;性能问题先在 Performance 视图抓一段火焰图,再决定是否搬进 Isolate(见 02 Isolate 与并发)。


八、日志:print 的替代品

方式平台特点
print()全平台简单;Flutter 高频输出可能丢日志
debugPrint()Flutter节流输出,避免 Android 日志丢失;Release 下仍输出
developer.log()全平台带级别、名称、错误与堆栈,DevTools 可过滤
dart:iostderr.writeln服务端与标准错误流对接,容器日志友好
import 'dart:developer' as developer;
 
void main() {
  developer.log('服务启动', name: 'app', level: 800);   // 800=INFO
 
  try {
    throw StateError('演示异常');
  } catch (e, st) {
    developer.log('捕获异常', name: 'app', error: e, stackTrace: st);
  }
}

九、常见坑

坑 1:异步未等待导致假通过。 测试里启动 Future 却不 awaittest 提前结束,失败断言永远不会上报。规则:测试体里每个 Future 都要 await 或返回。

坑 2:全局状态污染。 测试共享单例、静态缓存或环境变量时,执行顺序会决定成败。用 setUp 重建被测对象;无法重建的全局量用 tearDown 恢复。

坑 3:测试互相依赖。 测试 A 依赖测试 B 的副作用时,单独运行 A 会失败。每个测试必须能独立运行,且顺序无关。

坑 4:并发执行踩共享资源。 dart test 默认并发跑多个测试文件,它们可能同时读写同一个临时文件或端口。改用随机端口/临时目录,或用 -j 1 串行。

坑 5:mock 未注册 fallback。 any() 匹配自定义类型时抛 MissingStubError 或类型错误。在 setUpAll 里调用 registerFallbackValue(实例)

坑 6:测试依赖真实时间。 Future.delayed 让测试变慢且不稳定。用 fake_async 包虚拟推进时间,或把时钟抽象成可注入依赖。

坑 7:只跑 dart analyze 就以为测过了。 静态分析发现不了逻辑错误,CI 里必须同时跑 dart analyzedart test


本章小结

知识点一句话
测试金字塔单元最多、Widget 居中、集成最少
基本结构test/group/expectsetUp 隔离状态
matcherequals/isA/contains/throwsA/closeTo 覆盖常见断言
异步测试必须 awaitexpectLater + completion/emitsInOrder
Mockmocktail 免生成,mockito 需 build_runner
命令-N 过滤、-j 并发、--reporter 控制输出
覆盖率--coverage 收集,format_coverage 转 lcov,genhtml 出报告
DevTools断点调试、CPU 火焰图、内存快照、网络面板
日志developer.log 带级别与堆栈,优于裸 print
陷阱异步未等待、状态污染、互相依赖、依赖真实时间

练习

题号题目链接知识点
1两数之和https://leetcode.cn/problems/two-sum/测试编写、哈希表