为什么要认真写单测

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 字段”的旧数据路径,_historyFieldValidvalidMask == null 时直接返回 true,保留原始数值。这两条用例一起锁住了”新固件严格按位图、老固件按数值存在性”的兼容策略。

命令仓库的两态测试:Timer.run 模拟 WS 延迟 ack

CommandRepository.sendUploadPeriod 要处理两种 HTTP 响应:

  1. 终端型:HTTP 直接返回 CommandStatus.acked + result: 'ok',命令已完成,不需要等 WS。
  2. 已下发型: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.eventsAckEvent,测试需要”在仓库订阅完成之后、await future 之前”触发一次 emit。这里用 Timer.runemit 排到下一个微任务回合:

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 再 emitrepo.sendUploadPeriod(30) 返回的是 Future 但不立即 await,先把 Future 句柄存到 future 变量。这一步让仓库内部有机会发起 HTTP 调用并订阅 WS 流。
  • Timer.run 排队 emitTimer.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 的优势是”局部注册”——每个用例注册自己的清理回调,能访问当前作用域里的 reporealtime 变量。而顶层 tearDown 拿不到用例内部的局部变量,只能清理 setUp 里创建的共享资源。

dispose 不能省:_SilentRealtimeService 内部的 StreamController 不关闭会泄漏,仓库的 WS 订阅不取消会留着监听已关闭的流。flutter_test 在测试结束时也会检测未关闭的 timer/stream,泄漏会报 warning。

小结

  • group 按被测对象分组,test 名写成完整的行为契约句,失败时输出能直接定位分支
  • 算法测试覆盖所有分支出口:候选命中、退到动态生成、零跨度早返回,并断言隐含契约(如刻度数量上限)
  • 兼容性测试断言”输出长度恒定”而非”等于输入长度”,让下游代码可以无条件索引
  • 位图测试要同时覆盖”标记无效""共享位""无位图旧数据”三条路径,锁住兼容策略
  • 两态命令测试用两个 fake ApiService 区分语义,Timer.run 把 WS ack emit 排到下一回合模拟延迟到达
  • addTearDowntearDown 更适合”每个用例自己造资源自己清理”的场景,能访问局部变量
  • .timeout 是异步测试的兜底护栏,防止实现错误导致用例卡死

后续阅读