设备软件测试最现实的困难是硬件稀缺:开发机旁没有整机,实验设备不能随时制造故障,运动和加热又会让测试变慢甚至带来风险。结果往往是单元测试只覆盖工具类,真正重要的状态机、恢复流程和设备协同只能等到现场联调。
解决办法不是用 Mock 把所有 SDK 调用设成成功,而是建立逐级增加真实性的测试体系:纯逻辑模型验证规则,行为模拟器提供时间和故障,协议回放保护边界,最后用 Hardware-in-the-Loop(HIL)验证真实接口、时序和接线。
1. 按风险建立测试梯度
设备软件可以采用以下测试层次:
| 层次 | 主要对象 | 速度与环境 | 擅长发现的问题 |
|---|
| 纯逻辑测试 | 转换函数、校验器、计算模型 | 毫秒级,无 I/O | 状态规则、边界值、算法错误 |
| 组件测试 | 流程执行器 + Fake/Simulator | 快,可并行 | 超时、取消、恢复和资源竞争 |
| 协议/契约测试 | 适配器 + 模拟服务或回放 | 中等 | 报文解析、兼容性、错误映射 |
| 软件集成测试 | 完整进程 + 虚拟设备 | 中等 | DI、配置、持久化、进程生命周期 |
| HIL | 上位机 + 真实控制器/仿真负载 | 慢,受实验台约束 | 驱动、接线、时序和真实固件差异 |
| 整机验收 | 完整设备与受控工况 | 最慢 | 端到端功能、性能和安全验证 |
层次越高越接近现场,但覆盖组合越困难。HIL 不应替代低层测试;它应该聚焦只有真实接口才能发现的风险,而不是重复一万组 Recipe 边界值。
2. Fake、Stub、Mock 和 Simulator 各有用途
这些测试替身解决的问题不同:
- Stub 为特定输入返回预设结果,适合简单分支;
- Mock 重点验证交互是否发生,适合边界协作;
- Fake 是可工作的轻量实现,例如内存仓储;
- Simulator 模拟状态随命令和时间变化,并能够注入设备故障。
设备流程通常更需要 Simulator。一个始终立即成功的 IAxis 无法发现“移动尚未完成就开始采集”、取消后轴继续移动、回零前误用绝对坐标等问题。
下面的最小模拟轴保留了位置、运动耗时和故障注入:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
| public sealed class SimulatedAxis(TimeProvider timeProvider) : IAxis
{
private readonly object _sync = new();
private Millimeters _position = new(0);
private CancellationTokenSource? _activeMove;
public bool FailNextMove { get; set; }
public ValueTask<AxisSnapshot> ReadAsync(
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_sync)
{
return ValueTask.FromResult(new AxisSnapshot(
_position,
IsMoving: _activeMove is not null,
IsServoOn: true,
PositiveLimit: false,
NegativeLimit: false,
FaultCode: null));
}
}
public async ValueTask MoveAbsoluteAsync(
Millimeters target,
MillimetersPerSecond speed,
CancellationToken cancellationToken)
{
if (speed.Value <= 0)
{
throw new ArgumentOutOfRangeException(nameof(speed));
}
if (FailNextMove)
{
FailNextMove = false;
throw new DeviceFaultException("DriveFault", "SIM-001");
}
var moveCancellation =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
lock (_sync)
{
if (_activeMove is not null)
{
moveCancellation.Dispose();
throw new InvalidOperationException("The axis is already moving.");
}
_activeMove = moveCancellation;
}
try
{
double seconds = Math.Abs(target.Value - _position.Value) /
speed.Value;
await Task.Delay(
TimeSpan.FromSeconds(seconds),
timeProvider,
moveCancellation.Token);
lock (_sync)
{
_position = target;
}
}
finally
{
lock (_sync)
{
if (ReferenceEquals(_activeMove, moveCancellation))
{
_activeMove = null;
}
moveCancellation.Dispose();
}
}
}
public ValueTask StopAsync(
AxisStopMode mode,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_sync)
{
_activeMove?.Cancel();
}
return ValueTask.CompletedTask;
}
}
|
这是教学版:它会阻止并发移动,并把停止简化为取消等待、保持起点位置,并没有模拟加速度、中间位置和停止距离。模拟器不是越逼真越好,而是要忠实实现测试关注的契约;未实现的行为应明确说明,避免团队把它误当数字孪生。
3. 把时间变成可控制依赖
真实等待会让超时和重试测试又慢又不稳定。.NET 8+ 内置 TimeProvider,Microsoft.Extensions.TimeProvider.Testing 包提供 FakeTimeProvider,可以在测试中推进虚拟时间。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| using Microsoft.Extensions.Time.Testing;
var clock = new FakeTimeProvider(
new DateTimeOffset(2026, 8, 15, 0, 0, 0, TimeSpan.Zero));
var axis = new SimulatedAxis(clock);
ValueTask moving = axis.MoveAbsoluteAsync(
new Millimeters(100),
new MillimetersPerSecond(20),
CancellationToken.None);
clock.Advance(TimeSpan.FromSeconds(5));
await moving;
AxisSnapshot snapshot = await axis.ReadAsync(CancellationToken.None);
if (snapshot.ActualPosition != new Millimeters(100))
{
throw new InvalidOperationException("Simulation result is incorrect.");
}
|
包名是 Microsoft.Extensions.TimeProvider.Testing,命名空间是 Microsoft.Extensions.Time.Testing,两者不要混淆。虚拟时间只能控制使用同一个 TimeProvider 的代码;若业务中仍直接调用 DateTime.Now、Task.Delay 或 System.Threading.Timer,测试就会重新依赖真实时间。
4. 用故障脚本描述场景
仅有 FailNextMove 很快不够。可以把故障注入描述成脚本:
1
2
3
4
5
6
7
8
| Given 轴已回零,当前位置 10 mm
When 收到移动到 100 mm 的命令
And 运动 40% 时通信中断
Then 上位机进入 PositionUnknown
And 不再接受后续绝对移动
And 保存目标、最后观测位置和控制器错误码
When 通信恢复并重新读取编码器
Then 根据回读和恢复策略决定重新回零或人工确认
|
脚本应覆盖命令前失败、执行中失败、执行完成但确认丢失这三个关键时间窗口。对于并发场景,还要控制事件顺序、重复和迟到,而不是依赖线程调度“碰巧复现”。
5. 契约测试保护真实与模拟实现
同一个 IAxis 可以有厂商适配器和模拟器,但实现同一接口不等于语义一致。为接口建立一组契约测试,分别运行在每个实现上:
- 未回零时绝对移动是否被拒绝;
- 超出软限位时是否在动作前失败;
- 返回完成时是否满足约定的到位条件;
- 取消后动作和状态如何变化;
- 重复停止和关闭是否幂等;
- 原始错误码是否保留。
真实硬件无法在普通 CI 中运行所有契约时,可以给测试打环境标签,在专用实验台定时执行;模拟器仍需在每次提交中执行相同的通用部分。
6. 记录回放用于协议和现场问题
记录回放(Record/Replay)能够把一次真实交互转成可重复输入。记录内容可包括时间间隔、请求字节、响应字节、连接事件和关键环境版本。回放有三种常见模式:
- 严格回放:请求必须与记录完全一致,适合协议回归;
- 状态回放:根据当前模拟状态生成响应,适合流程测试;
- 扰动回放:在原记录中插入延迟、丢包、断连或坏帧,适合鲁棒性验证。
回放数据要脱敏,特别是序列号、客户 Recipe、生产标识和访问凭据。还要记录协议/固件版本,否则几年后无法判断样本代表哪个实现。
7. HIL 验证软件无法伪造的部分
HIL 将真实控制器、I/O 模块或驱动器接到仿真负载上,让上位机面对真实通信栈和固件(半导体设备的领域化实践见《半导体设备软件(十):仿真、单元测试与 Hardware-in-the-Loop》)。它适合验证:
- 驱动安装、位数和 SDK 依赖;
- 电平、接线、信号极性和抖动;
- 控制器扫描周期、缓冲和响应时序;
- 断电、复位、拔线和看门狗行为;
- 多设备同时运行时的资源与时序边界。
HIL 台必须有物理保护、运动范围和自动复位方案。测试脚本不能因为运行在“实验环境”就绕过急停和联锁。破坏性测试应使用专用工装,并由风险评估决定是否允许自动执行。
8. 测试断言最终事实
“调用过 MoveAbsoluteAsync 一次”只证明编排器发出了意图。更有价值的断言包括:
- 设备最终状态和实际位置;
- Recipe 与校准快照是否绑定到运行记录;
- 失败后是否拒绝危险的后续命令;
- 补偿动作是否在正确前提下执行;
- 报警、诊断证据和审计事件是否完整;
- 重启后是否从可信检查点恢复。
交互次数仍有用,但它是验证边界协议的手段,不应代替业务结果。
总结
设备软件需要从纯逻辑、行为模拟、协议契约、完整软件、HIL 到整机验收的测试梯度。模拟器应表现时间、状态和失败,TimeProvider 让时间相关测试可控,契约测试防止真实与仿真实现语义漂移,HIL 则专注真实接口和时序风险。
下一篇将完成系列:建立日志、指标、追踪、审计和诊断包,并把软件升级设计成可验证、可回退的设备变更流程。
参考资料