为什么要认真写单测
PipeMonitor 的 Flutter 端有大量”看似简单但边界条件多”的逻辑:刻度算法要覆盖零跨度、超大跨度、预设候选越界;JSON 解析要兼容固件新老版本的不同通道数;命令仓库要同时处理”HTTP 终态”和”HTTP 只确认已下发、等 WS ack”两种语义。这些逻辑靠手测很难把所有分支都点到,一旦改一行代码就可能回归。
flutter_test 自带 group/test/expect/setUp/tearDown,不需要额外框架就能写出可读、可维护的单测。本文按四个真实测试文件讲组织方式和典型技巧。
测试组织与命名规范
每个被测对象对应一个 test/<name>_test.dart 文件,文件内用 group 把同一对象的多个用例归到一起。用例描述用一句完整的人话,而不是 "test1"、"test_select" 这种无信息短标识:
void main() {
group('selectChartAxisInterval', () {
test('保留预设范围内的累计量刻度', () { ... });
test('累计量跨度超过预设上限时动态放大刻度', () { ... });
test('无跨度时交给图表库自动处理', () { ... });
});
}
读用例名就能看出”这个函数承诺了什么”。group 名通常是被测函数/类名,test 名是行为契约。失败时 flutter test 输出会拼成 selectChartAxisInterval 累计量跨度超过预设上限时动态放大刻度,一眼定位是哪条契约破了。
group 还能嵌套,例如 history_point_test.dart 把所有用例归到 HistoryPoint.fromJson 组里,强调”这一组都在测同一个工厂构造函数的不同分支”:
void main() {
group('HistoryPoint.fromJson', () {
test('uses NaN for zero temperature when valid mask marks it invalid', () { ... });
test('keeps PT100 temperature when shared valid bit is set', () { ... });
test('keeps legacy numeric values when valid mask is absent', () { ... });
});
}
刻度算法的边界用例
selectChartAxisInterval 负责给 Y 轴挑一个可读刻度:先在字段预设候选里找,候选都不够大时退到”1/2/5 × 10ⁿ”动态生成。算法本身只有二十几行,但边界条件不少——零跨度、负跨度、NaN、超过预设上限。
double? selectChartAxisInterval({
required double min,
required double max,
required List<double> preferredIntervals,
int targetTickCount = 6,
}) {
assert(preferredIntervals.isNotEmpty);
assert(targetTickCount > 0);
final span = (max - min).abs();
if (!span.isFinite || span <= 0) return null;
final target = span / targetTickCount;
for (final interval in preferredIntervals) {
if (interval >= target) return interval;
}
return _niceIntervalAtLeast(target);
}
测试覆盖了三条关键路径:
const totalIntervals = [10.0, 20.0, 50.0, 100.0, 200.0, 500.0, 1000.0];
test('保留预设范围内的累计量刻度', () {
final interval = selectChartAxisInterval(
min: 0,
max: 6000,
preferredIntervals: totalIntervals,
);
expect(interval, 1000);
});
test('累计量跨度超过预设上限时动态放大刻度', () {
final interval = selectChartAxisInterval(
min: 0,
max: 1200000,
preferredIntervals: totalIntervals,
);
expect(interval, 200000);
expect(1200000 / interval!, lessThanOrEqualTo(6));
});
test('无跨度时交给图表库自动处理', () {
final interval = selectChartAxisInterval(
min: 42,
max: 42,
preferredIntervals: totalIntervals,
);
expect(interval, isNull);
});
三条用例对应三个分支:候选命中、退到 _niceIntervalAtLeast、零跨度早返回。第二条还多断言 1200000 / 200000 = 6 不超过 targetTickCount,把”动态放大后刻度数量仍受控”这个隐含契约也锁住了——否则算法可能在超大跨度下退化成几十个刻度。
第三条 min == max 返回 null,让 fl_chart 自己处理。这条看似简单,却是防止”除零导致 NaN 刻度”的关键护栏。
旧 payload 兼容:7 通道补 null 成 9 通道
Measurement.temperatureChannelCount = 9,但早期固件只上报 7 个温度通道(T0-T6,没有流量计温度 T7/T8)。fromJson 必须把 7 通道老帧补齐成 9 通道,缺位用 null 填充:
factory Measurement.fromJson(
Map<String, dynamic> j, {
DateTime? serverReceivedAt,
}) {
final tempList = (j['temp'] as List?)?.cast<num?>() ?? const [];
return Measurement(
// ...
// 不管设备实际给了几个温度通道,都补齐 T0-T8 九路(缺位为 null)。
temperatures: List.generate(
temperatureChannelCount,
(i) => i < tempList.length ? tempList[i]?.toDouble() : null,
),
// ...
);
}
测试分两条:9 通道新帧正常解析、7 通道老帧自动补 null:
test('parses both flowmeter temperature channels', () {
final measurement = Measurement.fromJson({
'ts': 1,
'seq': 2,
'temp': [22.1, 22.2, 22.3, 22.4, 22.5, 22.6, 22.7, 31.2, 32.3],
'valid': 0x7F,
});
expect(measurement.temperatures.length, Measurement.temperatureChannelCount);
expect(measurement.temperatures[7], 31.2);
expect(measurement.temperatures[8], 32.3);
expect(measurement.temperatureValid(7), isTrue);
expect(measurement.temperatureValid(8), isTrue);
});
test(
'pads legacy seven-channel payload with null flowmeter temperatures',
() {
final measurement = Measurement.fromJson({
'ts': 1,
'seq': 2,
'temp': [22.1, 22.2, 22.3, 22.4, 22.5, 22.6, 22.7],
'valid': 0x3F,
});
expect(measurement.temperatures.length, Measurement.temperatureChannelCount);
expect(measurement.temperatures[7], isNull);
expect(measurement.temperatures[8], isNull);
},
);
关键点是断言 temperatures.length 恒等于 temperatureChannelCount,而不是断言”等于输入长度”。这样后续 UI 代码可以无条件访问 temperatures[7],不用担心越界。temperatureValid(7) 用 isFinite 判断而非 validBits 位图——固件位图不可靠这条契约也顺带测了。
HistoryPoint:位图与旧数据的多分支
HistoryPoint.fromJson 要处理四种场景:位图标记无效(置 NaN)、位图标记有效(保留值)、位图共享位(PT100 共用 bit5)、完全没有 valid 字段的旧数据(按数值是否存在判断)。
factory HistoryPoint.fromJson(Map<String, dynamic> j, HistoryField field) {
final ts = _parseHistoryTimestamp(j['receivedAt']);
final validMask = (j['valid'] as num?)?.toInt();
// ...
final valid = _historyFieldValid(validMask, field);
final value = raw?.toDouble();
return HistoryPoint(
timestamp: ts,
// 缺测或位图判定无效的点用 NaN 占位,绘图库会自动断开折线。
value: valid && value != null && value.isFinite ? value : double.nan,
);
}
五条用例把四个分支全覆盖了:
test('uses NaN for zero temperature when valid mask marks it invalid', () {
final point = HistoryPoint.fromJson({
'receivedAt': '2026-05-16T04:00:00.000Z',
'temp': List<double>.filled(7, 0.0),
'valid': 0,
}, HistoryField.t6);
expect(point.value.isNaN, isTrue);
});
test('keeps PT100 temperature when shared valid bit is set', () {
final point = HistoryPoint.fromJson({
'receivedAt': '2026-05-16T04:00:00.000Z',
'temp': [22.1, 22.2, 22.3, 22.4, 22.5, 22.6, 24.5],
'valid': 1 << 5,
}, HistoryField.t6);
expect(point.value, 24.5);
});
test('keeps legacy numeric values when valid mask is absent', () {
final point = HistoryPoint.fromJson({
'receivedAt': '2026-05-16T04:00:00.000Z',
'temp': List<double>.filled(7, 0.0),
}, HistoryField.t6);
expect(point.value, 0.0);
});
test('keeps flowmeter temperature when its valid bit is set', () {
final point = HistoryPoint.fromJson({
'receivedAt': '2026-05-16T04:00:00.000Z',
'temp': [22.1, 22.2, 22.3, 22.4, 22.5, 22.6, 22.7, 31.2, 32.3],
'valid': 1 << 6,
}, HistoryField.t8);
expect(point.value, 32.3);
});
第一条测”位图为 0 时即便 temp 是 0.0 也要置 NaN”——这是为了防止误把”传感器未接”显示成”0 度”。第三条测”没有 valid 字段”的旧数据路径,_historyFieldValid 在 validMask == null 时直接返回 true,保留原始数值。这两条用例一起锁住了”新固件严格按位图、老固件按数值存在性”的兼容策略。
命令仓库的两态测试:Timer.run 模拟 WS 延迟 ack
CommandRepository.sendUploadPeriod 要处理两种 HTTP 响应:
- 终端型:HTTP 直接返回
CommandStatus.acked+result: 'ok',命令已完成,不需要等 WS。 - 已下发型:HTTP 只返回
CommandStatus.sent,命令已下发但设备还没回 ack,仓库必须继续等 WS 推送的AckEvent才能完成Future。
这两种语义在外观上都是”HTTP 返回 200 + 一个 Command 对象”,但仓库的后续行为完全不同。测试用两个 fake ApiService 分别模拟:
/// 固定返回已 ACK 命令,用来验证仓库不再强制依赖 WebSocket。
class _TerminalCommandApiService implements ApiService {
@override
Future<Command> sendUploadPeriod({required int seconds}) async {
return Command(
seq: 1,
cmd: 'set_upload_period',
params: {'seconds': seconds},
sentAt: DateTime.now(),
ackedAt: DateTime.now(),
status: CommandStatus.acked,
result: 'ok',
);
}
// 其余接口 throw UnimplementedError()
}
/// 固定返回已下发命令,用来验证仓库继续等待 WebSocket ACK。
class _SentCommandApiService extends _TerminalCommandApiService {
@override
Future<Command> sendUploadPeriod({required int seconds}) async {
return Command(
seq: 7,
cmd: 'set_upload_period',
params: {'seconds': seconds},
sentAt: DateTime.now(),
status: CommandStatus.sent,
);
}
}
_SentCommandApiService 继承 _TerminalCommandApiService 只覆写一个方法,省去重复实现其它无关接口——这是 Dart 单测里造 fake 的常用省力写法。
终端型用例直接 await,断言状态是 acked:
test('uses terminal HTTP command result without waiting for WS ack', () async {
final realtime = _SilentRealtimeService();
final repo = CommandRepository(
api: _TerminalCommandApiService(),
realtime: realtime,
);
addTearDown(() async {
await repo.dispose();
await realtime.dispose();
});
final result = await repo
.sendUploadPeriod(10)
.timeout(const Duration(milliseconds: 100));
final command = result.valueOrNull!;
expect(command.status, CommandStatus.acked);
expect(command.result, 'ok');
});
_SilentRealtimeService 不主动 emit 任何事件,确保仓库不会”误等”到 WS ack——如果仓库在终端型路径上错误地等了 WS,这条用例会因 .timeout(100ms) 超时失败。
已下发型用例是重头戏。仓库拿到 sent 后会订阅 realtime.events 等 AckEvent,测试需要”在仓库订阅完成之后、await future 之前”触发一次 emit。这里用 Timer.run 把 emit 排到下一个微任务回合:
test('waits for WS ack when HTTP command result is sent', () async {
final realtime = _SilentRealtimeService();
final repo = CommandRepository(
api: _SentCommandApiService(),
realtime: realtime,
);
addTearDown(() async {
await repo.dispose();
await realtime.dispose();
});
final future = repo.sendUploadPeriod(30);
// HTTP 只确认命令已下发时,仓库继续等待设备通过 WS 回 ACK。
Timer.run(
() => realtime.emit(
const AckEvent(cmdSeq: 7, cmd: 'set_upload_period', result: 'ok'),
),
);
final result = await future.timeout(const Duration(milliseconds: 100));
final command = result.valueOrNull!;
expect(command.seq, 7);
expect(command.cmd, 'set_upload_period');
expect(command.status, CommandStatus.acked);
expect(command.result, 'ok');
});
关键技巧拆解:
- 不先
await再 emit:repo.sendUploadPeriod(30)返回的是Future但不立即await,先把Future句柄存到future变量。这一步让仓库内部有机会发起 HTTP 调用并订阅 WS 流。 Timer.run排队 emit:Timer.run的回调会在当前微任务队列清空后、下一个事件循环执行。这保证了仓库的 WS 订阅已经就绪,emit出去的AckEvent一定能被收到——同时又足够”晚”,能模拟真实 WS ack 的延迟到达。cmdSeq: 7必须匹配:_SentCommandApiService返回的Command.seq = 7,emit 的AckEvent.cmdSeq也是 7。仓库会按 seq 匹配 ack 与 pending command,seq 不一致会丢弃,用例就挂了。.timeout(100ms)兜底:如果仓库实现错误(比如没订阅 WS、或 ack 匹配逻辑错了),future永远不会完成,100ms 后超时抛TimeoutException,用例失败而不是卡死。
_SilentRealtimeService 是一个最小可用的 RealtimeService fake,内部用 StreamController.broadcast() 暴露 events 流,emit 方法供测试主动注入事件:
class _SilentRealtimeService implements RealtimeService {
final _events = StreamController<RealtimeEvent>.broadcast();
@override
Stream<RealtimeEvent> get events => _events.stream;
void emit(RealtimeEvent event) => _events.add(event);
@override
bool get isConnected => false;
@override
Future<void> connect({required String token}) async {}
@override
Future<void> disconnect() async {}
@override
Future<void> reconnectIfNeeded() async {}
Future<void> dispose() => _events.close();
}
broadcast() 是必须的——仓库内部可能多次订阅 events,非广播流只能被订阅一次会直接抛异常。
setUp / addTearDown 的用法
四个测试文件里没有用 setUp,因为每个用例的 fake 配置都不同(不同 ApiService、不同 valid 位图),强行抽 setUp 反而要传参。但 command_repository_test.dart 在每条用例里都用了 addTearDown:
addTearDown(() async {
await repo.dispose();
await realtime.dispose();
});
addTearDown 相比 tearDown 的优势是”局部注册”——每个用例注册自己的清理回调,能访问当前作用域里的 repo 和 realtime 变量。而顶层 tearDown 拿不到用例内部的局部变量,只能清理 setUp 里创建的共享资源。
dispose 不能省:_SilentRealtimeService 内部的 StreamController 不关闭会泄漏,仓库的 WS 订阅不取消会留着监听已关闭的流。flutter_test 在测试结束时也会检测未关闭的 timer/stream,泄漏会报 warning。
小结
- 用
group按被测对象分组,test名写成完整的行为契约句,失败时输出能直接定位分支 - 算法测试覆盖所有分支出口:候选命中、退到动态生成、零跨度早返回,并断言隐含契约(如刻度数量上限)
- 兼容性测试断言”输出长度恒定”而非”等于输入长度”,让下游代码可以无条件索引
- 位图测试要同时覆盖”标记无效""共享位""无位图旧数据”三条路径,锁住兼容策略
- 两态命令测试用两个 fake
ApiService区分语义,Timer.run把 WS ack emit 排到下一回合模拟延迟到达 addTearDown比tearDown更适合”每个用例自己造资源自己清理”的场景,能访问局部变量.timeout是异步测试的兜底护栏,防止实现错误导致用例卡死