13、状态机测试:单元测试框架选择、测试用例设计、覆盖率分析

状态机写完了,代码编译通过,功能仿真也跑了几轮。嗯,是不是就可以交付了?

我早年吃过这个亏。有一次我写了一个通信协议的状态机,自测时觉得逻辑完美,结果联调时发现某个边界状态根本进不去。从那以后,我养成了一个习惯:状态机必须做系统的单元测试,而且要看覆盖率。今天我们就聊聊这件事。

13.1 单元测试框架怎么选?

嵌入式C语言的单元测试框架不少,但真正适合状态机测试的,我个人觉得就三个主流选择。

框架 特点 适合场景
CUnit 轻量、无依赖、纯C 资源受限的嵌入式环境
Unity 极简、可配合CMock做桩 中小型项目、快速上手
Google Test 功能强大、支持C++ PC端模拟测试、复杂场景

我个人习惯用Unity + CMock的组合。为什么?因为状态机测试经常需要模拟外部输入,比如按键事件、传感器数据。Unity的桩函数生成能力,能帮你快速隔离外部依赖。

我的经验: 如果你在MCU上跑测试,选CUnit或Unity。如果你在Linux或Windows上做模拟测试,Google Test更顺手。别在资源受限的芯片上硬跑Google Test,那会把自己逼疯。

13.2 测试用例怎么设计?

状态机的测试用例,说白了就是覆盖所有状态和所有事件。但光覆盖还不够,你得考虑边界和异常。

13.2.1 状态覆盖

每个状态至少进一次。比如一个门禁状态机有IDLE、CARD_READ、OPEN、ALARM四个状态,那你的测试用例必须让状态机依次进入这四个状态。

13.2.2 事件覆盖

每个事件在每个状态下都要测试。举个例子,门禁状态机有刷卡、超时、关门三个事件。那你要测试:

  • IDLE状态下刷卡 → 进入CARD_READ
  • IDLE状态下超时 → 应该忽略或报错
  • OPEN状态下关门 → 回到IDLE
  • ALARM状态下刷卡 → 应该忽略
关键点: 状态机测试最容易漏掉的是“非法事件”。比如在OPEN状态下触发超时,状态机应该怎么处理?很多新手只测正常路径,结果线上出bug。

13.2.3 边界条件

我曾经遇到过一个状态机,它的超时计数器是8位无符号整数。测试时只测了正常超时,没测计数器溢出。结果产品运行三天后,计数器溢出,状态机直接卡死。嗯,从那以后我每次都会加一条:测试计数器的最大值和溢出行为

13.3 覆盖率分析怎么做?

覆盖率不是越高越好,但状态机这种逻辑密集型代码,覆盖率低了肯定不行。我一般关注三个指标:

覆盖率类型 含义 状态机中的目标
语句覆盖 每行代码至少执行一次 100%
分支覆盖 每个if/else、switch case都走到 100%
MC/DC覆盖 每个条件独立影响结果 安全关键系统要求

对于状态机,我建议至少做到分支覆盖100%。因为状态机里大量使用switch-case,每个case就是一个分支。漏掉一个case,就意味着某个状态-事件组合没测到。

注意: 覆盖率工具本身会引入一些插桩代码,可能影响实时性。在性能敏感的嵌入式系统里,建议在PC端做覆盖率分析,而不是在目标板上。

13.4 一个完整的测试示例

下面我用Unity框架,给一个简单的交通灯状态机写测试。状态机有三个状态:GREEN、YELLOW、RED,事件是定时器超时。

// 状态机头文件 traffic_light.h
typedef enum {
    STATE_GREEN,
    STATE_YELLOW,
    STATE_RED
} TrafficLightState;

typedef enum {
    EVENT_TIMEOUT
} TrafficLightEvent;

TrafficLightState traffic_light_next_state(TrafficLightState current, TrafficLightEvent event);
// 测试文件 test_traffic_light.c
#include "unity.h"
#include "traffic_light.h"

void setUp(void) {}
void tearDown(void) {}

void test_green_to_yellow(void) {
    TEST_ASSERT_EQUAL(STATE_YELLOW, 
        traffic_light_next_state(STATE_GREEN, EVENT_TIMEOUT));
}

void test_yellow_to_red(void) {
    TEST_ASSERT_EQUAL(STATE_RED, 
        traffic_light_next_state(STATE_YELLOW, EVENT_TIMEOUT));
}

void test_red_to_green(void) {
    TEST_ASSERT_EQUAL(STATE_GREEN, 
        traffic_light_next_state(STATE_RED, EVENT_TIMEOUT));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_green_to_yellow);
    RUN_TEST(test_yellow_to_red);
    RUN_TEST(test_red_to_green);
    return UNITY_END();
}

你看,这个测试覆盖了三个状态和事件的组合。但还不够——如果状态机收到非法事件呢?比如在GREEN状态下收到一个“紧急车辆”事件?那就要再加测试用例。

13.5 知识体系总览

下面这张图,是我对状态机测试核心逻辑的总结。你写测试时,按这个流程走,基本不会漏。

状态机测试核心流程 选择测试框架 设计测试用例 执行并分析覆盖率 框架选择要点 • 资源受限 → CUnit/Unity • PC模拟 → Google Test • 需要桩函数 → Unity+CMock 测试用例设计 • 每个状态至少进一次 • 每个事件在每个状态测试 • 边界条件(计数器溢出等) 覆盖率目标 • 语句覆盖 100% • 分支覆盖 100% • MC/DC(安全关键系统) 测试通过 & 覆盖率达标 → 状态机可交付

13.6 避坑指南

最后,分享几个我踩过的坑:

  • 别只测正常路径。 我曾经有一个状态机,所有正常状态切换都测了,但漏了“状态机初始化后立即收到事件”的情况。结果上电瞬间状态机就崩了。
  • 覆盖率不是万能的。 100%分支覆盖不代表没有bug。比如两个条件组合起来可能产生意想不到的结果。这时候需要MC/DC覆盖。
  • 测试代码也是代码。 别写完测试就不管了。我见过有人测试代码里写死循环,结果CI跑了一整天没结束。
我的习惯: 每次修改状态机逻辑后,先跑一遍已有的测试用例,再补新的。这样能快速发现回归问题,不至于改一个地方崩一片。

好了,状态机测试就聊到这里。记住:测试不是浪费时间,是在帮你省钱。一个线上bug的修复成本,是单元测试发现bug的几十倍。你想想看,是不是这个道理?

公众号:蓝海资料掘金营,微信deep3321