本文主要介绍了我在开发中引入单元测试的实践
写在前面(我遇到的现状)
我想,在大多数对软件质量没有严格管理或者本身产品要求不高的情况下,有很多的嵌入式开发人员也许都是遵循了这样一套开发模式:
- 编码实现一个功能/算法
- 手工调用这个功能/算法,工程师自主判断结果是否与预期符合
- 如果通过,继续编码实现下一个功能
- 重复上述直到整体开发完成
- 对整体的软件进行测试
不去讨论上述开发模式存在的隐患与风险,也不去评价此模式好坏。万事万物皆有其生存之道,黑猫白猫能抓到猫就是好猫。
"敏捷"开发示例
我想,很多开发人员都不可能一次将功能开发正确与完整(天才忽略)。为了引入单元测试的技能和思想,我们先来模拟一下上面的开发过程。
以一个简易的示例来说:
日常开发中,求平均值是很常见的计算方法。在现实世界,求平均值的思路就是明确的,给定数据集,求和,最后总和除以数据集的个数就能得到平均值;然而编码的实现远没有那么简单,我们需要去圈定一个范围:
- 数据集的构成?小数还是整数
- 数据集的上下限?最大值跟最小值的可能情况
闲话少说,这里我直接圈定一个集合:
- 数据集都是正数且都是整数
- 数据集最小值0,最大值1000
- 数据集的最大数据量为64个数据
我们用C语言实现求平均值的计算:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
uint16_t u16_calculate_buffer_average(uint16_t* buffer, uint8_t size) { uint16_t* v = buffer; uint16_t average = 0u; for(uint8_t i = 0; i < size; i++) { average += v[i]; } average = (uint16_t)(average / size); return average; }
|
程序实现很简单,因为数据在0到1000之间,我们选择无符号16位的变量,考虑到最大值1000,即使64次累加也不会溢出,所以average变量也选择16位无符号数据。
实现之后我们需要手动测试下这个计算方法是否正确,在main函数中指定一个数据集,并测试计算结果是否与预期一致
1 2 3 4 5 6 7 8 9 10 11 12
| static uint16_t data_fifo[64] = {0}; int main(void) { for (uint8_t i = 0; i < 64; i++) { data_fifo[i] = i * 2; } uint16_t res = u16_calculate_buffer_average(data_fifo, 64);
printf("result=%d\n", res); }
|
从代码上看,从0开始,步进1的等差数列的平均值计算结果,口算为:
2×64(0+126)×64=63
按照手动自测模式,计算平均值的功能就算初步实现完成了,在随机测试几组数据集,就可以进一步开发别的功能了。
潜在的Bug风险
或者说,上面这个计算平均值的策略真的万无一失吗?还真不一定。我们以从0开始,步进为1得等差数列为例:
2×64(0+63)×64=31.5
该数据集的实际平均值为31.5,在计算机程序中,整型数据会直接向下取整,没有四舍五入的操作,最终的结果是31,由此也可判断,如果某个数据集的计算结果为31.9999…,最终也会为31整数值;实际上,原数值应该更加靠近32才对。
这种情况下,就需要评估该误差是否对最终功能有没有影响,如果误差不可忽略,那么这就是一个潜在的bug,简单的手工测试并不能每次都会测出来这样的结果,或者说不同的工程师对该问题的敏感度不同,容易忽略。
更新与迭代
假设上述计算平均值方法运行良好,且无bug出现,但是,某一天,使用该方法的需求变了,数据集因为某些原因变成如下:
- 数据集都是正数且都是整数
- 数据集最小值0,最大值1000
- 数据集的最大数据量为500个数据
因为在未来的时间点,我想大部分人都不会记得该平均值计算的具体实现的,这时候又会重新检查每一行代码,然后再手工测试。
回到代码实现本身,原先某些实现就不适用了,需要在此考虑溢出和取值的范围问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
- uint16_t u16_calculate_buffer_average(uint16_t* buffer, uint8_t size) { + uint16_t u16_calculate_buffer_average(uint16_t* buffer, uint16_t size) { uint16_t* v = buffer; - uint16_t average = 0u; + uint32_t average = 0u; - for(uint8_t i = 0; i < size; i++) { + for(uint16_t i = 0; i < size; i++) { average += v[i]; } average = (uint16_t)(average / size); return average; }
|
再为新的数据集修正了实现后,依旧需要进行手工测试。
引入单元测试后
如果我们事先建立一个标准的测试流程来测试计算平均值的策略,在代码有所更改的情况下,自动化执行该测试流程,就省去了二次手工测试的麻烦;并且,标准的测试流程应尽可能覆盖住多数的测试情况。
单元测试应用示例(潜在bug覆盖性测试)
以GoogleTest框架(测试框架和配置使用不是本章讨论的内容)为例,我们针对计算平均值指定这样一套完备的测试流程:
- 输入数据集的最大临界值,所有值都是1000的情况,数据个数为64
- 输入数据集的最小临界值,所有值都是0的情况,数据个数为64
- 输入数据集为常规值,固定为从0开始,步进为1的数据集,共64个数据
- 输入数据集为常规值,固定为从0开始,步进为1的数据集,共64个数据,但是最后一个数据为37(验证平均值向下接近的情况)
- 输入数据集为常规值,固定为从0开始,步进为1的数据集,共64个数据,但是最后一个数据为90(验证平均值向上接近的情况)
- 输入数据集为常规值,固定为从0开始,步进为17的数据集,共64个数据
- 输入数据为256,输入数据长度为1
- 输入数据集为常规值,固定为从0开始,步进为21的数据集,输入数据长度为32
以上测试覆盖了输入参数的上下临界值测试,以及非正常数据的测试,代码实现如下:
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
| TEST(u16_calculate_buffer_average, data_max_input) { uint16_t data_fifo[64] = {0}; for (uint8_t i = 0; i < 64; i++) { data_fifo[i] = 1000; } uint16_t res = u16_calculate_buffer_average(data_fifo, 64); EXPECT_EQ(res, 1000); }
TEST(u16_calculate_buffer_average, data_min_input) { uint16_t data_fifo[64] = {0}; for (uint8_t i = 0; i < 64; i++) { data_fifo[i] = 0; } uint16_t res = u16_calculate_buffer_average(data_fifo, 64); EXPECT_EQ(res, 0); }
TEST(u16_calculate_buffer_average, result_mid_round) { uint16_t data_fifo[64] = {0}; for (uint8_t i = 0; i < 64; i++) { data_fifo[i] = i; } uint16_t res = u16_calculate_buffer_average(data_fifo, 64); EXPECT_EQ(res, 32); }
TEST(u16_calculate_buffer_average, result_down_round) { uint16_t data_fifo[64] = {0}; for (uint8_t i = 0; i < 63; i++) { data_fifo[i] = i; } data_fifo[63] = 37; uint16_t res = u16_calculate_buffer_average(data_fifo, 64); EXPECT_EQ(res, 31); }
TEST(u16_calculate_buffer_average, result_up_round) { uint16_t data_fifo[64] = {0}; for (uint8_t i = 0; i < 63; i++) { data_fifo[i] = i; } data_fifo[63] = 90; uint16_t res = u16_calculate_buffer_average(data_fifo, 64); EXPECT_EQ(res, 32); }
TEST(u16_calculate_buffer_average, size_max) { uint16_t data_fifo[64] = {0}; for (uint8_t i = 0; i < 64; i++) { data_fifo[i] = i * 17; } uint16_t res = u16_calculate_buffer_average(data_fifo, 64); EXPECT_EQ(res, 536); }
TEST(u16_calculate_buffer_average, size_min) { uint16_t data = 256; uint16_t res = u16_calculate_buffer_average(&data, 1); EXPECT_EQ(res, 256); }
TEST(u16_calculate_buffer_average, size_mid) { uint16_t data_fifo[32] = {0}; for (uint8_t i = 0; i < 32; i++) { data_fifo[i] = i * 21; } uint16_t res = u16_calculate_buffer_average(data_fifo, 32); EXPECT_EQ(res, 326); }
|
执行单元测试后,我们发现,只剩下四舍五入没有通过,要实现四舍五入有很多方式:
- 用浮点数计算后进行round操作
- 求和的结果追加0.5个数据长度值

这里我们采用第二种方法来修正这个bug:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
uint16_t u16_calculate_buffer_average(uint16_t* buffer, uint8_t size) { uint16_t* v = buffer; uint16_t average = 0u; for(uint8_t i = 0; i < size; i++) { average += v[i]; } + average = average + size / 2; average = (uint16_t)(average / size); return average; }
|
随后我们重新执行整个测试流程:

在修正了四舍五入的bug之后,测试就全部通过了
更新与迭代
在未来某一天,因为底层数据集个数发生变化:
数据集的最大数据量为64个数据
- 数据集的最大数据量为500个数据
在之前测试用例的基础上,只需要稍加修改测试临界值的一个测试用例即可:
输入数据集的最大临界值,所有值都是1000的情况,数据个数为64
- 输入数据集的最大临界值,所有值都是1000的情况,数据个数为500
1 2 3 4 5 6 7 8 9 10 11
| TEST(u16_calculate_buffer_average, size_max) { - uint16_t data_fifo[64] = {0}; + uint16_t data_fifo[500] = {0}; - for (uint8_t i = 0; i < 64; i++) { + for (uint16_t i = 0; i < 500; i++) { data_fifo[i] = 1000; } - uint16_t res = u16_calculate_buffer_average(data_fifo, 64); + uint16_t res = u16_calculate_buffer_average(data_fifo, 500); EXPECT_EQ(res, 1000); }
|
在原有版本的基础上测试无法通过,需要去修改相应的计算逻辑了。
当然,这里也许会有疑问,为啥不一开始就将计算平均值考虑到非常大的计算值呢?
这是个好问题,如果经常写嵌入式代码,特别是在资源不那么充足的单片机,甚至8位机上,数据的位宽影响是很明显的;当然本文这里也有为了举例而举例的嫌疑。
总之,考虑到人工测试中一测而过不留痕迹容易忽视潜在bug,以及功能迭代,在嵌入式开发中引入单元测试框架还是很有必要的。
结束语
下一章,我将介绍如何搭建适用于嵌入式开发的单元测试框架。